feat(emergent-time): calculus of emergent time + Agentic Time primitive (#561)

* feat(emergent-time): calculus of emergent time + Agentic Time primitive

Add `crates/emergent-time`, a dependency-free Rust implementation of the
calculus of emergent/relational time, plus a new agentic-time primitive and
an honest multi-clock benchmark.

Physics formalisms (each verified by tests):
- Wheeler-DeWitt timeless constraint H|Psi>=0 (kernel solver, residual ~1e-15)
- Page-Wootters relational clock: Schrodinger evolution emerges from a static
  entangled state via conditioning (fidelity 1.0)
- Entropic time tau_S=(S-S0)/k (cold-atom analogue; speed tracks dS/dlambda)
- Connes-Rovelli thermal time: modular Hamiltonian K=-ln rho, modular flow
  A(s)=e^{isK}A e^{-isK} (recovers rescaled physical evolution for Gibbs states)

Numerical core: self-contained complex scalars, real symmetric Jacobi
eigensolver, complex unitary evolution via spectral exponentiation, von Neumann
entropy via a real-symmetric Hermitian embedding.

Agentic time:
- Structural Proper Time: internal time as arc length through the state manifold
- Agentic Time tau_a=f(dB,dM,dR,dG,dE,dP) with explainable ticks (class+reason),
  Agentic Time Index, and a 7-state health classifier
- Four-clock benchmark (wall/step/token/agentic). On the bundled synthetic
  traces, structural time warns 2.8x earlier than the entropy clock and agentic
  time gives a 40-step lead where wall/step/token give 0, preserving causal order

Includes a walkthrough example, criterion benches, and ADR-251 documenting
Agentic Time as a proposed Ruflo/RuVector/RuQu runtime primitive.

39 tests passing, clippy clean.

https://claude.ai/code/session_01ApBCSaebKsCzLeA7JhvDvU

* fix(emergent-time): M1 correctness + honesty hardening

Five corroborated-review fixes that raise rigor/honesty without touching
the sound numerical core (Jacobi eigensolver, spectral exp, state/complex/
entropy unchanged).

FIX 1 — explain() noise-floor contract (agentic_time.rs): document that
per-channel Tick fields are RAW (pre-floor) weighted contributions while
`delta` is post-floor max(0, Σchannels − noise_floor); the identity
delta==Σchannels holds only when noise_floor==0. New test
explain_delta_is_post_floor_channels_are_pre_floor asserts the floor=0.1
case (delta strictly < Σchannels) and the clamp-to-0 case.

FIX 2 — Wheeler–DeWitt falsifiability (wheeler_dewitt.rs): module doc now
states the kernel is trivial-by-construction for the energy-matched clock;
existing "kernel" tests relabelled as consistency checks; new discriminating
test generic_clock_yields_empty_physical_space builds Ĵ from a generic
H_C ≠ −H_R and asserts NO eigenvalue within 1e-9 of zero (empty physical
space), with a deterministic perturbation guard and an eigenvalue-sum bound.

FIX 3 — entropic non-tautological test (entropic.rs): docstring softened to
"β-swept Gibbs ensemble" (a temperature sweep, not closed-system dynamics);
tautological tau test renamed tau_reparametrization_formula_is_exact; new
internal_time_spacing_tracks_measured_entropy_production verifies the clock
rate against independently finite-differenced gibbs_entropy and that the
entropy curve is non-trivial and correctly signed.

FIX 4 — Page–Wootters honesty docstring (page_wootters.rs): scope is
real-symmetric H; Born-rule weighting holds only for pure global states;
single-time conditional states only — Kuchař two-time objection out of scope.

FIX 5 — fair baseline + de-hype (agentic_time.rs, examples/emergent_time.rs):
new WindowedDeltaClock rolling-window z-score change-point detector (the
non-strawman baseline the constant-rate wall/step/token clocks were missing).
On the designed trace the fair baseline fires at least as early as the agentic
clock; example output and test relabel the headline as a coverage-gap demo,
not a competitive win. Honest finding: agentic clock does NOT beat a fair
baseline on synthetic data — real-trace head-to-head is M3 work.

ADR-251: adds "Honest limitations" section (WD constructive-not-discovery,
entropic β-sweep, benchmark coverage-gap-not-win, PW scope) and prior-art
note (ADWIN; Ostovar 2016 concept-drift in process mining) stating what is
new (physics-grounded composite state-arc-length runtime primitive).

cargo test -p emergent-time: 43 passed (39 baseline + 4 new); build/clippy
clean; example prints the fair baseline.

Co-Authored-By: claude-flow <ruv@ruv.net>

* perf(emergent-time): M2 performance + robustness (P1/P2/R1/R4)

Numerical core unchanged — pure speed (P1/P2) plus guardrails (R1/R4)
that do not alter valid-input results. All 49 tests pass (43 original
+ 6 new); clippy clean; physics fidelity/entropy/modular values
unchanged.

P1 — stop re-diagonalizing (complex_matrix.rs, page_wootters.rs)
  - Add exp_i_from_spectrum / exp_i_apply_from_spectrum: spectral
    exp(iθH) from a PRECOMPUTED (eigvals, V), no re-diagonalization.
    exp_i_symmetric now routes through exp_i_from_spectrum.
  - PageWootters caches |ψ0| and evolves in the cached energy eigenbasis:
    schrodinger_state(t) = Σ_k e^{-iE_k t}⟨E_k|ψ0⟩|E_k⟩, O(n²)/t, no
    propagator matrix. From-scratch path kept as
    schrodinger_state_from_scratch for callers holding only H.
  - Bench (n16): cached 666 ns vs from-scratch 35.3 µs → ~53x.
  - New test cached_evolution_equals_from_scratch_propagator (1e-12).

P2 — hoist t-independent static state (page_wootters.rs)
  - global_static_state |Ψ| (d²) built once in new(), cached; per-t
    conditional_state conditions the cached vector.
  - Bench page_wootters_conditional_n8: 294 ns → 225 ns (~1.3x).

R1 — restore entropy guardrail (entropy.rs)
  - Replace silent `p > 1e-12` clamp with standard von-Neumann `p > 0.0`
    (skips only 0·ln0; keeps legitimate tiny probabilities; roundoff
    negatives contribute 0). Add debug-only PSD + normalization
    validation so a non-PSD/non-normalized ρ surfaces in dev.
  - New tests: roundoff-negative [0.5,0.5,-1e-15]→ln2, tiny-positive not
    clamped, non-PSD/non-normalized trip debug_assert (debug-only).

R4 — relative Jacobi convergence + non-convergence guard (real_matrix.rs)
  - Replace scale-dependent absolute `off < 1e-28` with relative
    off²/‖A‖²_F < tol² (tol=1e-14); sweep cap kept as backstop.
  - debug_assert! fires if the cap is hit without convergence (signature
    unchanged — every caller destructures (Vec<f64>, RealMatrix);
    subsumes the deferred M1 convergence guard).
  - New near-degenerate stress test (diag 1, 1+1e-10, 2 + tiny
    off-diagonals): orthonormal vectors + correct spectrum.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(emergent-time): M3 real-trace defensibility gate (honest null result)

Run the agentic clock vs the FAIR WindowedDeltaClock baseline (and the
constant-rate strawmen) on REAL recorded agent traces -- the Claude Code
session transcripts for this repo -- with PRE-REGISTERED thresholds and an
honestly-defined event-to-predict. This replaces the circular synthetic
benchmark with the genuine M3 gate from ADR-251 section 4.

THE FINDING (reported honestly, not manufactured): on the 2 real traces the
contradiction-free honest agentic clock scores 0 win / 1 tie / 1 loss vs the
fair windowed baseline. It does NOT beat the fair baseline on real data either.
The defensible value of the primitive is diagnostic (per-channel attribution +
health classifier), not a raw early-warning-lead win. The crate stays honest.

- examples/real_trace_eval.rs: real-trace adapter + pre-registered protocol.
  - Source: ~/.claude/projects/C--Users-ruv-ruvector/*.jsonl (real tool-use
    sequences, retries, is_error events). Deliberately NOT intelligence.json
    (51 flat all-success records, no failure events -- would be dishonest).
  - Documented heuristic channel mapping (tool-type TF -> belief, distinct
    files -> memory, Read/Grep -> retrieval, new user prompt -> goal, is_error
    rate -> contradiction, text+repetition -> plan).
  - Event-to-predict = real error cascade (>=2 is_error in 4 steps), defined
    from the harness is_error flag ONLY (non-circular).
  - Circularity guard: an honest agentic variant with contradiction weight 0
    so it cannot see the signal that defines the event. This is the real gate.
  - Pre-registered (before any lead computed): window=10, k=3sigma, metric=lead.
  - Prints an alive-vs-degenerate diagnostic: the honest signal is NOT flat
    (mean inc ~1.5, max ~4.4) but never clears its own mean+3sigma bar because
    early exploratory churn sets a high baseline -- a real property of real
    traces, not a dead clock.
  - Degrades gracefully (prints [skip], exits 0) when no traces are present,
    so CI without the data still passes.
- agentic_time.rs: add test contradiction_free_weights_blind_to_error_channel
  locking in the M3 circularity guard (50 tests, was 49).
- ADR-251: replace the M3-future-work note with the actual real-trace result;
  mark the Baseline-dominance gate UNMET; full lead table + caveats in Honest
  limitations.

Validation: cargo test -p emergent-time => 50 passed; build + clippy clean;
real_trace_eval runs and prints real numbers (0 win / 1 tie / 1 loss).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(emergent-time): M3b adaptive change-point detector (honest null, more robust)

M3 got an honest null on real traces with a fixed-window mean+3σ alarm and
diagnosed the cause: a frozen early baseline poisoned by exploration churn. M3
proposed an adaptive-window detector as the fix. M3b implements that exact fix.

- src/adaptive.rs: Page-Hinkley test (Page 1954 / Hinkley 1970), dependency-free
  pure Rust. Running-mean reference instead of a frozen window; upward + downward
  forms; clock-agnostic adaptive_alarm_step / adaptive_early_warning_lead.
  Documented math + literature citations. 12 unit tests (detects real step-change,
  silent on stationary noise, constant streams never alarm, threshold/tolerance
  monotonicity, slot-0 padding excluded, fair on both clock + baseline).
- examples/real_trace_eval.rs: wires the SAME pre-registered detector (δ=0.15,
  λ=5.0, fixed before any lead) into BOTH the agentic-honest composite AND the
  fair baseline. Prints fixed-window (M3) AND adaptive (M3b) leads side-by-side.

Honest result on the same n=2 real traces: the adaptive detector works as
designed — the fair belief-shift baseline, which never fired under the fixed
window, now leads by 32 and 25 steps. But it does NOT rescue the agentic clock:
the honest composite's adaptive alarms (steps 75, 49) still land AFTER the error
cascades (steps 37, 29), so its lead stays 0. Verdict moves 0/1/1 → 0 win / 0 tie
/ 2 loss. The M3-proposed fix was tried and did not change the verdict; the honest
null is now MORE ROBUST. Defensible value of the primitive remains diagnostic
(per-channel attribution + health classifier), not a raw early-warning-lead win.
n=2 caveat stands; a fair win would have demanded a larger pre-registered corpus.

ADR-251 §3/§4 extended with the adaptive-detector outcome and fixed-vs-adaptive
table. cargo test green (62), clippy clean, examples build, graceful-skip intact.

Co-Authored-By: claude-flow <ruv@ruv.net>

* style(emergent-time): apply rustfmt across the crate

Bring the crate (including the M2/M3/M3b additions) under rustfmt to
satisfy the CI Rustfmt check. Formatting only; no behavior change, 62
tests still pass.

https://claude.ai/code/session_01ApBCSaebKsCzLeA7JhvDvU

* fix(emergent-time): make real-trace parser robust to tool_use key order

The M3 real-trace harness silently ingested zero steps from genuine
Claude-Code transcripts because `extract_tool_names` only searched for
`"name":"..."` AFTER the `"type":"tool_use"` marker. Current transcripts
emit the name BEFORE the type (`{"name":"Bash","type":"tool_use",...}`),
so every single-tool step was dropped, `parse_session` fell below
MIN_STEPS and returned None, and the harness reported "No real session
transcripts found" — masquerading a parse failure as missing data.

Verified on a real 531-line session transcript: 0 steps parsed before,
112 after. The session has no error cascade, so it is correctly reported
as descriptive-only (not scoreable) rather than silently skipped.

Changes:
- extract_tool_names: pair each tool_use marker to the nearest "name"
  within a bounded window in EITHER direction (order-independent).
- load_traces: return files-seen / parse-failure counts so main can
  distinguish "no files" from "files present but unparseable" — an
  honesty fix so a silent parser gap can't pose as absence.
- add a regression test covering both key orderings + multi-tool lines.

fmt clean, clippy clean, 62 lib tests + 1 example test pass.

https://claude.ai/code/session_01ApBCSaebKsCzLeA7JhvDvU

* feat(emergent-time): learn agentic-time channel weights (honest harness)

Replace hand-set AgenticWeights with weights LEARNED from labelled
outcomes via L2-regularized logistic regression (dependency-free), with
held-out evaluation and a circularity guard (Honest mode drops the
contradiction channel).

Honest finding, reported not hidden: learning matches the hand-set guess
(AUC 0.936 vs 0.935) and yields interpretable importances (plan +0.75
dominant), but does NOT beat the best single channel on this synthetic
data (goal_graph 0.950 / contradiction 0.956) — the signal is
concentrated in one planted channel. Composition only earns its keep
when signal is spread across weak channels (ADR-251 §4), which needs
real traces. This is the reusable apparatus to run that test.

4 new tests; 66 lib tests pass, clippy + fmt clean.

https://claude.ai/code/session_01ApBCSaebKsCzLeA7JhvDvU

* feat(emergent-time): trained model + witness-chain provenance

Add a deterministic trained-weight model with tamper-evident, reproducible
provenance, and an honest "beyond baseline, with proof" demonstration.

- weight_learning: make LearnedWeights dimension-generic (store `dim`, add
  `from_params`); add a Gaussian sampler and `diffuse_dataset` — a controlled
  weak-signal benchmark (channels of differing strength + pure-noise channels).
  New test proves the learned composition BEATS both the best single channel
  and the equal-weight baseline in this regime (the one the thesis targets).

- witness: FNV-1a hash-linked WitnessChain (seal/append/verify, text round-trip,
  tamper + reproducibility detection). Proof of *provenance*: the sealed metrics
  correspond to the committed model and re-training reproduces the same hash.

- examples/train_model: trains, seals a witness record, persists the model +
  chain artifact, then verifies (1) chain integrity, (2) committed model matches
  sealed model_hash, (3) reproducibility. On the diffuse benchmark the learned
  model scores AUC 0.759 vs best-single 0.681 vs equal-weight 0.708 and recovers
  the signal structure (noise channels learned to ~0).

- models/agentic_weights.witness.txt: the sealed trained-model artifact.

HONEST SCOPE: this is "beyond baseline, with verifiable proof" in the method's
target regime (distributed weak signal) — NOT a claim of beating real-world
agent-failure SOTA, which still needs real labelled traces (ADR-251 §4).

72 lib tests pass, clippy + fmt clean.

https://claude.ai/code/session_01ApBCSaebKsCzLeA7JhvDvU

* docs(emergent-time): add README; release 2.2.4

2.2.3 published without a README (bare crates.io page). Adds a
matter-of-fact README (physics formalisms, Agentic Time, benchmark
results, usage) and decouples the crate version from the workspace so it
can be released independently.

Co-Authored-By: claude-flow <ruv@ruv.net>

* ci(emergent-time): dedicated test + falsifiability guard

Path-filtered CI gate for the emergent-time crate: fmt, clippy -D
warnings, full test suite, example builds + no-data runs, and a
publish-equivalent package check. Plus a guard step that greps for the
falsifiability / pre-registered-evaluation tests (generic-clock empty
kernel, cached-vs-from-scratch equivalence, entropy-rate-vs-measured,
error-blind agentic weights, real_trace_eval harness) so none can be
silently removed without failing CI.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(emergent-time): sync Cargo.lock to crate version 2.2.4

The 2.2.4 version bump updated Cargo.toml but left Cargo.lock at 2.2.3,
failing the lockfile-integrity CI gate. Update the lock to match.

https://claude.ai/code/session_01ApBCSaebKsCzLeA7JhvDvU

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: ruv <ruvnet@users.noreply.github.com>
This commit is contained in:
rUv 2026-06-13 13:15:31 -04:00 committed by GitHub
parent 8f2b4bd822
commit 44a836d57e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 7274 additions and 0 deletions

93
.github/workflows/emergent-time-ci.yml vendored Normal file
View file

@ -0,0 +1,93 @@
name: emergent-time CI
# Dedicated, fast gate for the dependency-free `emergent-time` crate. Runs its
# full test suite, lints, and examples — and a guard that the falsifiability /
# pre-registered-evaluation tests cannot be silently removed (these are the
# checks that keep the crate's claims matched to its evidence; deleting one
# would let a regression pass unnoticed).
on:
push:
branches: [main]
paths:
- 'crates/emergent-time/**'
- '.github/workflows/emergent-time-ci.yml'
pull_request:
paths:
- 'crates/emergent-time/**'
- '.github/workflows/emergent-time-ci.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: emergent-time-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
emergent-time:
name: test + lint + examples
runs-on: ubuntu-22.04
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache Rust
uses: Swatinem/rust-cache@v2
with:
key: emergent-time
# ── Guard: the falsifiability / pre-registered tests must exist ──
# If any of these greps fail, a test that keeps a claim honest has been
# removed or renamed — fail loudly rather than let it slip.
- name: Guard — falsifiability tests present
run: |
cd crates/emergent-time
set -e
check() { grep -rq "$1" src/ examples/ || { echo "::error::missing required test/check: $1"; exit 1; }; }
# WheelerDeWitt: the discriminating test (a generic clock yields no kernel)
check "generic_clock_yields_empty_physical_space"
# PageWootters cached vs from-scratch equivalence (perf must not change physics)
check "cached_evolution_equals_from_scratch_propagator"
# Entropic time: rate checked against measured entropy, not its own definition
check "internal_time_spacing_tracks_measured_entropy_production"
# Agentic clock blinded to the error channel it predicts (no leakage)
check "contradiction_free_weights_blind_to_error_channel"
# Real-trace evaluation harness present
test -f examples/real_trace_eval.rs || { echo "::error::real_trace_eval.rs missing"; exit 1; }
echo "all falsifiability guards present"
- name: Format check
run: cargo fmt -p emergent-time -- --check
- name: Clippy (deny warnings)
run: cargo clippy -p emergent-time --all-targets -- -D warnings
- name: Test
run: cargo test -p emergent-time
- name: Build examples
run: cargo build -p emergent-time --examples
# The examples skip cleanly when their data files are absent (CI has none),
# so a clean exit here confirms they at least load and run their no-data path.
- name: Run examples (no-data path)
run: |
cargo run -p emergent-time --example emergent_time
cargo run -p emergent-time --example real_trace_eval
cargo run -p emergent-time --example train_model
# Belt-and-suspenders: the crate builds in isolation as it would on
# crates.io (zero runtime deps), so a publish can never ship broken.
- name: Package check (publish-equivalent build)
run: cargo package -p emergent-time --allow-dirty

7
Cargo.lock generated
View file

@ -2465,6 +2465,13 @@ dependencies = [
"serde",
]
[[package]]
name = "emergent-time"
version = "2.2.4"
dependencies = [
"criterion 0.5.1",
]
[[package]]
name = "encode_unicode"
version = "1.0.0"

View file

@ -238,6 +238,9 @@ members = [
"crates/ruvector-graph-condense-wasm",
# Perception substrate: delta -> boundary -> coherence -> proof -> action
"crates/ruvector-perception",
# Calculus of emergent / relational time (Wheeler-DeWitt, Page-Wootters,
# entropic, thermal) + Structural Proper Time for agentic systems.
"crates/emergent-time",
]
resolver = "2"

View file

@ -0,0 +1,33 @@
[package]
name = "emergent-time"
# Decoupled from the workspace version so the crate can be released
# independently (workspace is at 2.2.3; 2.2.3 was published without a README).
version = "2.2.4"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
readme = "README.md"
description = "Calculus of emergent / relational time: Wheeler-DeWitt timeless constraint, Page-Wootters relational clocks, entropic time, Connes-Rovelli thermal time, and Structural Proper Time for agentic and quantum systems"
keywords = ["time", "quantum-gravity", "relational", "thermodynamics", "agents"]
categories = ["science", "simulation", "algorithms"]
[dependencies]
[dev-dependencies]
criterion = { workspace = true }
[[bench]]
name = "clocks"
harness = false
# Research-tier crate: keep correctness/suspicious lints denied, relax style churn.
[lints.rust]
dead_code = "allow"
[lints.clippy]
# Explicit index loops read more clearly in the dense numeric kernels (matmul,
# Jacobi sweeps, nearest-keyframe search) than iterator chains would.
needless_range_loop = "allow"
manual_find = "allow"

View file

@ -0,0 +1,64 @@
# emergent-time
A dependency-free Rust crate for **relational/emergent time**: time defined as ordered internal change rather than an external coordinate. It contains two parts — implementations of four physics formalisms for time without an external clock, and an "Agentic Time" primitive that applies the same idea to AI agent traces.
```toml
[dependencies]
emergent-time = "2.2.4"
```
Zero runtime dependencies. 72 tests.
## Physics formalisms
Each is implemented on a self-contained numerical core (real-symmetric Jacobi eigensolver, complex spectral matrix exponentiation, von Neumann entropy) and verified by tests.
| Module | Computes | Verification |
|---|---|---|
| `wheeler_dewitt` | Bipartite constraint `Ĵ = H_C⊗I + I⊗H_R` and its kernel (timeless physical states). | Constructed kernel is a consistency check; a separate test confirms a generic clock Hamiltonian yields an empty kernel. |
| `page_wootters` | Schrödinger evolution recovered by conditioning a static entangled clock+system state on clock eigenstates. | Conditioned state matches an independently computed propagator to < 1e-8 across positive and negative times. |
| `entropic` | `τ_S = (S S₀)/k`, internal-time rate as a function of entropy production over a β-swept Gibbs ensemble. | Clock rate checked against finite-difference `dS/dβ` of the measured Gibbs entropy. |
| `thermal` | ConnesRovelli thermal time: modular Hamiltonian `K = ln ρ` and modular flow `A(s) = e^{isK} A e^{-isK}`. | `K = βH + (ln Z)I` and modular-flow = rescaled physical evolution, each verified by independent recomputation. |
The numerical core uses the stable Jacobi rotation formula, a 2n×2n real-symmetric embedding for complex-Hermitian eigenvalues, and spectral (not series) matrix exponentials. `PageWootters` evolves in a cached eigenbasis (`ψ(t) = Σ_k e^{-iE_k t} c_k |E_k⟩`), which is ~53× faster than re-diagonalizing per timestep.
## Agentic Time
`agentic_time` measures internal time as arc length through a system's state manifold over six channels — belief, memory, retrieval, goal-graph, contradiction, plan. It provides:
- **Explainable ticks** — each tick carries a class, a reason string, and per-channel attribution.
- **Agentic Time Index (ATI)** — progress per unit of internal change.
- **A 7-state health classifier** — Healthy, Drifting, Stuck, NeedsReplan, Contradicting, Collapsing, NeedsHumanReview.
- **Change-point alarms** — a fixed-window `mean + kσ` detector and an adaptive PageHinkley detector (`adaptive` module).
## Benchmarks
`examples/emergent_time.rs` runs a multi-clock comparison (wall, step-count, token-count, agentic, and a fair rolling-window baseline) on a synthetic failing-agent trace.
`examples/real_trace_eval.rs` runs an early-warning evaluation on recorded agent traces with pre-registered thresholds, predicting a real error cascade defined independently of the agentic channels. Measured results:
| Detector | Agentic clock vs fair baseline (n=2 real traces) |
|---|---|
| Fixed-window `mean + 3σ` | 0 win / 1 tie / 1 loss |
| Adaptive PageHinkley | 0 win / 0 tie / 2 loss |
The agentic clock does not lead the fair baseline on these traces. Its demonstrated value is the diagnostic layer (per-channel attribution + health classifier), not early-warning lead. A larger pre-registered corpus would be required to establish a lead; the harness ships in the crate.
`examples/train_model.rs` learns a weighted channel composition on a controlled signal-plus-noise dataset (`weight_learning`), with held-out evaluation: AUC 0.759 (learned) vs 0.708 (equal-weight) vs 0.681 (best single channel), recovering near-zero weights on the pure-noise channels. The result is sealed with a reproducible FNV-1a provenance chain (`witness`) linking the committed model to the reported metrics. The hash is an integrity/provenance check, not a cryptographic commitment.
## Examples
```bash
cargo run --example emergent_time # clocks + multi-clock comparison
cargo run --example real_trace_eval # real-trace early-warning gate (skips with no data)
cargo run --example train_model # learned channel weights + provenance seal
cargo bench # numerical-core and clock benchmarks
```
## References
WheelerDeWitt (DeWitt 1967); Page & Wootters, "Evolution without evolution" (1983); GiovannettiLloydMaccone (2015); Connes & Rovelli, thermal time (1994); PageHinkley (Page 1954, Hinkley 1970); ADWIN (BifetGavaldà 2007). See `docs/adr/ADR-251-agentic-time.md` for the full design record and limitations.
## License
MIT OR Apache-2.0.

View file

@ -0,0 +1,83 @@
//! Criterion benchmarks for the emergent-time numerical core and clocks.
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use emergent_time::complex_matrix::schrodinger_propagator;
use emergent_time::real_matrix::RealMatrix;
use emergent_time::structural_clock::{
self, compression_error, early_warning_lead, Clock, Scenario, StructuralMetric,
StructuralProperTime,
};
use emergent_time::PageWootters;
fn sym_h(n: usize) -> RealMatrix {
RealMatrix::from_fn(n, |r, c| {
if r == c {
(r as f64) - (n as f64) / 2.0
} else if (r as i64 - c as i64).abs() == 1 {
0.3
} else {
0.0
}
})
}
fn bench_eigensolver(c: &mut Criterion) {
let mut g = c.benchmark_group("symmetric_eigen");
for &n in &[4usize, 8, 16, 32] {
let h = sym_h(n);
g.bench_function(format!("n{n}"), |b| {
b.iter(|| black_box(h.symmetric_eigen()))
});
}
g.finish();
}
fn bench_propagator(c: &mut Criterion) {
let h = sym_h(16);
c.bench_function("schrodinger_propagator_n16", |b| {
b.iter(|| black_box(schrodinger_propagator(&h, 1.0)))
});
}
fn bench_page_wootters(c: &mut Criterion) {
let pw = PageWootters::new(sym_h(8));
c.bench_function("page_wootters_conditional_n8", |b| {
b.iter(|| black_box(pw.conditional_state(black_box(1.3))))
});
// P1: cached-eigenbasis Schrödinger evolution vs the from-scratch path that
// re-diagonalizes H_R and forms the full propagator every call. Same H size
// (n16) as `schrodinger_propagator_n16` for a like-for-like comparison.
let pw16 = PageWootters::new(sym_h(16));
c.bench_function("page_wootters_schrodinger_cached_n16", |b| {
b.iter(|| black_box(pw16.schrodinger_state(black_box(1.0))))
});
c.bench_function("page_wootters_schrodinger_from_scratch_n16", |b| {
b.iter(|| black_box(pw16.schrodinger_state_from_scratch(black_box(1.0))))
});
}
fn bench_structural_clock(c: &mut Criterion) {
let traj = structural_clock::generate_scenario(&Scenario::default());
let spt = StructuralProperTime::new(StructuralMetric::default());
let mut g = c.benchmark_group("structural_clock");
g.bench_function("cumulative", |b| {
b.iter(|| black_box(spt.cumulative(&traj)))
});
g.bench_function("early_warning_lead", |b| {
b.iter(|| black_box(early_warning_lead(&spt, &traj, 80, 30, 4.0)))
});
g.bench_function("compression_error", |b| {
b.iter(|| black_box(compression_error(&spt, &traj, 10)))
});
g.finish();
}
criterion_group!(
benches,
bench_eigensolver,
bench_propagator,
bench_page_wootters,
bench_structural_clock
);
criterion_main!(benches);

View file

@ -0,0 +1,229 @@
//! Walk through all five emergent-time constructions and run the three-clock
//! benchmark. Run with:
//!
//! ```bash
//! cargo run -p emergent-time --example emergent_time
//! ```
use emergent_time::complex::fidelity;
use emergent_time::structural_clock::{
self, evaluate, Clock, EntropyClock, Scenario, StructuralMetric, StructuralProperTime,
WallClock,
};
use emergent_time::{
agentic::CausalTimeline, agentic_time, agentic_time::AgentClock, entropic,
real_matrix::RealMatrix, thermal, wheeler_dewitt, PageWootters,
};
fn rule(title: &str) {
println!("\n=== {title} ===");
}
fn sample_hamiltonian(n: usize) -> RealMatrix {
RealMatrix::from_fn(n, |r, c| {
if r == c {
(r as f64) - (n as f64 - 1.0) / 2.0
} else if (r as i64 - c as i64).abs() == 1 {
0.3
} else {
0.0
}
})
}
fn main() {
println!("emergent-time : time as ordered change measured from inside the system");
// ---- 1. WheelerDeWitt: the timeless universe -------------------------
rule("1. Wheeler-DeWitt (H|Psi> = 0)");
let pw = PageWootters::new(sample_hamiltonian(4));
let j = wheeler_dewitt::bipartite_constraint(&pw.clock_hamiltonian(), &pw.h_r);
let psi = pw.global_static_state();
let residual = wheeler_dewitt::constraint_residual(&j, &psi);
let phys = wheeler_dewitt::solve_constraint(&j);
println!(" global state satisfies the constraint: ||J|Psi>|| = {residual:.2e}");
println!(
" kernel eigenvalue nearest zero: {:.2e}",
phys.eigenvalue
);
println!(" -> the universe's state carries no external time parameter.");
// ---- 2. PageWootters: evolution from a static state ------------------
rule("2. Page-Wootters (relational clock)");
println!(" conditioning the static |Psi> on clock-reading t recovers e^-iHt|psi0>:");
for &t in &[0.0, 0.5, 1.0, 2.0] {
let f = fidelity(&pw.conditional_state(t), &pw.schrodinger_state(t));
println!(" t = {t:>4}: fidelity(conditional, Schrodinger) = {f:.10}");
}
println!(" -> time is what the rest sector looks like given the clock.");
// ---- 3. Entropic time: the cold-atom clock ----------------------------
rule("3. Entropic time (tau_S = (S - S0)/k)");
let h = sample_hamiltonian(5);
let clock = entropic::EntropicClock::new(0.0, 1.0);
let sweep = entropic::entropic_time_sweep(&h, &clock, 3.0, 0.2, 6);
println!(" sweeping the barrier (inverse temperature lambda):");
println!(" {:>8} {:>10} {:>10}", "lambda", "S(nats)", "tau_S");
for (lam, s, tau) in &sweep {
println!(" {lam:>8.3} {s:>10.4} {tau:>10.4}");
}
println!(" -> internal time runs fast where entropy changes fast, stalls where it saturates.");
// ---- 4. Thermal time: flow generated by the state ---------------------
rule("4. Thermal time (K = -ln rho, A(s) = e^isK A e^-isK)");
let beta = 0.7;
let rho = entropic::gibbs_density(&h, beta);
let k = thermal::modular_hamiltonian(&rho);
// K should equal beta*H + const; report the off-diagonal match.
let diff = RealMatrix::from_fn(h.n, |r, c| k.get(r, c) - beta * h.get(r, c));
println!(" modular Hamiltonian K = beta*H + c*I ?");
println!(
" max |off-diagonal(K - beta*H)| = {:.2e}",
diff.max_offdiag()
);
println!(" -> physical time flow is recovered from the thermodynamic state itself.");
// ---- 5a. Agentic causal time ------------------------------------------
rule("5a. Agentic causal time (time = causal structure)");
let mut tl = CausalTimeline::new();
let a = tl.add(vec![], 3.0); // start: high uncertainty
let b = tl.add(vec![a], 3.0); // idle hour: nothing changes
let c = tl.add(vec![b], 0.6); // contradiction resolved: big drop
let d = tl.add(vec![c], 0.55); // small refinement
for id in [a, b, c, d] {
println!(
" event {id}: causal_depth = {} internal_time = {:.3}",
tl.causal_depth(id),
tl.internal_time(id)
);
}
println!(" -> an idle hour adds ~0 internal time; one contradiction is a large jump.");
// ---- 5b. Structural Proper Time + the three-clock benchmark -----------
rule("5b. Structural Proper Time (a new form of agentic time)");
let sc = Scenario::default();
let traj = structural_clock::generate_scenario(&sc);
let spt = StructuralProperTime::new(StructuralMetric::default());
let budget = 10;
// Baseline must be learned over the quiet stretch *before* the first
// structural precursor, else the precursor contaminates the baseline.
let (bw, ks) = (sc.baseline_window, 4.0f64);
let reports = [
evaluate(&WallClock, &traj, sc.fail_index, bw, ks, budget),
evaluate(&EntropyClock, &traj, sc.fail_index, bw, ks, budget),
evaluate(&spt, &traj, sc.fail_index, bw, ks, budget),
];
println!(
" scenario: {} steps, structural drift @ {}, entropy rise @ {}, failure @ {}",
sc.steps, sc.embed_onset, sc.entropy_onset, sc.fail_index
);
println!(
" {:<12} {:>12} {:>18} {:>14}",
"clock", "warn-lead", "compress-error", "order-ok"
);
for r in &reports {
println!(
" {:<12} {:>12} {:>18.4} {:>14}",
r.name, r.lead, r.compression_error, r.causal_order_ok
);
}
let wall = &reports[0];
let entropy = &reports[1];
let structural = &reports[2];
let lead_ratio = structural.lead as f64 / entropy.lead.max(1) as f64;
// History compression: samples each clock needs to preserve the trajectory
// to a fixed tolerance.
let tol = 0.3;
let wall_budget = structural_clock::samples_to_tolerance(&WallClock, &traj, tol);
let struct_budget = structural_clock::samples_to_tolerance(&spt, &traj, tol);
let compress_ratio = wall_budget as f64 / struct_budget as f64;
println!(
"\n warn-lead: wall {} -> structural {} (structure precedes the visible failure)",
wall.lead, structural.lead
);
println!(
" early-warning lead vs entropy clock: {:.1}x [acceptance target: >= 2x]",
lead_ratio
);
println!(
" history compression to tol {tol}: wall needs {wall_budget} samples, structural needs {struct_budget} ({compress_ratio:.1}x)"
);
// Agent self-awareness: replan on internal churn without progress, not on
// step count.
let spent = spt.cumulative(&traj).last().copied().unwrap_or(0.0);
let progress_good = 0.9; // hypothetical converging task
let progress_stuck = 0.05; // hypothetical thrashing task
println!(
"\n agentic replan trigger (progress/structural_time < 0.1): converging={}, thrashing={}",
structural_clock::should_replan(spent, progress_good * spent, 0.1),
structural_clock::should_replan(spent, progress_stuck, 0.1)
);
// ---- 5c. Agentic Time: the four-clock workflow demo -------------------
rule("5c. Agentic Time (tau_a = f(dB, dM, dR, dG, dE, dP))");
let tr = agentic_time::generate_failing_trace(0xA9E1);
let agentic = agentic_time::AgenticTime::new(agentic_time::AgenticWeights::default());
let abw = tr.baseline_window;
println!(
" failing workflow: {} steps, plan-thrash onset @ {}, failure @ {}",
tr.states.len(),
tr.thrash_onset,
tr.fail_index
);
// Fair baselines: windowed z-score change-point detectors (non-strawman).
let token_base = agentic_time::WindowedDeltaClock::token_delta(abw);
let belief_base = agentic_time::WindowedDeltaClock::belief_shift(abw);
println!(" {:<26} {:>12}", "clock", "warn-lead");
let clocks: [(&str, &dyn agentic_time::AgentClock); 6] = [
("wall (constant-rate)", &agentic_time::AgentWallClock),
("step-count (constant-rate)", &agentic_time::StepCountClock),
(
"token-count (constant-rate)",
&agentic_time::TokenCountClock,
),
("windowed-z[token-delta] FAIR", &token_base),
("windowed-z[belief] FAIR", &belief_base),
("agentic (multi-channel)", &agentic),
];
for (label, cl) in clocks {
let lead = agentic_time::early_warning_lead(cl, &tr.states, tr.fail_index, abw, 4.0);
println!(" {label:<26} {lead:>12}");
}
println!(" NOTE: wall/step/token are constant-rate clocks (zero baseline variance ->");
println!(" their alarm CANNOT fire); their 0 lead is a coverage gap, not a");
println!(" measured loss. The windowed-z detectors ARE fair competitors and");
println!(" on THIS designed trace they fire at least as early as the agentic");
println!(" clock: the belief-shift detector catches the planted structural");
println!(" signal (a single-channel z-score already sees it), and the");
println!(" token-delta detector trips early on quantization noise (tokens are");
println!(" a near-constant integer stream) -- reported, not hidden.");
println!(" HONEST FRAMING: the agentic clock does NOT beat a fair baseline on this");
println!(" synthetic trace. The 40-step lead is a property of how far the");
println!(" structural precursor was planted ahead of failure, NOT a measured");
println!(" competitive win. The agentic clock's real value -- composing many");
println!(" weak channels when no single scalar carries the signal -- can only");
println!(" be substantiated on a REAL trace vs this fair baseline (M3 work;");
println!(" see ADR-251 'Honest limitations').");
// Live health verdict at a window straddling the thrash onset.
let th = agentic_time::HealthThresholds::default();
let w0 = tr.thrash_onset.saturating_sub(2);
let w1 = tr.thrash_onset + 6;
let dtau = agentic.cumulative(&tr.states)[w1] - agentic.cumulative(&tr.states)[w0];
let dprog = tr.progress[w1] - tr.progress[w0];
let contra = tr.states[w1].contradiction;
let verdict = agentic_time::classify(dtau, dprog, contra, &th);
println!(
" agentic-time index at onset window: dtau={dtau:.2}, dprogress={dprog:.2} -> {verdict:?}"
);
println!(
" -> agents should measure time by meaningful change, not seconds, tokens, or steps."
);
println!("\n thesis: physics state -> vector geometry -> internal clock -> prediction");
}

View file

@ -0,0 +1,114 @@
//! Learn agentic-time channel weights from labelled synthetic traces and
//! compare, honestly, against two fair baselines: the hand-set weights and the
//! single best channel.
//!
//! ```bash
//! cargo run -p emergent-time --example learn_weights
//! ```
use emergent_time::weight_learning::{
auc, best_single_channel_auc, build_dataset, linear_scores, synth_trace, FeatureMode,
LabeledTrace, LearnedWeights,
};
/// Disjoint train/val seeds, half failing / half healthy.
fn split(n_per_class: usize, train_frac: f64) -> (Vec<LabeledTrace>, Vec<LabeledTrace>) {
let mut train = Vec::new();
let mut val = Vec::new();
let cut = (n_per_class as f64 * train_frac) as u64;
for s in 0..n_per_class as u64 {
for will_fail in [true, false] {
let seed = (s + 1) * 2_654_435_761 + will_fail as u64;
let tr = synth_trace(seed, will_fail);
if s < cut {
train.push(tr);
} else {
val.push(tr);
}
}
}
(train, val)
}
fn report(mode: FeatureMode, train: &[LabeledTrace], val: &[LabeledTrace], horizon: usize) {
let (xtr, ytr) = build_dataset(train, horizon, mode);
let (xva, yva) = build_dataset(val, horizon, mode);
let model = LearnedWeights::fit(&xtr, &ytr, mode.dim(), 800, 0.3, 1e-3);
let learned: Vec<f64> = xva.iter().map(|r| model.predict(r)).collect();
let learned_auc = auc(&learned, &yva);
// Hand-set default weights mapped to this mode's feature order.
let handset: Vec<f64> = match mode {
FeatureMode::Full => vec![1.0, 0.5, 0.5, 1.0, 1.5, 1.0],
FeatureMode::Honest => vec![1.0, 0.5, 0.5, 1.0, 1.0],
};
let handset_auc = auc(&linear_scores(&xva, &handset), &yva);
let (best_ch, single_auc) = best_single_channel_auc(&xva, &yva, mode.dim());
let names = mode.channel_names();
println!("\n mode = {mode:?} (val pos rate {:.2})", {
let p = yva.iter().filter(|&&l| l > 0.5).count();
p as f64 / yva.len().max(1) as f64
});
println!(" learned composition AUC : {learned_auc:.3}");
println!(" hand-set weights AUC : {handset_auc:.3}");
println!(
" best single channel AUC : {single_auc:.3} ({})",
names[best_ch]
);
// Learned coefficients = interpretable channel importances.
print!(" learned importances :");
for (n, c) in names.iter().zip(&model.coef) {
print!(" {n}={c:+.2}");
}
println!();
// Honest verdict.
let beats_handset = learned_auc >= handset_auc - 1e-9;
let beats_single = learned_auc > single_auc + 1e-9;
println!(
" verdict: learning {} the hand-set guess; {} the best single channel.",
if beats_handset {
"matches/beats"
} else {
"loses to"
},
if beats_single {
"BEATS"
} else {
"does NOT beat"
}
);
}
fn main() {
println!("emergent-time : learned agentic-time channel weights");
println!("====================================================");
println!(" honest harness — every number is on a held-out validation split;");
println!(" the contradiction channel is dropped in Honest mode (circularity guard).");
let (train, val) = split(60, 0.6);
let horizon = 12;
println!(
"\n dataset: {} train + {} val traces (half failing/half healthy), horizon {} steps",
train.len(),
val.len(),
horizon
);
report(FeatureMode::Honest, &train, &val, horizon);
report(FeatureMode::Full, &train, &val, horizon);
println!("\n reading this honestly:");
println!(" • Learning the weights is at least as good as the hand-set guess — so");
println!(" the hand-tuned constants can be replaced by fitted ones safely.");
println!(" • On THIS synthetic data the failure signal is concentrated in one");
println!(" planted channel, so the best single channel is already strong and");
println!(" composition does not clearly beat it. That is expected here.");
println!(" • The thesis (compose many WEAK channels) can only be confirmed on");
println!(" REAL traces where no single channel dominates — ADR-251 §4. This");
println!(" harness is the reusable apparatus to run that test when labelled");
println!(" real traces are supplied.");
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,224 @@
//! Train an agentic-time weight model, seal it into a witness chain, persist the
//! artifact, and verify integrity + reproducibility.
//!
//! ```bash
//! cargo run -p emergent-time --example train_model
//! # custom output path:
//! EMERGENT_TIME_MODEL_OUT=/tmp/model.witness.txt cargo run -p emergent-time --example train_model
//! ```
//!
//! What this proves (and what it does NOT): it produces a deterministic trained
//! model whose held-out metrics are sealed in a tamper-evident, reproducible
//! witness chain — proof of *provenance*, not of beating real-world SOTA. On the
//! controlled diffuse-signal benchmark (the method's target regime) the learned
//! composition beats both the best single channel and the equal-weight baseline;
//! that is an honest existence proof, not a claim about real agent traces.
use std::fs;
use std::path::PathBuf;
use emergent_time::weight_learning::{
auc, best_single_channel_auc, diffuse_dataset, linear_scores, LearnedWeights,
};
use emergent_time::witness::{hash_dataset, hash_f64s, WitnessChain};
/// Per-channel signal strengths: two strong-ish, two weak, two pure-noise.
const MUS: [f64; 6] = [0.7, 0.6, 0.3, 0.3, 0.0, 0.0];
const N_PER_CLASS: usize = 4000;
const ITERS: usize = 500;
const LR: f64 = 0.3;
const L2: f64 = 1e-4;
const TRAIN_SEED: u64 = 0xD1FF;
const VAL_SEED: u64 = 0x5EED;
/// Canonical f64 vector summarizing the fitted model (for `model_hash`).
fn model_params(m: &LearnedWeights) -> Vec<f64> {
let mut v = vec![m.dim as f64];
v.extend_from_slice(&m.coef);
v.push(m.bias);
v.extend_from_slice(&m.mean);
v.extend_from_slice(&m.std);
v
}
/// Train once and return (model, val_auc, single_auc, handset_auc, data_hash).
fn train() -> (LearnedWeights, f64, f64, f64, u64) {
let d = MUS.len();
let (xtr, ytr) = diffuse_dataset(N_PER_CLASS, &MUS, TRAIN_SEED);
let (xva, yva) = diffuse_dataset(N_PER_CLASS, &MUS, VAL_SEED);
let model = LearnedWeights::fit(&xtr, &ytr, d, ITERS, LR, L2);
let learned_auc = auc(
&xva.iter().map(|r| model.predict(r)).collect::<Vec<_>>(),
&yva,
);
let handset_auc = auc(&linear_scores(&xva, &vec![1.0; d]), &yva);
let (_, single_auc) = best_single_channel_auc(&xva, &yva, d);
let data_hash = hash_dataset(&xtr, &ytr);
(model, learned_auc, single_auc, handset_auc, data_hash)
}
fn config_hash() -> u64 {
let mut v = vec![
N_PER_CLASS as f64,
MUS.len() as f64,
ITERS as f64,
LR,
L2,
TRAIN_SEED as f64,
VAL_SEED as f64,
];
v.extend_from_slice(&MUS);
hash_f64s(&v)
}
fn main() {
println!("emergent-time : train + witness an agentic-time weight model");
println!("============================================================");
let (model, val_auc, single_auc, handset_auc, data_hash) = train();
let cfg_hash = config_hash();
let model_hash = hash_f64s(&model_params(&model));
println!("\n diffuse-signal benchmark (held-out validation):");
println!(" learned composition AUC : {val_auc:.4}");
println!(" best single channel AUC : {single_auc:.4}");
println!(" equal-weight handset AUC: {handset_auc:.4}");
let beats = val_auc > single_auc + 1e-9 && val_auc > handset_auc + 1e-9;
println!(
" verdict: learned composition {} BOTH baselines.",
if beats { "BEATS" } else { "does not beat" }
);
print!(" learned importances :");
for (i, c) in model.coef.iter().enumerate() {
print!(" c{i}={c:+.2}");
}
println!();
// Seal into a witness chain.
let mut chain = WitnessChain::new();
chain
.seal_and_append(
data_hash,
cfg_hash,
model_hash,
val_auc,
single_auc,
handset_auc,
)
.expect("genesis seal");
println!("\n witness record:");
println!(" {}", chain.records[0].to_line());
println!(
" data_hash={:016x} config_hash={:016x} model_hash={:016x}",
data_hash, cfg_hash, model_hash
);
// Persist model + chain.
let out = std::env::var_os("EMERGENT_TIME_MODEL_OUT")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("models")
.join("agentic_weights.witness.txt")
});
let artifact = render_artifact(&model, &chain);
if let Some(parent) = out.parent() {
let _ = fs::create_dir_all(parent);
}
match fs::write(&out, &artifact) {
Ok(()) => println!("\n wrote artifact: {}", out.display()),
Err(e) => println!("\n [warn] could not write {}: {e}", out.display()),
}
// ---- Verification 1: chain integrity ----------------------------------
let reloaded = WitnessChain::from_text(&artifact);
match reloaded.verify() {
Ok(n) => println!(" [PASS] witness chain verifies ({n} record(s), links + seals intact)"),
Err(e) => println!(" [FAIL] witness chain verification: {e}"),
}
// ---- Verification 2: the committed model matches its sealed hash -------
let (m2, parsed_model_hash) = parse_model(&artifact);
let recomputed = hash_f64s(&model_params(&m2));
let model_ok = recomputed == parsed_model_hash && recomputed == reloaded.records[0].model_hash;
println!(
" [{}] committed model matches sealed model_hash ({:016x})",
if model_ok { "PASS" } else { "FAIL" },
recomputed
);
// ---- Verification 3: reproducibility (re-train → identical hash) -------
let (m3, ..) = train();
let repro = hash_f64s(&model_params(&m3)) == model_hash;
println!(
" [{}] reproducible: re-training yields identical model_hash",
if repro { "PASS" } else { "FAIL" }
);
println!("\n honest framing:");
println!(" • PROVEN here: a deterministic trained model whose held-out win over");
println!(" both baselines is sealed in a verifiable, reproducible witness chain.");
println!(" • This is 'beyond baseline, with proof' in the method's target regime");
println!(" (distributed weak signal) — NOT a claim of beating real-world agent-");
println!(" failure SOTA, which needs real labelled traces (ADR-251 §4).");
}
/// Render the persisted artifact: a `[model]` section + the witness chain.
fn render_artifact(m: &LearnedWeights, chain: &WitnessChain) -> String {
let mut s = String::new();
s.push_str("# emergent-time trained model + witness chain\n");
s.push_str("[model]\n");
s.push_str(&format!("dim={}\n", m.dim));
s.push_str(&format!("bias={:.6}\n", m.bias));
s.push_str(&format!("coef={}\n", join6(&m.coef)));
s.push_str(&format!("mean={}\n", join6(&m.mean)));
s.push_str(&format!("std={}\n", join6(&m.std)));
s.push_str("[witness]\n");
s.push_str(&chain.to_text());
s
}
fn join6(xs: &[f64]) -> String {
// Round identically to the witness hasher (round-half-away, 6 dp) so the
// serialized params re-hash to the sealed model_hash exactly.
xs.iter()
.map(|x| format!("{:.6}", (x * 1e6).round() / 1e6))
.collect::<Vec<_>>()
.join(",")
}
fn parse6(s: &str) -> Vec<f64> {
s.split(',').filter_map(|t| t.trim().parse().ok()).collect()
}
/// Parse the `[model]` section back into a model and return (model, model_hash
/// recomputed from the artifact's own params is done by caller).
fn parse_model(artifact: &str) -> (LearnedWeights, u64) {
let mut dim = 0usize;
let mut bias = 0.0;
let mut coef = Vec::new();
let mut mean = Vec::new();
let mut std = Vec::new();
for line in artifact.lines() {
let line = line.trim();
if let Some(v) = line.strip_prefix("dim=") {
dim = v.parse().unwrap_or(0);
} else if let Some(v) = line.strip_prefix("bias=") {
bias = v.parse().unwrap_or(0.0);
} else if let Some(v) = line.strip_prefix("coef=") {
coef = parse6(v);
} else if let Some(v) = line.strip_prefix("mean=") {
mean = parse6(v);
} else if let Some(v) = line.strip_prefix("std=") {
std = parse6(v);
}
}
// Pull the sealed model_hash out of the witness section for cross-check.
let chain = WitnessChain::from_text(artifact);
let sealed = chain.records.first().map(|r| r.model_hash).unwrap_or(0);
let m = LearnedWeights::from_params(dim, coef, bias, mean, std);
(m, sealed)
}

View file

@ -0,0 +1,11 @@
# emergent-time trained model + witness chain
[model]
dim=6
bias=0.000290
coef=0.765235,0.607439,0.301707,0.280669,-0.007227,0.039656
mean=0.348411,0.297289,0.148232,0.156949,-0.008836,-0.002080
std=1.057942,1.047949,1.007804,1.013764,0.998482,0.993423
[witness]
# emergent-time witness chain
# index|prev|data_hash|config_hash|model_hash|val_auc|single_auc|handset_auc|hash
0|0000000000000000|00291849d1ef122c|dfc27d1920d87675|33fae008ca679df8|0.759042|0.681365|0.707503|61777cf8d6e7318d

View file

@ -0,0 +1,461 @@
//! **Adaptive change-point detection** — the M3b deliverable.
//!
//! M3 ran the agentic-clock defensibility gate on real recorded agent traces
//! with a **fixed-window** `mean + kσ` change-point alarm ([`alarm_step`]) and
//! got an *honest null*: the agentic-honest clock never alarms, because the
//! agent's early-exploration churn sets a high fixed baseline that later genuine
//! movement can't clear (0 win / 1 tie / 1 loss vs the fair baseline). M3 itself
//! diagnosed the cause — *a fixed baseline poisoned by early churn* — and named
//! the fix: an **adaptive-window detector** whose reference statistic keeps
//! moving instead of freezing on the first `BASELINE_WINDOW` increments.
//!
//! This module implements the simplest defensible such detector — the
//! **PageHinkley test** — as a pure, dependency-free clock-agnostic alarm. It
//! is applied **equally** to the agentic clock *and* the fair baseline (and the
//! constant-rate clocks), exactly as the fixed-window alarm was, so any change in
//! verdict is a fair same-detector-both-sides result, not a manufactured win.
//!
//! [`alarm_step`]: crate::agentic_time::alarm_step
//!
//! ## The PageHinkley test (math)
//!
//! Page's cumulative-sum (CUSUM) change-point test (Page, 1954) detects a shift
//! in the mean of a stream `x₁, x₂, …`. The *Hinkley* one-sided form tracks the
//! cumulative deviation of each sample from the **running mean** `x̄_T`, minus a
//! tolerance `δ` for changes considered "normal":
//!
//! ```text
//! x̄_T = (1/T) Σ_{t≤T} x_t (running mean — adapts every step)
//!
//! upward drift accumulator (detect an *increase* in the mean):
//! U_T = Σ_{t≤T} (x_t x̄_t δ)
//! m_T = min_{t≤T} U_t
//! PH_T = U_T m_T (rise above the running minimum)
//! alarm when PH_T > λ
//!
//! downward drift accumulator (detect a *decrease*, symmetric):
//! D_T = Σ_{t≤T} (x_t x̄_t + δ)
//! M_T = max_{t≤T} D_t
//! PH⁻_T = M_T D_T
//! alarm when PH⁻_T > λ
//! ```
//!
//! Intuition: while the stream is stationary, `x_t x̄_t` averages to ≈ 0 and the
//! `−δ` tolerance pulls `U_T` steadily *down*, so `U_T` keeps re-touching its
//! running minimum and `PH_T` stays near 0 — **no alarm on stationary noise**.
//! When the mean genuinely steps **up**, `x_t x̄_t δ` turns positive for a run
//! of samples, `U_T` climbs away from its minimum, and once the accumulated rise
//! exceeds `λ` the test **alarms**. Crucially the reference `x̄_t` is a *running*
//! mean over the whole observed prefix, so — unlike `mean + kσ` over a frozen
//! early window — a high-variance early phase does **not** permanently raise the
//! bar; it is averaged into a reference the later drift is measured against.
//!
//! Because an early-exploration phase makes `x̄_t` *larger* early (so later
//! genuine movement clears a smaller relative bar), and because the test responds
//! to a *sustained directional shift* rather than a single-sample threshold
//! crossing, PageHinkley is precisely the detector M3 argued for.
//!
//! ## Parameters (PRE-REGISTERED — see `real_trace_eval.rs`)
//!
//! * `delta` (`δ`) — magnitude of change tolerated as "normal" before the
//! accumulator starts counting it. Small `δ` ⇒ more sensitive.
//! * `lambda` (`λ`) — detection threshold on the cumulative deviation. Large `λ`
//! ⇒ fewer false alarms, later detection.
//!
//! The real-trace harness fixes `δ` and `λ` **before** any lead is computed and
//! prints them, keeping M3's pre-registration discipline.
//!
//! ## Literature
//!
//! * E. S. Page, *"Continuous Inspection Schemes"*, Biometrika **41**(1/2), 1954,
//! pp. 100115 — the original CUSUM change-point test.
//! * D. V. Hinkley, *"Inference about the change-point in a sequence of random
//! variables"*, Biometrika **57**(1), 1970 — the change-point estimator the
//! one-sided "PageHinkley" running form is named for.
//! * For the streaming/concept-drift framing (same family as the fixed-window
//! baseline): A. Bifet & R. Gavaldà, *"Learning from Time-Changing Data with
//! Adaptive Windowing" (ADWIN)*, SDM 2007.
use crate::agentic_time::{AgentClock, AgentState};
/// Pre-registered PageHinkley parameters. Fixed before any lead is computed.
#[derive(Clone, Copy, Debug)]
pub struct PageHinkley {
/// `δ` — magnitude of change considered normal (tolerance). The accumulator
/// only counts deviations beyond this, so it suppresses stationary jitter.
pub delta: f64,
/// `λ` — alarm threshold on the cumulative deviation from the running mean.
pub lambda: f64,
/// Detect upward shifts (an *increase* in the mean increment) when `true`.
/// The agentic-drift event we predict is a *rise* in structural movement, so
/// the upward form is the natural one; the downward form is provided for
/// completeness and symmetry of the test.
pub upward: bool,
}
impl PageHinkley {
/// Construct an upward (increase-detecting) PageHinkley test.
pub fn upward(delta: f64, lambda: f64) -> Self {
PageHinkley {
delta,
lambda,
upward: true,
}
}
/// Construct a downward (decrease-detecting) PageHinkley test.
pub fn downward(delta: f64, lambda: f64) -> Self {
PageHinkley {
delta,
lambda,
upward: false,
}
}
/// Run the PageHinkley statistic over a raw scalar stream and return the
/// **first index** at which it alarms, or `None` if it never does.
///
/// Index `0` is treated as **padding**, not a sample (consistent with the
/// per-transition increment convention used across the crate, where slot 0 is
/// a padded `0.0`): it is excluded from the running mean and from the
/// accumulator, so the artificial `0 → first-real-increment` jump cannot
/// itself trip the detector. The same exclusion is what lets a constant-rate
/// clock (increments `[0, 1, 1, …]`) stay un-alarmed, exactly as it does
/// under the fixed-window `mean + kσ` alarm (whose baseline is `inc[1..]`).
/// Alarms are therefore reported only from index 1 onward, evaluated over the
/// real increment stream.
pub fn first_alarm(&self, stream: &[f64]) -> Option<usize> {
if stream.len() < 2 {
return None;
}
// Running mean accumulators.
let mut count: f64 = 0.0;
let mut sum: f64 = 0.0;
// Cumulative accumulator and its running extremum.
let mut cum: f64 = 0.0;
let mut extreme: f64 = if self.upward {
f64::INFINITY
} else {
f64::NEG_INFINITY
};
for (i, &x) in stream.iter().enumerate() {
// Slot 0 is padding (the per-transition convention pads it with 0.0);
// exclude it from the running statistics entirely so the artificial
// first jump from the pad into the real stream cannot trip the test.
if i == 0 {
continue;
}
// Update the running mean to INCLUDE the current sample, so the
// reference adapts every step (the whole point vs a frozen window).
count += 1.0;
sum += x;
let mean = sum / count;
if self.upward {
// U_T = Σ (x_t x̄_t δ); alarm on rise above running min.
cum += x - mean - self.delta;
if cum < extreme {
extreme = cum;
}
let ph = cum - extreme;
if i >= 1 && ph > self.lambda {
return Some(i);
}
} else {
// D_T = Σ (x_t x̄_t + δ); alarm on drop below running max.
cum += x - mean + self.delta;
if cum > extreme {
extreme = cum;
}
let ph = extreme - cum;
if i >= 1 && ph > self.lambda {
return Some(i);
}
}
}
None
}
}
/// The first step at which the PageHinkley test, applied to a **clock's own
/// per-step increment stream**, alarms. This is the adaptive counterpart of
/// [`alarm_step`](crate::agentic_time::alarm_step): same input (`clock.increments`),
/// a different — adaptive — detector. Applying it to *any* clock keeps the
/// agentic-vs-baseline comparison fair (same detector on both sides).
pub fn adaptive_alarm_step(
clock: &dyn AgentClock,
trace: &[AgentState],
ph: &PageHinkley,
) -> Option<usize> {
let inc = clock.increments(trace);
ph.first_alarm(&inc)
}
/// Adaptive early-warning lead: steps between the PageHinkley alarm and the
/// failure (0 if no alarm or the alarm is after the failure). Mirrors
/// [`early_warning_lead`](crate::agentic_time::early_warning_lead) but uses the
/// adaptive detector.
pub fn adaptive_early_warning_lead(
clock: &dyn AgentClock,
trace: &[AgentState],
fail_index: usize,
ph: &PageHinkley,
) -> usize {
match adaptive_alarm_step(clock, trace, ph) {
Some(a) if a <= fail_index => fail_index - a,
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agentic_time::{
early_warning_lead, generate_failing_trace, AgenticTime, AgenticWeights, StepCountClock,
WindowedDeltaClock,
};
// -- Core PageHinkley behaviour on synthetic scalar streams ------------
/// A clean step-change: stationary noise, then a sustained level shift. The
/// detector MUST fire, and fire after the step, not before it.
#[test]
fn detects_a_real_step_change() {
// 40 samples around 0.0 (±0.05), then 40 samples around 2.0 (±0.05).
let mut stream = vec![0.0]; // slot-0 padding (per-transition convention)
let mut seed = 1u64;
let mut noise = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
((seed >> 33) as f64 / (1u64 << 31) as f64 - 1.0) * 0.05
};
for _ in 0..40 {
stream.push(0.0 + noise());
}
let step_at = stream.len();
for _ in 0..40 {
stream.push(2.0 + noise());
}
// δ tolerates the 0.05 noise; λ requires a sustained rise.
let ph = PageHinkley::upward(0.1, 1.0);
let alarm = ph
.first_alarm(&stream)
.expect("must detect the level shift");
assert!(
alarm >= step_at,
"alarm {alarm} must come at/after the step at {step_at}, never before"
);
// And it must fire promptly — within a handful of samples of the step.
assert!(
alarm <= step_at + 5,
"alarm {alarm} should follow the step ({step_at}) promptly"
);
}
/// Stationary noise only: the detector MUST NOT fire (no false alarm).
#[test]
fn does_not_fire_on_stationary_noise() {
let mut stream = vec![0.0];
let mut seed = 99u64;
let mut noise = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
((seed >> 33) as f64 / (1u64 << 31) as f64 - 1.0) * 0.1
};
for _ in 0..200 {
stream.push(1.0 + noise());
}
let ph = PageHinkley::upward(0.2, 1.0);
assert_eq!(
ph.first_alarm(&stream),
None,
"PageHinkley must not alarm on stationary noise (δ tolerates jitter)"
);
}
/// A constant stream cannot trip the detector (zero deviation from its mean).
#[test]
fn constant_stream_never_alarms() {
let stream = vec![0.0; 100];
let ph = PageHinkley::upward(0.0, 0.5);
assert_eq!(ph.first_alarm(&stream), None);
let flat = vec![3.3_f64; 100];
assert_eq!(ph.first_alarm(&flat), None);
}
/// Build a slot-0-padded step-change stream: `n_low` samples at `low`, then
/// `n_high` samples at `high`.
fn step_stream(low: f64, n_low: usize, high: f64, n_high: usize) -> Vec<f64> {
let mut s = vec![0.0]; // slot-0 padding (per-transition convention)
s.extend(std::iter::repeat_n(low, n_low));
s.extend(std::iter::repeat_n(high, n_high));
s
}
/// The downward form catches a level DROP and ignores a level RISE.
#[test]
fn downward_form_detects_a_drop_not_a_rise() {
let down = step_stream(2.0, 30, 0.0, 30);
let ph_down = PageHinkley::downward(0.1, 1.0);
assert!(
ph_down.first_alarm(&down).is_some(),
"downward form must detect a level drop"
);
// A pure rise should NOT trip the downward detector.
let up = step_stream(0.0, 30, 2.0, 30);
assert_eq!(
ph_down.first_alarm(&up),
None,
"downward form must ignore a level rise"
);
}
/// A larger λ delays (or suppresses) detection; a smaller λ detects sooner.
/// Monotonicity in the threshold is a basic correctness property.
#[test]
fn larger_lambda_detects_later_or_never() {
let stream = step_stream(0.0, 30, 1.0, 60);
let sensitive = PageHinkley::upward(0.05, 0.5).first_alarm(&stream);
let strict = PageHinkley::upward(0.05, 5.0).first_alarm(&stream);
assert!(sensitive.is_some());
match (sensitive, strict) {
(Some(a), Some(b)) => assert!(b >= a, "stricter λ must not detect earlier"),
(Some(_), None) => {} // stricter suppressed entirely — also valid
_ => panic!("sensitive detector should have fired"),
}
}
/// A larger δ tolerance makes the test less sensitive: a small drift that the
/// sensitive setting catches can be tolerated away by a big δ.
#[test]
fn larger_delta_tolerates_small_drift() {
// A SMALL sustained drift of +0.3 after a flat phase.
let stream = step_stream(0.0, 30, 0.3, 60);
let sensitive = PageHinkley::upward(0.05, 0.5).first_alarm(&stream);
let tolerant = PageHinkley::upward(0.5, 0.5).first_alarm(&stream);
assert!(sensitive.is_some(), "small δ should catch the small drift");
assert!(
tolerant.is_none(),
"δ (0.5) larger than the drift (0.3) must tolerate it away"
);
}
/// Slot-0 padding is never itself reported as an alarm.
#[test]
fn never_alarms_on_padding_index_zero() {
// Construct a stream whose only "movement" is the padded 0 vs a big first
// real sample; ensure the detector does not return index 0.
let stream = vec![0.0, 5.0, 5.0, 5.0, 5.0];
let ph = PageHinkley::upward(0.0, 0.1);
if let Some(a) = ph.first_alarm(&stream) {
assert!(a >= 1, "alarm index must be ≥ 1 (never the slot-0 pad)");
}
}
/// Short streams degrade gracefully (no panic, no alarm).
#[test]
fn short_streams_are_safe() {
let ph = PageHinkley::upward(0.1, 1.0);
assert_eq!(ph.first_alarm(&[]), None);
assert_eq!(ph.first_alarm(&[0.0]), None);
// A 2-sample stream must not panic; whatever it returns is index ≥ 1.
if let Some(a) = ph.first_alarm(&[0.0, 1.0]) {
assert!(a >= 1);
}
}
// -- Clock-wired adaptive alarms ----------------------------------------
/// The adaptive alarm wired to a real clock fires on the synthetic failing
/// trace's agentic signal, and the constant-rate step clock (flat increments)
/// never fires — same structural blindness the fixed-window alarm has, so the
/// adaptive detector is not silently rescuing strawmen.
#[test]
fn adaptive_alarm_fires_on_agentic_not_on_constant_clock() {
let tr = generate_failing_trace(0xA9E1);
let agentic = AgenticTime::new(AgenticWeights::default());
let ph = PageHinkley::upward(0.1, 1.0);
let agentic_alarm = adaptive_alarm_step(&agentic, &tr.states, &ph);
assert!(
agentic_alarm.is_some(),
"adaptive detector must fire on the agentic signal of the failing trace"
);
// The step-count clock emits a constant 1.0 per step: zero deviation from
// its running mean, so PageHinkley cannot fire on it (just like the
// fixed-window mean+kσ alarm). Confirms the adaptive detector is not a
// free pass for constant-rate strawmen.
let step_alarm = adaptive_alarm_step(&StepCountClock, &tr.states, &ph);
assert_eq!(
step_alarm, None,
"constant-rate step clock must not alarm even under the adaptive detector"
);
}
/// The adaptive lead is well-formed: a non-zero lead means the alarm preceded
/// the failure; it is bounded by the failure index.
#[test]
fn adaptive_lead_is_well_formed() {
let tr = generate_failing_trace(0xA9E1);
let agentic = AgenticTime::new(AgenticWeights::default());
let ph = PageHinkley::upward(0.1, 1.0);
let lead = adaptive_early_warning_lead(&agentic, &tr.states, tr.fail_index, &ph);
assert!(
lead <= tr.fail_index,
"lead cannot exceed the failure index"
);
if let Some(a) = adaptive_alarm_step(&agentic, &tr.states, &ph) {
if a <= tr.fail_index {
assert_eq!(lead, tr.fail_index - a);
} else {
assert_eq!(lead, 0);
}
}
}
/// Same-detector fairness: the adaptive detector can be applied to BOTH the
/// agentic clock and the fair windowed baseline with identical parameters —
/// the property that makes any verdict change a fair comparison, not an
/// artifact. We only assert the mechanism runs identically on both; the
/// outcome (who wins) is reported by the example, not asserted here.
#[test]
fn adaptive_detector_applies_to_both_sides_identically() {
let tr = generate_failing_trace(0xA9E1);
let agentic = AgenticTime::new(AgenticWeights::default());
let baseline = WindowedDeltaClock::belief_shift(tr.baseline_window);
let ph = PageHinkley::upward(0.1, 1.0);
// Both calls take the SAME detector instance — same δ, same λ.
let _a = adaptive_early_warning_lead(&agentic, &tr.states, tr.fail_index, &ph);
let _b = adaptive_early_warning_lead(&baseline, &tr.states, tr.fail_index, &ph);
// (Mechanism check: identical detector, both produce a defined lead.)
}
/// Cross-check against the fixed-window alarm on the synthetic trace: the
/// adaptive detector is a genuinely *different* detector, so it need not agree
/// with the fixed-window alarm — but both must be live (able to fire) on the
/// agentic signal. This documents that we swapped the detector, not the clock.
#[test]
fn adaptive_and_fixed_window_are_distinct_live_detectors() {
let tr = generate_failing_trace(0xA9E1);
let agentic = AgenticTime::new(AgenticWeights::default());
let bw = tr.baseline_window;
let fixed_lead = early_warning_lead(&agentic, &tr.states, tr.fail_index, bw, 4.0);
let ph = PageHinkley::upward(0.1, 1.0);
let adaptive_lead = adaptive_early_warning_lead(&agentic, &tr.states, tr.fail_index, &ph);
// Both are live on this designed trace (both can produce a lead > 0).
assert!(
fixed_lead > 0,
"fixed-window alarm fires on the synthetic trace"
);
assert!(
adaptive_lead > 0,
"adaptive alarm also fires on the synthetic trace"
);
}
}

View file

@ -0,0 +1,162 @@
//! Causal event time for agents.
//!
//! Wall-clock time is often noise for an agent: an hour with no state change
//! carries no information, while one contradiction can reorganize everything.
//! Here time is read off the *causal structure* of events, not timestamps.
//!
//! Each event records its causal parents and an information measure (entropy of
//! the agent's belief/state). Two derived quantities give an internal clock:
//!
//! * **causal depth** — the longest chain of causes leading to the event, a
//! robust integer ordering that survives shuffled arrival order;
//! * **internal time** — accumulated *irreversible* change (entropy reduction)
//! along the causal history, a continuous monotone.
/// A single event in the agent's causal history.
#[derive(Clone, Debug)]
pub struct Event {
/// Causal parents (indices of earlier events).
pub parents: Vec<usize>,
/// Information content of the agent's state at this event (nats).
pub entropy: f64,
}
/// An append-only causal DAG of events.
#[derive(Default)]
pub struct CausalTimeline {
events: Vec<Event>,
}
impl CausalTimeline {
pub fn new() -> Self {
CausalTimeline { events: Vec::new() }
}
pub fn len(&self) -> usize {
self.events.len()
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
pub fn event(&self, id: usize) -> &Event {
&self.events[id]
}
/// Append an event with the given parents and state entropy. Parents must
/// reference already-added events (smaller ids), keeping the graph acyclic.
/// Returns the new event id.
pub fn add(&mut self, parents: Vec<usize>, entropy: f64) -> usize {
let id = self.events.len();
for &p in &parents {
assert!(p < id, "parent {p} must precede event {id}");
}
self.events.push(Event { parents, entropy });
id
}
/// Causal depth: longest path from any root to `id`. Roots have depth 0.
/// This is the agent's discrete event-time, independent of arrival order.
pub fn causal_depth(&self, id: usize) -> usize {
let mut memo = vec![usize::MAX; self.events.len()];
self.depth_rec(id, &mut memo)
}
fn depth_rec(&self, id: usize, memo: &mut [usize]) -> usize {
if memo[id] != usize::MAX {
return memo[id];
}
let d = self.events[id]
.parents
.iter()
.map(|&p| 1 + self.depth_rec(p, memo))
.max()
.unwrap_or(0);
memo[id] = d;
d
}
/// Internal time: accumulated entropy *reduction* along the maximal causal
/// path to `id`. Each causal step contributes `max(0, S_parent - S_event)`,
/// so internal time is non-decreasing along every causal edge — a quiet
/// stretch adds nothing, a sharp drop in uncertainty adds a lot.
pub fn internal_time(&self, id: usize) -> f64 {
let mut memo = vec![f64::NAN; self.events.len()];
self.itime_rec(id, &mut memo)
}
fn itime_rec(&self, id: usize, memo: &mut [f64]) -> f64 {
if !memo[id].is_nan() {
return memo[id];
}
let s = self.events[id].entropy;
let t = self.events[id]
.parents
.iter()
.map(|&p| {
let reduction = (self.events[p].entropy - s).max(0.0);
self.itime_rec(p, memo) + reduction
})
.fold(0.0f64, f64::max);
memo[id] = t;
t
}
/// Topological ordering (events sorted by causal depth, ties by id). This is
/// the sequence an internal observer experiences, recovered without any
/// clock.
pub fn causal_order(&self) -> Vec<usize> {
let mut ids: Vec<usize> = (0..self.events.len()).collect();
ids.sort_by(|&a, &b| {
self.causal_depth(a)
.cmp(&self.causal_depth(b))
.then(a.cmp(&b))
});
ids
}
/// Verify internal time is non-decreasing across every causal edge.
pub fn preserves_causal_order(&self) -> bool {
for id in 0..self.events.len() {
let t = self.internal_time(id);
for &p in &self.events[id].parents {
if self.internal_time(p) > t + 1e-9 {
return false;
}
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn depth_follows_longest_chain() {
let mut tl = CausalTimeline::new();
let a = tl.add(vec![], 2.0);
let b = tl.add(vec![a], 1.8);
let c = tl.add(vec![a], 1.9);
let d = tl.add(vec![b, c], 1.0);
assert_eq!(tl.causal_depth(a), 0);
assert_eq!(tl.causal_depth(d), 2);
assert_eq!(tl.causal_order()[0], a);
assert_eq!(*tl.causal_order().last().unwrap(), d);
}
#[test]
fn internal_time_jumps_on_uncertainty_drop() {
let mut tl = CausalTimeline::new();
let a = tl.add(vec![], 3.0);
// idle step: no entropy change -> no internal time advance
let b = tl.add(vec![a], 3.0);
// contradiction resolved: big entropy drop -> big internal-time jump
let c = tl.add(vec![b], 0.5);
assert!((tl.internal_time(b) - 0.0).abs() < 1e-12);
assert!((tl.internal_time(c) - 2.5).abs() < 1e-12);
assert!(tl.preserves_causal_order());
}
}

View file

@ -0,0 +1,907 @@
//! **Agentic Time** — a clock for autonomous systems where time is measured by
//! meaningful state change, not seconds, tokens, or steps.
//!
//! > Wall-clock time tells you *when* something happened.
//! > Agentic time tells you *how much the agent changed.*
//!
//! An agent can run for 30 minutes and barely age; or hit one contradiction and
//! age massively in a second. The agentic-time increment over a transition is
//!
//! ```text
//! τ_a = f(ΔB, ΔM, ΔR, ΔG, ΔE, ΔP)
//! ```
//!
//! * `ΔB` — belief change,
//! * `ΔM` — memory change,
//! * `ΔR` — retrieval change,
//! * `ΔG` — goal-graph movement,
//! * `ΔE` — error / contradiction change,
//! * `ΔP` — plan change.
//!
//! The **Agentic Time Index** (ATI) is *progress per unit structural change*:
//! high ATI means the agent is learning and moving; low ATI means it is
//! spinning; falling progress means it is accumulating confusion. ATI drives a
//! health classifier (`Healthy`, `Drifting`, `Stuck`, `NeedsReplan`,
//! `Contradicting`, `Collapsing`, `NeedsHumanReview`).
//!
//! The included demo runs an agent trace through four clocks — wall, step count,
//! token count, agentic — and shows agentic time flags trouble earliest.
fn l2(a: &[f64], b: &[f64]) -> f64 {
a.iter()
.zip(b)
.map(|(x, y)| (x - y) * (x - y))
.sum::<f64>()
.sqrt()
}
/// A snapshot of an agent's cognitive state.
#[derive(Clone, Debug)]
pub struct AgentState {
/// Belief embedding. `ΔB`
pub belief: Vec<f64>,
/// Working-memory embedding. `ΔM`
pub memory: Vec<f64>,
/// Retrieved-context embedding. `ΔR`
pub retrieval: Vec<f64>,
/// Goal-graph summary (e.g. open-subgoal mass). `ΔG`
pub goal_graph: f64,
/// Contradiction / error score in `[0, 1]`. `ΔE`
pub contradiction: f64,
/// Plan embedding. `ΔP`
pub plan: Vec<f64>,
/// Cumulative tokens consumed (for the token-count clock).
pub tokens: u64,
}
/// A clock over agent states: assigns a non-negative internal-time increment to
/// each transition.
pub trait AgentClock {
fn name(&self) -> &str;
fn tick(&self, prev: &AgentState, cur: &AgentState) -> f64;
fn increments(&self, trace: &[AgentState]) -> Vec<f64> {
let mut out = vec![0.0];
for i in 1..trace.len() {
out.push(self.tick(&trace[i - 1], &trace[i]).max(0.0));
}
out
}
fn cumulative(&self, trace: &[AgentState]) -> Vec<f64> {
let mut acc = 0.0;
let mut out = Vec::with_capacity(trace.len());
for (i, _s) in trace.iter().enumerate() {
if i > 0 {
acc += self.tick(&trace[i - 1], &trace[i]).max(0.0);
}
out.push(acc);
}
out
}
}
/// Wall-clock: one tick per observation, regardless of what changed.
pub struct AgentWallClock;
impl AgentClock for AgentWallClock {
fn name(&self) -> &str {
"wall"
}
fn tick(&self, _p: &AgentState, _c: &AgentState) -> f64 {
1.0
}
}
/// Step-count: identical to wall-clock here (one step per observation), included
/// to make the four-clock comparison explicit.
pub struct StepCountClock;
impl AgentClock for StepCountClock {
fn name(&self) -> &str {
"step-count"
}
fn tick(&self, _p: &AgentState, _c: &AgentState) -> f64 {
1.0
}
}
/// Token-count: internal time advances with tokens consumed.
pub struct TokenCountClock;
impl AgentClock for TokenCountClock {
fn name(&self) -> &str {
"token-count"
}
fn tick(&self, p: &AgentState, c: &AgentState) -> f64 {
c.tokens.saturating_sub(p.tokens) as f64
}
}
/// A **fair, non-strawman baseline**: a rolling-window change-point detector on a
/// single cheap scalar observable (no physics decomposition, no embeddings).
///
/// The wall / step / token clocks emit a *constant* per-step rate, so their
/// baseline standard deviation is zero and the `mean + k·σ` alarm can never fire
/// — they are strawmen that cannot alarm by construction. This clock is the
/// honest competitor: it computes, at each step, the absolute deviation of the
/// observable from the trailing window mean, normalized by the window's standard
/// deviation (a z-score / mean+k·std change-point detector). It is exactly the
/// kind of cheap detector a practitioner would actually deploy on a single signal
/// (token-delta by default) before reaching for state embeddings, so beating it
/// — or merely matching it — is the meaningful comparison.
///
/// By default the observable is **token-delta** (the strongest plausible cheap
/// signal that is always available without embeddings). The detector is the same
/// family as ADWIN / process-mining concept-drift detectors (Ostovar et al.,
/// 2016): windowed statistics over a scalar stream.
pub struct WindowedDeltaClock {
/// Trailing window length used to estimate the running mean/std.
pub window: usize,
/// Extracts the scalar observable for a transition `(prev, cur)`.
pub observable: fn(&AgentState, &AgentState) -> f64,
/// Human-readable name of the observable (for reporting).
pub observable_name: &'static str,
/// Variance floor (added to the window std) so a near-constant / quantized
/// observable does not make the z-score blow up to ∞ and fire spuriously
/// early. This is standard practice for deployed z-score change-point
/// detectors and is what keeps the baseline *fair* rather than degenerate.
pub std_floor: f64,
}
impl WindowedDeltaClock {
/// The default fair baseline: a windowed z-score on **token-delta**. The std
/// floor is scaled to the token-delta magnitude (~1 token of quantization
/// noise) so the near-constant integer stream does not trip a spurious ∞
/// z-score.
pub fn token_delta(window: usize) -> Self {
WindowedDeltaClock {
window,
observable: |p, c| c.tokens.saturating_sub(p.tokens) as f64,
observable_name: "token-delta",
std_floor: 1.0,
}
}
/// A fair baseline on the **belief-shift** observable (the cheapest structural
/// signal the agentic clock also sees), for an apples-to-apples comparison on
/// the same input the physics clock uses.
pub fn belief_shift(window: usize) -> Self {
WindowedDeltaClock {
window,
observable: |p, c| l2(&p.belief, &c.belief),
observable_name: "belief-shift",
std_floor: 1e-6,
}
}
/// The raw observable series for a trace (index 0 is a padded 0.0 to align
/// with the per-transition increment convention).
fn observable_series(&self, trace: &[AgentState]) -> Vec<f64> {
let mut out = vec![0.0];
for i in 1..trace.len() {
out.push((self.observable)(&trace[i - 1], &trace[i]));
}
out
}
}
impl AgentClock for WindowedDeltaClock {
fn name(&self) -> &str {
"windowed-baseline"
}
/// The per-step "tick" is the rolling z-score magnitude of the observable:
/// how many trailing-window standard deviations the current observable sits
/// from the trailing-window mean. This is a true change-point signal, so its
/// baseline variance is non-zero and the `mean + k·σ` alarm can actually fire
/// — unlike the constant-rate strawmen.
fn tick(&self, prev: &AgentState, cur: &AgentState) -> f64 {
// A single-transition tick has no trailing window context; the windowed
// z-score is only meaningful via `increments`. Fall back to the raw
// observable magnitude so a standalone tick is still well defined.
(self.observable)(prev, cur).abs()
}
fn increments(&self, trace: &[AgentState]) -> Vec<f64> {
let series = self.observable_series(trace);
let w = self.window.max(2);
let mut out = vec![0.0; trace.len()];
for i in 1..trace.len() {
// Trailing window of observables strictly before i.
let start = i.saturating_sub(w);
let win = &series[start..i];
if win.len() < 2 {
out[i] = 0.0;
continue;
}
let mean = win.iter().sum::<f64>() / win.len() as f64;
let var = win.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / win.len() as f64;
// Apply the variance floor so a near-constant / quantized observable
// can't produce a spurious ∞ z-score and an artificially early alarm.
let std = var.sqrt().max(self.std_floor);
let dev = (series[i] - mean).abs();
out[i] = dev / std;
}
out
}
}
/// Weights for the six agentic-time channels.
#[derive(Clone, Copy, Debug)]
pub struct AgenticWeights {
pub belief: f64,
pub memory: f64,
pub retrieval: f64,
pub goal_graph: f64,
pub contradiction: f64,
pub plan: f64,
}
impl Default for AgenticWeights {
fn default() -> Self {
// Contradictions age an agent the most; memory/retrieval the least.
AgenticWeights {
belief: 1.0,
memory: 0.5,
retrieval: 0.5,
goal_graph: 1.0,
contradiction: 1.5,
plan: 1.0,
}
}
}
/// Agentic Time: `τ_a = Σ wᵢ·d(channelᵢ)`.
pub struct AgenticTime {
pub weights: AgenticWeights,
}
impl AgenticTime {
pub fn new(weights: AgenticWeights) -> Self {
AgenticTime { weights }
}
}
impl AgentClock for AgenticTime {
fn name(&self) -> &str {
"agentic"
}
fn tick(&self, p: &AgentState, c: &AgentState) -> f64 {
let w = &self.weights;
w.belief * l2(&p.belief, &c.belief)
+ w.memory * l2(&p.memory, &c.memory)
+ w.retrieval * l2(&p.retrieval, &c.retrieval)
+ w.goal_graph * (c.goal_graph - p.goal_graph).abs()
+ w.contradiction * (c.contradiction - p.contradiction).abs()
+ w.plan * l2(&p.plan, &c.plan)
}
}
/// Classification of an agentic-time tick (ADR-251 §8.4).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TickClass {
/// Below the noise floor — no meaningful change.
Idle,
/// Belief / plan / goal moved forward.
Progress,
/// New information arrived (retrieval / memory moved).
Learning,
/// Contradiction rose.
Contradiction,
/// Contradiction is high — failure regime.
Collapse,
}
/// An explainable agentic-time tick: the magnitude, its class, a human-readable
/// reason, and the per-channel weighted contributions (ADR-251 invariant §31.5:
/// every tick must have an auditable reason).
///
/// ## Contract: `delta` is post-floor, per-channel fields are pre-floor (raw)
///
/// The per-channel fields (`belief`, `memory`, `retrieval`, `goal_graph`,
/// `contradiction`, `plan`) report the **raw weighted contribution** of each
/// channel *before* the noise floor is subtracted. `delta` is the **post-floor
/// magnitude**, `delta = max(0, Σ channels noise_floor)`. Therefore:
///
/// * the identity `delta == Σ channels` holds **only when `noise_floor == 0`**;
/// * with a positive floor and `Σ channels > noise_floor`, `delta` is strictly
/// smaller than `Σ channels` by exactly `noise_floor`;
/// * with `Σ channels ≤ noise_floor`, `delta == 0` while the channels stay
/// non-zero (the movement existed but was below the reporting threshold).
///
/// This is deliberate: the per-channel attribution explains *what moved* (an
/// audit/diagnostic view of raw movement), while `delta` is the *reportable*
/// internal-time increment after jitter suppression. Consumers that need the
/// pre-floor total should sum the channels; consumers that need the emitted
/// increment should read `delta`.
#[derive(Clone, Debug)]
pub struct Tick {
/// Post-floor internal-time magnitude: `max(0, Σ channels noise_floor)`.
pub delta: f64,
pub class: TickClass,
pub reason: String,
/// Raw (pre-floor) weighted belief contribution.
pub belief: f64,
/// Raw (pre-floor) weighted memory contribution.
pub memory: f64,
/// Raw (pre-floor) weighted retrieval contribution.
pub retrieval: f64,
/// Raw (pre-floor) weighted goal-graph contribution.
pub goal_graph: f64,
/// Raw (pre-floor) weighted contradiction contribution.
pub contradiction: f64,
/// Raw (pre-floor) weighted plan contribution.
pub plan: f64,
}
impl AgenticTime {
/// Compute an explainable tick for a transition. `noise_floor` suppresses
/// jitter; the returned per-channel contributions are the **raw (pre-floor)**
/// weighted movements, while `Tick::delta` is the **post-floor** magnitude
/// `max(0, Σ channels noise_floor)`. See [`Tick`] for the full contract:
/// the identity `delta == Σ channels` holds only when `noise_floor == 0`.
pub fn explain(&self, p: &AgentState, c: &AgentState, noise_floor: f64) -> Tick {
let w = &self.weights;
let belief = w.belief * l2(&p.belief, &c.belief);
let memory = w.memory * l2(&p.memory, &c.memory);
let retrieval = w.retrieval * l2(&p.retrieval, &c.retrieval);
let goal_graph = w.goal_graph * (c.goal_graph - p.goal_graph).abs();
let contradiction = w.contradiction * (c.contradiction - p.contradiction).abs();
let plan = w.plan * l2(&p.plan, &c.plan);
let delta = (belief + memory + retrieval + goal_graph + contradiction + plan - noise_floor)
.max(0.0);
// Dominant channel drives the class and reason.
let channels = [
("belief", belief),
("memory", memory),
("retrieval", retrieval),
("goal-graph", goal_graph),
("contradiction", contradiction),
("plan", plan),
];
let (dom_name, dom_val) =
channels
.iter()
.copied()
.fold(("none", 0.0), |acc, x| if x.1 > acc.1 { x } else { acc });
let class = if delta <= 0.0 {
TickClass::Idle
} else if dom_name == "contradiction" {
if c.contradiction >= 0.5 {
TickClass::Collapse
} else {
TickClass::Contradiction
}
} else if dom_name == "retrieval" || dom_name == "memory" {
TickClass::Learning
} else {
TickClass::Progress
};
let reason = if delta <= 0.0 {
"no meaningful state change".to_string()
} else {
format!(
"{class:?}: dominated by {dom_name} movement ({dom_val:.3} of {delta:.3} total)"
)
};
Tick {
delta,
class,
reason,
belief,
memory,
retrieval,
goal_graph,
contradiction,
plan,
}
}
}
/// Health verdicts derived from the Agentic Time Index.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AgentHealth {
/// Progress is keeping pace with internal change.
Healthy,
/// Moving, but inefficiently (low progress per unit change).
Drifting,
/// Neither changing nor progressing.
Stuck,
/// Lots of internal churn, no progress — replan.
NeedsReplan,
/// Losing ground (progress going backwards).
Contradicting,
/// Contradiction is high and rising.
Collapsing,
/// Contradiction is critical — escalate to a human.
NeedsHumanReview,
}
/// Thresholds for the health classifier.
#[derive(Clone, Copy, Debug)]
pub struct HealthThresholds {
pub idle: f64, // below this Δτ, the agent is not changing
pub healthy_ati: f64, // ATI at/above this is healthy
pub drifting_ati: f64, // ATI at/above this is drifting (else replan)
pub collapse: f64, // contradiction at/above this is collapsing
pub human_review: f64, // contradiction at/above this escalates
}
impl Default for HealthThresholds {
fn default() -> Self {
HealthThresholds {
idle: 1e-3,
healthy_ati: 0.5,
drifting_ati: 0.1,
collapse: 0.5,
human_review: 0.8,
}
}
}
/// The Agentic Time Index: progress per unit of structural change over a window.
pub fn agentic_time_index(delta_tau: f64, delta_progress: f64) -> f64 {
if delta_tau <= 1e-12 {
// No internal change: efficiency is "infinite" if progressing, else 0.
if delta_progress > 0.0 {
f64::INFINITY
} else {
0.0
}
} else {
delta_progress / delta_tau
}
}
/// Classify agent health from the change in agentic time, the change in
/// progress, and the current contradiction level over a window.
pub fn classify(
delta_tau: f64,
delta_progress: f64,
contradiction: f64,
th: &HealthThresholds,
) -> AgentHealth {
if contradiction >= th.human_review {
return AgentHealth::NeedsHumanReview;
}
if contradiction >= th.collapse {
return AgentHealth::Collapsing;
}
if delta_progress < -1e-9 {
return AgentHealth::Contradicting;
}
if delta_tau < th.idle {
// Not changing. Progressing-while-static is fine; otherwise stuck.
return if delta_progress > th.idle {
AgentHealth::Healthy
} else {
AgentHealth::Stuck
};
}
let ati = agentic_time_index(delta_tau, delta_progress);
if ati >= th.healthy_ati {
AgentHealth::Healthy
} else if ati >= th.drifting_ati {
AgentHealth::Drifting
} else {
AgentHealth::NeedsReplan
}
}
/// First step where a clock's rate exceeds `mean + k·std` of its baseline.
pub fn alarm_step(
clock: &dyn AgentClock,
trace: &[AgentState],
baseline_window: usize,
k_sigma: f64,
) -> Option<usize> {
let inc = clock.increments(trace);
if trace.len() <= baseline_window + 1 {
return None;
}
let base = &inc[1..=baseline_window];
let mean = base.iter().sum::<f64>() / base.len() as f64;
let var = base.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / base.len() as f64;
let threshold = mean + k_sigma * var.sqrt();
for i in (baseline_window + 1)..trace.len() {
if inc[i] > threshold {
return Some(i);
}
}
None
}
/// Early-warning lead: steps between the alarm and the failure (0 if no alarm).
pub fn early_warning_lead(
clock: &dyn AgentClock,
trace: &[AgentState],
fail_index: usize,
baseline_window: usize,
k_sigma: f64,
) -> usize {
match alarm_step(clock, trace, baseline_window, k_sigma) {
Some(a) if a <= fail_index => fail_index - a,
_ => 0,
}
}
// ---------------------------------------------------------------------------
// Synthetic agent traces (deterministic).
// ---------------------------------------------------------------------------
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Rng(seed | 1)
}
fn unit(&mut self) -> f64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
let v = x.wrapping_mul(0x2545_F491_4F6C_DD1D);
((v >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
}
}
/// A labelled agent trace plus its progress curve and failure index.
pub struct AgentTrace {
pub states: Vec<AgentState>,
pub progress: Vec<f64>,
pub fail_index: usize,
pub thrash_onset: usize,
pub baseline_window: usize,
}
/// Generate a failing workflow trace: an early healthy phase where belief and
/// plan converge and progress rises, then a *thrash onset* where the plan
/// oscillates, retrieval destabilizes, and contradictions climb while progress
/// stalls — culminating in failure. Tokens accrue at a near-constant rate, so
/// wall / step / token clocks stay blind to the internal collapse.
pub fn generate_failing_trace(seed: u64) -> AgentTrace {
let dim = 6;
let steps = 100;
let onset = 40;
let fail_index = 80;
let baseline_window = 18;
let mut rng = Rng::new(seed);
let target: Vec<f64> = (0..dim)
.map(|i| if i % 2 == 0 { 1.0 } else { -1.0 })
.collect();
let mut states = Vec::with_capacity(steps);
let mut progress = Vec::with_capacity(steps);
let mut tokens = 0u64;
for i in 0..steps {
tokens += 120 + (rng.unit().abs() * 10.0) as u64;
let (belief, plan, retrieval, contradiction, prog) = if i < onset {
// Healthy convergence: belief/plan ease toward target; progress rises.
let frac = i as f64 / onset as f64;
let belief: Vec<f64> = target
.iter()
.map(|&t| frac * t + 0.01 * rng.unit())
.collect();
let plan = belief.clone();
let retrieval: Vec<f64> = target.iter().map(|&t| frac * t).collect();
(belief, plan, retrieval, 0.05, 0.5 * frac)
} else {
// Thrash: plan oscillates hard, retrieval unstable, contradiction
// climbs, progress stalls near 0.5.
let osc = if i % 2 == 0 { 1.0 } else { -1.0 };
let p = (i - onset) as f64 / (fail_index - onset) as f64;
let belief: Vec<f64> = target
.iter()
.map(|&t| t + 0.3 * osc + 0.05 * rng.unit())
.collect();
let plan: Vec<f64> = target
.iter()
.map(|&t| t + 0.8 * osc + 0.1 * rng.unit())
.collect();
let retrieval: Vec<f64> = target.iter().map(|&t| t + 0.4 * rng.unit()).collect();
let contradiction = (0.05 + 0.9 * p).min(0.95);
(belief, plan, retrieval, contradiction, 0.5)
};
let goal_graph = if i < onset {
(onset - i) as f64 / onset as f64 // open subgoals shrinking
} else {
1.0 + (i - onset) as f64 * 0.05 // subgoals reopening (thrash)
};
states.push(AgentState {
belief,
memory: states
.last()
.map(|s: &AgentState| s.belief.clone())
.unwrap_or_else(|| vec![0.0; dim]),
retrieval,
goal_graph,
contradiction,
plan,
tokens,
});
progress.push(prog);
}
AgentTrace {
states,
progress,
fail_index,
thrash_onset: onset,
baseline_window,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agentic_time_beats_constant_rate_strawmen() {
// This test documents the COVERAGE GAP the constant-rate clocks have on
// the designed trace — it is NOT a competitive win claim. The wall / step
// / token clocks emit a constant per-step rate, so their `mean + k·σ`
// alarm cannot fire on a trace where the planted signal is structural,
// not chronological. The fair baseline is tested separately below.
let tr = generate_failing_trace(0xA9E1);
let agentic = AgenticTime::new(AgenticWeights::default());
let bw = tr.baseline_window;
let lead_wall = early_warning_lead(&AgentWallClock, &tr.states, tr.fail_index, bw, 4.0);
let lead_step = early_warning_lead(&StepCountClock, &tr.states, tr.fail_index, bw, 4.0);
let lead_token = early_warning_lead(&TokenCountClock, &tr.states, tr.fail_index, bw, 4.0);
let lead_agentic = early_warning_lead(&agentic, &tr.states, tr.fail_index, bw, 4.0);
// The three constant-rate clocks are blind to the internal collapse —
// by construction, not because the comparison was rigged: a constant
// signal has zero baseline variance.
assert_eq!(lead_wall, 0);
assert_eq!(lead_step, 0);
assert_eq!(lead_token, 0);
// Agentic time flags it before the visible failure.
assert!(lead_agentic > 0, "agentic clock must fire before failure");
// It should fire right around the thrash onset (the planted structural
// precursor). The lead magnitude is a PROPERTY OF THE CONSTRUCTED TRACE
// (how far the precursor precedes the failure index), not a measured
// competitive margin.
let alarm = alarm_step(&agentic, &tr.states, bw, 4.0).unwrap();
assert!(alarm <= tr.thrash_onset + 2);
}
#[test]
fn fair_windowed_baseline_is_a_real_competitor_not_a_strawman() {
// The fair baseline (windowed z-score change-point detector) is NOT a
// strawman: its baseline variance is non-zero, so its alarm CAN fire —
// and on this designed trace it DOES, at least as early as the agentic
// clock. This is the honest, credibility-strengthening result the M1
// hardening is meant to surface: the agentic clock does NOT beat a fair
// cheap baseline on this synthetic trace. Any genuine competitive claim
// must come from a REAL trace (M3), not this constructed one.
//
// Falsifiable facts asserted here:
// 1. both fair windowed detectors actually FIRE (non-strawman);
// 2. the belief-shift detector catches the planted structural signal
// with a lead at least as large as the agentic clock's — so the
// agentic clock has NO measured advantage over it here;
// 3. the token-delta detector also fires early, but as a quantization-
// noise artifact (tokens are a near-constant integer stream), which
// we document rather than hide — a naive z-score on a quantized
// near-constant signal trips on jitter, it is not "detecting" the
// structural drift.
let tr = generate_failing_trace(0xA9E1);
let bw = tr.baseline_window;
let agentic = AgenticTime::new(AgenticWeights::default());
let token_base = WindowedDeltaClock::token_delta(bw);
let belief_base = WindowedDeltaClock::belief_shift(bw);
let lead_agentic = early_warning_lead(&agentic, &tr.states, tr.fail_index, bw, 4.0);
let lead_token_base = early_warning_lead(&token_base, &tr.states, tr.fail_index, bw, 4.0);
let lead_belief_base = early_warning_lead(&belief_base, &tr.states, tr.fail_index, bw, 4.0);
// (1) Both fair detectors fire — they are real competitors, not strawmen.
assert!(
lead_token_base > 0 && lead_belief_base > 0,
"fair windowed baselines must be able to fire (token={lead_token_base}, \
belief={lead_belief_base})"
);
// (2) The belief-shift fair baseline matches or beats the agentic clock
// on this designed trace: the agentic clock has NO measured edge here.
// (We assert ≥ to lock in the honest "no competitive win" conclusion; if
// a future change made the agentic clock beat it on THIS trace, that
// would be suspicious — the designed trace plants a single structural
// precursor that a one-channel detector already sees.)
assert!(
lead_belief_base >= lead_agentic,
"on this DESIGNED trace the fair belief-shift baseline (lead \
{lead_belief_base}) should be at least as early as the agentic clock \
(lead {lead_agentic}); the agentic clock is not supposed to beat a \
fair baseline on synthetic data that requires a real trace (M3)"
);
}
#[test]
fn contradiction_free_weights_blind_to_error_channel() {
// M3 circularity guard. The real-trace evaluation defines its
// event-to-predict (an error cascade) from the harness `is_error` flag,
// which also feeds the `contradiction` channel. To keep the agentic-vs-
// baseline comparison non-circular, M3 runs an *honest* variant with
// `contradiction = 0`, so the clock cannot read the very signal that
// defines the event. This test locks that property in: with the
// contradiction weight zeroed, a pure contradiction jump contributes
// EXACTLY zero to the tick, while the full-weight clock sees it.
let honest = AgenticTime::new(AgenticWeights {
contradiction: 0.0,
..AgenticWeights::default()
});
let full = AgenticTime::new(AgenticWeights::default());
let base = AgentState {
belief: vec![1.0, 0.0],
memory: vec![0.0],
retrieval: vec![0.0],
goal_graph: 0.0,
contradiction: 0.0,
plan: vec![1.0, 0.0],
tokens: 0,
};
// Only the contradiction channel moves between base and `errored`.
let mut errored = base.clone();
errored.contradiction = 0.9;
let honest_tick = honest.tick(&base, &errored);
let full_tick = full.tick(&base, &errored);
// Honest clock is blind to the error-only move; full clock is not.
assert!(
honest_tick.abs() < 1e-12,
"honest (contradiction=0) clock must not react to a pure error jump, got {honest_tick}"
);
assert!(
full_tick > 0.0,
"full clock must react to the contradiction jump (diagnostic variant)"
);
// Sanity: when other channels move, the honest clock DOES react (it is
// not a dead clock — it just ignores the error channel specifically).
let mut belief_moved = base.clone();
belief_moved.belief = vec![0.0, 1.0];
assert!(
honest.tick(&base, &belief_moved) > 0.0,
"honest clock must still react to non-error channel movement"
);
}
#[test]
fn classifier_distinguishes_health() {
let th = HealthThresholds::default();
// Converging fast: healthy.
assert_eq!(classify(1.0, 0.8, 0.05, &th), AgentHealth::Healthy);
// Churning, no progress: replan.
assert_eq!(classify(2.0, 0.0, 0.1, &th), AgentHealth::NeedsReplan);
// Static and not progressing: stuck.
assert_eq!(classify(0.0, 0.0, 0.1, &th), AgentHealth::Stuck);
// Losing ground: contradicting.
assert_eq!(classify(1.0, -0.2, 0.1, &th), AgentHealth::Contradicting);
// High contradiction: collapsing / escalate.
assert_eq!(classify(1.0, 0.0, 0.6, &th), AgentHealth::Collapsing);
assert_eq!(classify(1.0, 0.0, 0.9, &th), AgentHealth::NeedsHumanReview);
}
#[test]
fn ati_high_when_progressing_low_when_spinning() {
let healthy = agentic_time_index(1.0, 0.9);
let spinning = agentic_time_index(5.0, 0.02);
assert!(healthy > spinning);
}
#[test]
fn explain_tick_classifies_and_attributes() {
let tr = generate_failing_trace(0xA9E1);
let agentic = AgenticTime::new(AgenticWeights::default());
// A transition across the thrash onset should be a contradiction/collapse
// tick dominated by an identifiable channel, with a reason string.
let o = tr.thrash_onset;
let tick = agentic.explain(&tr.states[o - 1], &tr.states[o], 0.0);
assert!(tick.delta > 0.0);
assert!(!tick.reason.is_empty());
assert!(matches!(
tick.class,
TickClass::Progress
| TickClass::Learning
| TickClass::Contradiction
| TickClass::Collapse
));
// With noise_floor == 0, the post-floor delta equals the raw channel
// sum exactly (this is the *only* floor value for which the identity
// holds — see the noise-floor test below).
let sum = tick.belief
+ tick.memory
+ tick.retrieval
+ tick.goal_graph
+ tick.contradiction
+ tick.plan;
assert!((tick.delta - sum).abs() < 1e-9);
}
#[test]
fn explain_delta_is_post_floor_channels_are_pre_floor() {
// Regression for the noise-floor contract: per-channel fields are RAW
// (pre-floor) weighted contributions, while `delta` is post-floor. The
// identity delta == Σ channels must therefore FAIL by exactly the floor
// for any noise_floor > 0 when the movement exceeds the floor.
let tr = generate_failing_trace(0xA9E1);
let agentic = AgenticTime::new(AgenticWeights::default());
let o = tr.thrash_onset;
let floor = 0.1;
let tick = agentic.explain(&tr.states[o - 1], &tr.states[o], floor);
let sum = tick.belief
+ tick.memory
+ tick.retrieval
+ tick.goal_graph
+ tick.contradiction
+ tick.plan;
// The thrash-onset transition is large, so sum > floor and delta is
// strictly *less* than the raw channel sum by exactly the floor.
assert!(
sum > floor,
"precondition: movement should exceed the floor"
);
let expected = (sum - floor).max(0.0);
assert!(
(tick.delta - expected).abs() < 1e-9,
"delta {} should equal max(0, sum {} - floor {}) = {}",
tick.delta,
sum,
floor,
expected
);
// And it must NOT equal the raw sum (the bug was reporting them equal).
assert!(
(tick.delta - sum).abs() > floor / 2.0,
"delta must differ from the raw channel sum by ~the floor"
);
// When the movement is below the floor, delta is clamped to 0 while the
// channels stay non-zero (movement existed, just below the threshold).
let big_floor = sum + 1.0;
let clamped = agentic.explain(&tr.states[o - 1], &tr.states[o], big_floor);
assert_eq!(clamped.delta, 0.0);
let clamped_sum = clamped.belief
+ clamped.memory
+ clamped.retrieval
+ clamped.goal_graph
+ clamped.contradiction
+ clamped.plan;
assert!(
clamped_sum > 0.0,
"raw channels stay non-zero under a high floor"
);
}
#[test]
fn idle_transition_is_idle_tick() {
let s = AgentState {
belief: vec![1.0, 2.0],
memory: vec![0.0, 0.0],
retrieval: vec![1.0, 1.0],
goal_graph: 0.5,
contradiction: 0.1,
plan: vec![1.0, 0.0],
tokens: 100,
};
let agentic = AgenticTime::new(AgenticWeights::default());
let tick = agentic.explain(&s, &s.clone(), 1e-6);
assert_eq!(tick.class, TickClass::Idle);
assert_eq!(tick.delta, 0.0);
}
}

View file

@ -0,0 +1,188 @@
//! Minimal complex-scalar arithmetic.
//!
//! Self-contained so the crate pulls in no external linear-algebra deps. Only
//! the operations needed by the emergent-time formalisms are provided.
use std::ops::{Add, AddAssign, Mul, Neg, Sub};
/// A complex number `re + im*i` over `f64`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Complex {
pub re: f64,
pub im: f64,
}
impl Complex {
pub const ZERO: Complex = Complex { re: 0.0, im: 0.0 };
pub const ONE: Complex = Complex { re: 1.0, im: 0.0 };
pub const I: Complex = Complex { re: 0.0, im: 1.0 };
#[inline]
pub const fn new(re: f64, im: f64) -> Self {
Complex { re, im }
}
/// Real scalar embedded into the complex plane.
#[inline]
pub const fn real(re: f64) -> Self {
Complex { re, im: 0.0 }
}
/// `e^{i*theta}` — a unit phasor.
#[inline]
pub fn phase(theta: f64) -> Self {
Complex {
re: theta.cos(),
im: theta.sin(),
}
}
/// Complex conjugate `re - im*i`.
#[inline]
pub fn conj(self) -> Self {
Complex {
re: self.re,
im: -self.im,
}
}
/// Squared modulus `|z|^2`.
#[inline]
pub fn norm_sqr(self) -> f64 {
self.re * self.re + self.im * self.im
}
/// Modulus `|z|`.
#[inline]
pub fn modulus(self) -> f64 {
self.norm_sqr().sqrt()
}
/// Scale by a real factor.
#[inline]
pub fn scale(self, s: f64) -> Self {
Complex {
re: self.re * s,
im: self.im * s,
}
}
}
impl Add for Complex {
type Output = Complex;
#[inline]
fn add(self, rhs: Complex) -> Complex {
Complex {
re: self.re + rhs.re,
im: self.im + rhs.im,
}
}
}
impl AddAssign for Complex {
#[inline]
fn add_assign(&mut self, rhs: Complex) {
self.re += rhs.re;
self.im += rhs.im;
}
}
impl Sub for Complex {
type Output = Complex;
#[inline]
fn sub(self, rhs: Complex) -> Complex {
Complex {
re: self.re - rhs.re,
im: self.im - rhs.im,
}
}
}
impl Neg for Complex {
type Output = Complex;
#[inline]
fn neg(self) -> Complex {
Complex {
re: -self.re,
im: -self.im,
}
}
}
impl Mul for Complex {
type Output = Complex;
#[inline]
fn mul(self, rhs: Complex) -> Complex {
Complex {
re: self.re * rhs.re - self.im * rhs.im,
im: self.re * rhs.im + self.im * rhs.re,
}
}
}
impl Mul<f64> for Complex {
type Output = Complex;
#[inline]
fn mul(self, rhs: f64) -> Complex {
self.scale(rhs)
}
}
/// `<a|b>` inner product of two complex vectors (conjugate-linear in the first
/// argument, matching the physics convention).
pub fn inner(a: &[Complex], b: &[Complex]) -> Complex {
debug_assert_eq!(a.len(), b.len());
let mut acc = Complex::ZERO;
for i in 0..a.len() {
acc += a[i].conj() * b[i];
}
acc
}
/// L2 norm of a complex vector.
pub fn vec_norm(v: &[Complex]) -> f64 {
v.iter().map(|z| z.norm_sqr()).sum::<f64>().sqrt()
}
/// Return a unit-norm copy of `v` (unchanged if it is the zero vector).
pub fn normalized(v: &[Complex]) -> Vec<Complex> {
let n = vec_norm(v);
if n < 1e-300 {
return v.to_vec();
}
v.iter().map(|z| z.scale(1.0 / n)).collect()
}
/// Quantum fidelity `|<a|b>|` between two normalized state vectors. Equals 1.0
/// when they coincide up to a global phase.
pub fn fidelity(a: &[Complex], b: &[Complex]) -> f64 {
let a = normalized(a);
let b = normalized(b);
inner(&a, &b).modulus()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mul_and_conj() {
let z = Complex::new(2.0, 3.0);
assert_eq!(z * z.conj(), Complex::real(13.0));
}
#[test]
fn phase_is_unit() {
let p = Complex::phase(0.7);
assert!((p.modulus() - 1.0).abs() < 1e-12);
}
#[test]
fn fidelity_phase_invariant() {
let a = vec![Complex::new(1.0, 0.0), Complex::new(0.0, 1.0)];
// global phase rotation by e^{i*1.1}
let ph = Complex::phase(1.1);
let b: Vec<_> = a.iter().map(|z| *z * ph).collect();
assert!((fidelity(&a, &b) - 1.0).abs() < 1e-12);
}
}

View file

@ -0,0 +1,290 @@
//! Dense complex matrices plus the spectral functions that turn a real
//! symmetric Hamiltonian into complex unitary dynamics.
//!
//! All evolution operators (`e^{-iHt}`, modular flow `e^{isK}`) are built by
//! diagonalizing the underlying real symmetric generator and exponentiating its
//! eigenvalues — accurate and free of truncated power series.
use crate::complex::Complex;
use crate::real_matrix::RealMatrix;
/// Row-major dense `n x n` complex matrix.
#[derive(Clone, Debug, PartialEq)]
pub struct CMatrix {
pub n: usize,
pub data: Vec<Complex>,
}
impl CMatrix {
pub fn zeros(n: usize) -> Self {
CMatrix {
n,
data: vec![Complex::ZERO; n * n],
}
}
pub fn identity(n: usize) -> Self {
let mut m = Self::zeros(n);
for i in 0..n {
m.set(i, i, Complex::ONE);
}
m
}
/// Embed a real matrix into the complex matrices.
pub fn from_real(m: &RealMatrix) -> Self {
CMatrix {
n: m.n,
data: m.data.iter().map(|&x| Complex::real(x)).collect(),
}
}
#[inline]
pub fn get(&self, r: usize, c: usize) -> Complex {
self.data[r * self.n + c]
}
#[inline]
pub fn set(&mut self, r: usize, c: usize, v: Complex) {
self.data[r * self.n + c] = v;
}
/// Conjugate transpose `A†`.
pub fn dagger(&self) -> CMatrix {
let n = self.n;
CMatrix {
n,
data: {
let mut d = vec![Complex::ZERO; n * n];
for r in 0..n {
for c in 0..n {
d[c * n + r] = self.get(r, c).conj();
}
}
d
},
}
}
/// Matrix product `self * other`.
pub fn matmul(&self, other: &CMatrix) -> CMatrix {
assert_eq!(self.n, other.n);
let n = self.n;
let mut out = CMatrix::zeros(n);
for r in 0..n {
for k in 0..n {
let a = self.get(r, k);
if a == Complex::ZERO {
continue;
}
for c in 0..n {
out.data[r * n + c] += a * other.get(k, c);
}
}
}
out
}
/// Matrix-vector product `self * v`.
pub fn matvec(&self, v: &[Complex]) -> Vec<Complex> {
assert_eq!(self.n, v.len());
let n = self.n;
let mut out = vec![Complex::ZERO; n];
for r in 0..n {
let mut acc = Complex::ZERO;
for c in 0..n {
acc += self.get(r, c) * v[c];
}
out[r] = acc;
}
out
}
pub fn sub(&self, other: &CMatrix) -> CMatrix {
CMatrix {
n: self.n,
data: self
.data
.iter()
.zip(&other.data)
.map(|(a, b)| *a - *b)
.collect(),
}
}
pub fn scale(&self, s: Complex) -> CMatrix {
CMatrix {
n: self.n,
data: self.data.iter().map(|z| *z * s).collect(),
}
}
/// Commutator `[A, B] = AB - BA`.
pub fn commutator(a: &CMatrix, b: &CMatrix) -> CMatrix {
a.matmul(b).sub(&b.matmul(a))
}
/// Frobenius norm `sqrt(Σ |a_ij|²)`.
pub fn frob_norm(&self) -> f64 {
self.data.iter().map(|z| z.norm_sqr()).sum::<f64>().sqrt()
}
}
/// `exp(i * theta * H)` for a generator supplied by its **precomputed** real
/// spectral decomposition `(eigvals, V)` with `H = V diag(eigvals) Vᵀ`.
///
/// This is the cache-reuse entry point: callers who already hold the spectrum
/// (e.g. [`crate::page_wootters::PageWootters`], which diagonalizes once in
/// `new`) build any number of evolution operators `e^{iθH}` without paying for
/// re-diagonalization. The result is unitary.
pub fn exp_i_from_spectrum(eigvals: &[f64], v: &RealMatrix, theta: f64) -> CMatrix {
let n = v.n;
debug_assert_eq!(
eigvals.len(),
n,
"spectrum length must match matrix dimension"
);
// phases[k] = e^{i*theta*E_k}
let phases: Vec<Complex> = eigvals.iter().map(|&e| Complex::phase(theta * e)).collect();
let mut out = CMatrix::zeros(n);
for r in 0..n {
for c in 0..n {
let mut acc = Complex::ZERO;
for k in 0..n {
let w = v.get(r, k) * v.get(c, k);
acc += phases[k].scale(w);
}
out.set(r, c, acc);
}
}
out
}
/// Apply `exp(i * theta * H)` to a complex vector `psi` directly, using the
/// **precomputed** spectral decomposition `(eigvals, V)` — without ever forming
/// the propagator matrix. This is `O(n²)` work per call and the natural way to
/// evolve a state in its own energy eigenbasis:
///
/// ```text
/// e^{iθH} |ψ> = Σ_k e^{iθE_k} <E_k|ψ> |E_k>, |E_k> = column k of V.
/// ```
///
/// Equivalent (to round-off) to `exp_i_from_spectrum(eigvals, v, theta).matvec(psi)`
/// but allocates no `n × n` matrix.
pub fn exp_i_apply_from_spectrum(
eigvals: &[f64],
v: &RealMatrix,
theta: f64,
psi: &[Complex],
) -> Vec<Complex> {
let n = v.n;
debug_assert_eq!(
eigvals.len(),
n,
"spectrum length must match matrix dimension"
);
debug_assert_eq!(psi.len(), n, "state length must match matrix dimension");
// Coefficients c_k = <E_k|ψ> (V is real, so the bra is just the column).
let mut coeffs = vec![Complex::ZERO; n];
for k in 0..n {
let mut acc = Complex::ZERO;
for r in 0..n {
acc += psi[r].scale(v.get(r, k));
}
coeffs[k] = Complex::phase(theta * eigvals[k]) * acc;
}
// Reconstruct in the standard basis: out[r] = Σ_k V[r][k] * (phase_k c_k).
let mut out = vec![Complex::ZERO; n];
for r in 0..n {
let mut acc = Complex::ZERO;
for k in 0..n {
acc += coeffs[k].scale(v.get(r, k));
}
out[r] = acc;
}
out
}
/// `exp(i * theta * H)` for a real **symmetric** generator `H`, via its
/// spectral decomposition. The result is unitary. From-scratch convenience for
/// callers who hold only `H`; reuse [`exp_i_from_spectrum`] when the spectrum is
/// already cached.
pub fn exp_i_symmetric(h: &RealMatrix, theta: f64) -> CMatrix {
let (eigvals, v) = h.symmetric_eigen();
exp_i_from_spectrum(&eigvals, &v, theta)
}
/// Schrödinger propagator `U(t) = e^{-iHt}` for a real symmetric Hamiltonian.
pub fn schrodinger_propagator(h: &RealMatrix, t: f64) -> CMatrix {
exp_i_symmetric(h, -t)
}
/// Eigenvalues of a complex **Hermitian** matrix, obtained from the real
/// symmetric `2n x 2n` embedding
///
/// ```text
/// M -> [ Re(M) -Im(M) ]
/// [ Im(M) Re(M) ]
/// ```
///
/// whose spectrum is each Hermitian eigenvalue repeated twice. We sort the `2n`
/// values and keep every second one. This lets us take the von Neumann entropy
/// of an arbitrary complex reduced density matrix without a dedicated Hermitian
/// eigensolver.
pub fn hermitian_eigenvalues(m: &CMatrix) -> Vec<f64> {
let n = m.n;
let mut big = RealMatrix::zeros(2 * n);
for r in 0..n {
for c in 0..n {
let z = m.get(r, c);
// top-left = Re, bottom-right = Re
big.set(r, c, z.re);
big.set(r + n, c + n, z.re);
// top-right = -Im, bottom-left = +Im
big.set(r, c + n, -z.im);
big.set(r + n, c, z.im);
}
}
let (mut vals, _v) = big.symmetric_eigen();
vals.sort_by(|a, b| a.partial_cmp(b).unwrap());
// Each true eigenvalue appears twice; take the even-indexed representatives.
(0..n).map(|i| vals[2 * i]).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn propagator_is_unitary() {
let h = RealMatrix::from_fn(2, |r, c| if r == c { 1.0 } else { 0.4 });
let u = schrodinger_propagator(&h, 0.9);
let prod = u.matmul(&u.dagger());
let id = CMatrix::identity(2);
assert!(prod.sub(&id).frob_norm() < 1e-9);
}
#[test]
fn group_property() {
// U(t1) U(t2) = U(t1 + t2)
let h = RealMatrix::from_fn(3, |r, c| if r == c { (r as f64) - 1.0 } else { 0.3 });
let a = schrodinger_propagator(&h, 0.5);
let b = schrodinger_propagator(&h, 0.7);
let ab = a.matmul(&b);
let c = schrodinger_propagator(&h, 1.2);
assert!(ab.sub(&c).frob_norm() < 1e-9);
}
#[test]
fn hermitian_eig_matches_real_diag() {
// A real diagonal density matrix embedded as complex.
let mut m = CMatrix::zeros(3);
m.set(0, 0, Complex::real(0.5));
m.set(1, 1, Complex::real(0.3));
m.set(2, 2, Complex::real(0.2));
let mut e = hermitian_eigenvalues(&m);
e.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert!((e[0] - 0.2).abs() < 1e-9);
assert!((e[2] - 0.5).abs() < 1e-9);
}
}

View file

@ -0,0 +1,255 @@
//! Entropic time — a β-swept Gibbs-ensemble clock.
//!
//! The internal time of an observed sector can be defined from its *change in
//! entropy*:
//!
//! ```text
//! τ_S = (S(λ) - S_0) / k
//! ```
//!
//! where `λ` is a lab control parameter. **What this module actually models** is
//! a *temperature sweep* of a Gibbs (thermal) ensemble: `λ` is interpreted as the
//! inverse temperature `β`, and `S(λ)` is the von Neumann entropy of `ρ = e^{βH}/Z`.
//! This is an equilibrium one-parameter family, **not** closed-system irreversible
//! dynamics — there is no hidden sector exchanging entropy in real time here. It
//! is the simplest honest demonstrator of the entropic-time *reparametrization*:
//! it shows how `τ_S` tracks the entropy curve, and how the "speed of internal
//! time" `dτ_S/dλ` follows entropy production along the sweep. (A genuine
//! cold-atom mini-universe with irreversible entropy exchange — Barontini et al.,
//! *Phys. Rev. Research* 2026 — is the physical system this is an analogue *of*,
//! not what is simulated.)
//! Derivatives reparametrize as
//!
//! ```text
//! dX/dτ_S = (k / (dS/dλ)) · dX/dλ.
//! ```
//!
//! The "speed of internal time" `dτ_S/dλ = (dS/dλ)/k` tracks entropy
//! production: when entropy exchange stalls, internal time freezes; when it
//! accelerates, internal time speeds up.
use crate::entropy::entropy_from_spectrum;
use crate::real_matrix::RealMatrix;
/// Maps observed-sector entropy onto an internal time coordinate.
#[derive(Clone, Copy, Debug)]
pub struct EntropicClock {
/// Reference entropy `S_0` (internal time origin).
pub s0: f64,
/// Clock scale `k` (nats of entropy per unit internal time).
pub k: f64,
}
impl EntropicClock {
pub fn new(s0: f64, k: f64) -> Self {
EntropicClock { s0, k }
}
/// Internal time `τ_S` for an observed entropy `s`.
pub fn tau(&self, s: f64) -> f64 {
(s - self.s0) / self.k
}
/// Speed of internal time `dτ_S/dλ = (dS/dλ)/k`.
pub fn rate(&self, ds_dlambda: f64) -> f64 {
ds_dlambda / self.k
}
/// Convert a `λ`-derivative into a `τ_S`-derivative,
/// `dX/dτ_S = (k/(dS/dλ)) dX/dλ`.
///
/// Returns `None` when entropy production vanishes (internal time is frozen,
/// so the rate of change per unit internal time is undefined / unbounded).
pub fn convert_derivative(&self, dx_dlambda: f64, ds_dlambda: f64) -> Option<f64> {
if ds_dlambda.abs() < 1e-12 {
None
} else {
Some((self.k / ds_dlambda) * dx_dlambda)
}
}
/// Reparametrize a `λ`-sampled observable trajectory into internal time.
/// Each input sample is `(λ, S(λ), X(λ))`; output is `(τ_S, X)`.
pub fn reparametrize(&self, samples: &[(f64, f64, f64)]) -> Vec<(f64, f64)> {
samples.iter().map(|&(_l, s, x)| (self.tau(s), x)).collect()
}
}
/// Gibbs (thermal) density matrix `ρ = e^{-βH}/Z` for a real symmetric
/// Hamiltonian — the standard entropy source for an observed sector at inverse
/// temperature `β`.
pub fn gibbs_density(h: &RealMatrix, beta: f64) -> RealMatrix {
let (energies, vecs) = h.symmetric_eigen();
// Shift by the ground-state energy for numerical stability of exp.
let e_min = energies.iter().cloned().fold(f64::INFINITY, f64::min);
let weights: Vec<f64> = energies
.iter()
.map(|&e| (-beta * (e - e_min)).exp())
.collect();
let z: f64 = weights.iter().sum();
let probs: Vec<f64> = weights.iter().map(|w| w / z).collect();
RealMatrix::from_spectrum(&probs, &vecs)
}
/// Von Neumann entropy of the Gibbs state at inverse temperature `β`.
pub fn gibbs_entropy(h: &RealMatrix, beta: f64) -> f64 {
let (energies, _v) = h.symmetric_eigen();
let e_min = energies.iter().cloned().fold(f64::INFINITY, f64::min);
let weights: Vec<f64> = energies
.iter()
.map(|&e| (-beta * (e - e_min)).exp())
.collect();
let z: f64 = weights.iter().sum();
let probs: Vec<f64> = weights.iter().map(|w| w / z).collect();
entropy_from_spectrum(&probs)
}
/// Sweep the inverse temperature `λ = β ∈ [lo, hi]` over the Gibbs ensemble,
/// returning `(λ, S(λ), τ_S(λ))` triples. This is a β-sweep of an equilibrium
/// state (not closed-system irreversible dynamics); it demonstrates how the
/// internal clock runs fast where the entropy curve changes quickly and stalls
/// where it saturates.
pub fn entropic_time_sweep(
h: &RealMatrix,
clock: &EntropicClock,
lo: f64,
hi: f64,
steps: usize,
) -> Vec<(f64, f64, f64)> {
let mut out = Vec::with_capacity(steps);
for i in 0..steps {
let frac = if steps <= 1 {
0.0
} else {
i as f64 / (steps - 1) as f64
};
let lam = lo + frac * (hi - lo);
let s = gibbs_entropy(h, lam);
out.push((lam, s, clock.tau(s)));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_h() -> RealMatrix {
RealMatrix::diag(&[0.0, 1.0, 2.0, 3.0])
}
#[test]
fn gibbs_trace_one() {
let rho = gibbs_density(&sample_h(), 0.8);
let tr: f64 = (0..rho.n).map(|i| rho.get(i, i)).sum();
assert!((tr - 1.0).abs() < 1e-10);
}
#[test]
fn entropy_monotone_in_temperature() {
// Higher temperature (lower β) → higher entropy.
let s_hot = gibbs_entropy(&sample_h(), 0.1);
let s_cold = gibbs_entropy(&sample_h(), 5.0);
assert!(s_hot > s_cold);
}
#[test]
fn frozen_entropy_freezes_time() {
let clock = EntropicClock::new(0.0, 1.0);
assert!(clock.convert_derivative(1.0, 0.0).is_none());
assert!(clock.convert_derivative(1.0, 2.0).unwrap().abs() > 0.0);
}
#[test]
fn tau_reparametrization_formula_is_exact() {
// Tests the DEFINITION τ_S = (S S₀)/k as an arithmetic identity. This
// is true by construction (it is just the formula evaluated) and cannot
// discriminate a correct entropy curve from an incorrect one — it only
// checks the reparametrization arithmetic. The discriminating test that
// ties the clock to the *measured* entropy curve is below.
let clock = EntropicClock::new(0.5, 2.0);
assert!((clock.tau(2.5) - 1.0).abs() < 1e-12);
}
#[test]
fn internal_time_spacing_tracks_measured_entropy_production() {
// DISCRIMINATING TEST: verify the reparametrization against the REAL
// Gibbs entropy curve S(λ), not against the τ = (S S₀)/k definition.
//
// The independence is in the *source* of dS/dλ. The clock's τ values are
// one object; the entropy production is measured separately by finite-
// differencing `gibbs_entropy(H, β)` — the physical entropy of the actual
// thermal state ρ = e^{βH}/Z — at λ points the clock never stored. We
// then assert:
//
// 1. the clock rate (dτ/dλ from its τ samples) equals rate(measured
// dS/dλ) — i.e. it tracks the *measured* entropy curve;
// 2. that entropy curve is physically non-trivial: monotone-rising with
// temperature (β decreasing ⇒ S increasing), with a varying slope,
// not a constant. A constant/flat/anti-correlated S(λ) would fail.
//
// A wrong entropy implementation (e.g. one that ignored the spectrum, or
// returned a constant) would still satisfy the pure-arithmetic
// `tau_reparametrization_formula_is_exact` test but would FAIL this one,
// because here dS/dλ is recomputed from the real thermal state.
let h = RealMatrix::diag(&[0.0, 1.0, 2.0, 3.0]);
let k = 1.7;
let clock = EntropicClock::new(0.0, k);
let (lo, hi, steps) = (0.2_f64, 4.0_f64, 41);
let sweep = entropic_time_sweep(&h, &clock, lo, hi, steps);
let dlam = (hi - lo) / (steps - 1) as f64;
let eps = dlam / 4.0; // independent probe step for measuring dS/dλ
// The sweep runs lo→hi in β, i.e. hot→cold, so S should DECREASE along it.
let s_first = sweep.first().unwrap().1;
let s_last = sweep.last().unwrap().1;
assert!(
s_first - s_last > 0.1,
"entropy must fall appreciably as β rises (hot→cold) for the test to bite"
);
let mut slopes = Vec::new();
let mut checked = 0;
for i in 1..sweep.len() - 1 {
let lam = sweep[i].0;
let tau_next = sweep[i + 1].2;
let tau_prev = sweep[i - 1].2;
// (1) Internal-time spacing per unit λ from the clock's τ samples.
let dtau_dlam_from_clock = (tau_next - tau_prev) / (2.0 * dlam);
// (2) Entropy production measured INDEPENDENTLY from the physical
// thermal state at fresh λ points (not the stored sweep values).
let s_plus = gibbs_entropy(&h, lam + eps);
let s_minus = gibbs_entropy(&h, lam - eps);
let ds_dlam_measured = (s_plus - s_minus) / (2.0 * eps);
let rate_from_entropy = clock.rate(ds_dlam_measured);
// The clock rate tracks the measured entropy production. Both are
// centered differences of the same smooth curve at slightly different
// step sizes, so they agree to finite-difference order.
let tol = 5e-3 + 0.02 * rate_from_entropy.abs();
assert!(
(dtau_dlam_from_clock - rate_from_entropy).abs() < tol,
"at λ={lam:.3}: dτ/dλ from clock = {dtau_dlam_from_clock:.5}, \
rate(independently-measured dS/) = {rate_from_entropy:.5}"
);
slopes.push(ds_dlam_measured);
checked += 1;
}
assert!(checked > 30, "should have checked the bulk of the sweep");
// The entropy curve is genuinely non-trivial (varying slope), so the
// clock's speed actually changes along the sweep — it is not a constant
// reparametrization. Min and max measured slopes differ substantially.
let smin = slopes.iter().cloned().fold(f64::INFINITY, f64::min);
let smax = slopes.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
assert!(
(smax - smin).abs() > 0.05,
"entropy production must vary along the sweep (clock speeds up/slows down): \
slope range [{smin:.4}, {smax:.4}]"
);
// And entropy production is negative throughout (S falls as β rises).
assert!(smax < 0.0, "dS/dβ must be negative across the sweep");
}
}

View file

@ -0,0 +1,138 @@
//! Von Neumann / Shannon entropy helpers.
//!
//! Entropy is the monotone that several emergent-time constructions use as the
//! internal clock variable (the cold-atom toy universe in particular). All
//! logarithms are natural, so entropy is measured in nats.
use crate::complex_matrix::{hermitian_eigenvalues, CMatrix};
use crate::real_matrix::RealMatrix;
/// How negative an eigenvalue may be before we treat it as a genuine non-PSD
/// signal (rather than diagonalization round-off) in debug validation.
const NEG_TOL: f64 = -1e-9;
/// Debug-only sanity check that a spectrum is a valid probability distribution
/// (a density-matrix spectrum): it sums to ~1 and has no meaningfully-negative
/// eigenvalue. A failure here means a **non-PSD or non-normalized ρ** reached
/// the entropy routine — a real upstream bug, surfaced in dev only. No-op in
/// release builds, so it never alters production results.
#[inline]
fn debug_validate_spectrum(probs: &[f64]) {
debug_assert!(
probs.iter().all(|&p| p >= NEG_TOL),
"entropy_from_spectrum: eigenvalue below {NEG_TOL:e} — ρ is not PSD: {probs:?}"
);
if !probs.is_empty() {
let sum: f64 = probs.iter().sum();
debug_assert!(
(sum - 1.0).abs() < 1e-6,
"entropy_from_spectrum: spectrum sums to {sum} (expected ~1) — ρ not normalized: {probs:?}"
);
}
}
/// Shannon / von Neumann entropy `S = -Σ p_k ln p_k` from a probability
/// spectrum (density-matrix eigenvalues).
///
/// Uses the standard von-Neumann clamp `if p > 0.0`: this skips exactly the
/// `0·ln0` term (the `lim_{p->0} p ln p = 0` convention) while keeping every
/// genuinely-positive probability, however small — so legitimate tiny
/// eigenvalues are *not* biased downward by an arbitrary epsilon. Round-off
/// negatives (`p <= 0`) contribute nothing.
///
/// In debug builds a [`debug_validate_spectrum`] check fires if the spectrum is
/// non-PSD or non-normalized, surfacing an upstream bad ρ rather than silently
/// masking it.
pub fn entropy_from_spectrum(probs: &[f64]) -> f64 {
debug_validate_spectrum(probs);
let mut s = 0.0;
for &p in probs {
if p > 0.0 {
s -= p * p.ln();
}
}
s
}
/// Von Neumann entropy of a real symmetric density matrix.
pub fn von_neumann_entropy_real(rho: &RealMatrix) -> f64 {
let (eigs, _v) = rho.symmetric_eigen();
entropy_from_spectrum(&eigs)
}
/// Von Neumann entropy of a complex Hermitian density matrix.
pub fn von_neumann_entropy_hermitian(rho: &CMatrix) -> f64 {
entropy_from_spectrum(&hermitian_eigenvalues(rho))
}
/// Purity `Tr(ρ²) = Σ p_k²`. Equals 1 for a pure state, `1/d` for the maximally
/// mixed state of dimension `d`.
pub fn purity_from_spectrum(probs: &[f64]) -> f64 {
probs.iter().map(|p| p * p).sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pure_state_zero_entropy() {
assert!(entropy_from_spectrum(&[1.0, 0.0, 0.0]).abs() < 1e-12);
}
#[test]
fn maximally_mixed_is_ln_d() {
let d = 4;
let probs = vec![1.0 / d as f64; d];
let s = entropy_from_spectrum(&probs);
assert!((s - (d as f64).ln()).abs() < 1e-12);
}
#[test]
fn real_density_entropy() {
let rho = RealMatrix::diag(&[0.5, 0.5]);
assert!((von_neumann_entropy_real(&rho) - 2.0f64.ln()).abs() < 1e-10);
}
/// R1: a round-off-negative eigenvalue (`p = -1e-15`, well within `NEG_TOL`)
/// contributes exactly 0 via the `p > 0` guard, so a `[0.5, 0.5, -1e-15]`
/// spectrum still gives `ln 2`. The old `p > 1e-12` clamp would also skip it,
/// but would additionally and wrongly bias any legitimate tiny-positive
/// probability downward — which the new guard does not.
#[test]
fn roundoff_negative_eigenvalue_contributes_zero() {
let s = entropy_from_spectrum(&[0.5, 0.5, -1e-15]);
assert!((s - 2.0f64.ln()).abs() < 1e-12, "got {s}, expected ln2");
}
/// R1: a legitimately tiny but positive eigenvalue is *kept*, not silently
/// dropped by an epsilon clamp. With p far below the old `1e-12` cutoff its
/// `-p ln p` term is nonzero and must appear in the entropy.
#[test]
fn tiny_positive_probability_is_not_clamped_away() {
let p = 1e-15;
let s = entropy_from_spectrum(&[1.0 - p, p]);
let expected = -(1.0 - p) * (1.0 - p).ln() - p * p.ln();
assert!((s - expected).abs() < 1e-14, "got {s}, expected {expected}");
assert!(s > 0.0, "tiny positive prob must contribute, got {s}");
}
/// R1: a clearly non-PSD spectrum (eigenvalue far below `NEG_TOL`) trips the
/// debug validation in debug builds, surfacing an upstream non-PSD ρ.
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "not PSD")]
fn non_psd_spectrum_trips_debug_assert() {
// Sums to 1 but has a genuinely-negative eigenvalue.
let _ = entropy_from_spectrum(&[1.2, -0.2]);
}
/// R1: a non-normalized spectrum (does not sum to ~1) trips the debug
/// validation in debug builds.
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "not normalized")]
fn non_normalized_spectrum_trips_debug_assert() {
let _ = entropy_from_spectrum(&[0.5, 0.6]);
}
}

View file

@ -0,0 +1,117 @@
//! # emergent-time
//!
//! A small, dependency-free Rust implementation of the **calculus of emergent
//! time**: time treated not as a background substance but as an *ordered
//! internal variable* that a subsystem computes by tracking irreversible change
//! against the rest of a closed system.
//!
//! Four physics formalisms are implemented, each individually correct and
//! verified by tests, plus a new agentic primitive:
//!
//! 1. [`wheeler_dewitt`] — the timeless global constraint `Ĥ|Ψ> = 0`. The state
//! of a closed universe carries no external clock; time must be found inside.
//! 2. [`page_wootters`] — relational time. A globally *static* entangled state
//! looks dynamic to an internal observer correlated with a clock subsystem;
//! Schrödinger evolution emerges from conditioning, `ρ_R(τ)`.
//! 3. [`entropic`] — entropic time `τ_S = (S(λ) S₀)/k`. The cold-atom toy
//! universe: the speed of internal time tracks entropy production.
//! 4. [`thermal`] — ConnesRovelli thermal time. The modular Hamiltonian
//! `K = ln ρ` generates a time flow `A(s) = e^{isK} A e^{-isK}` from the
//! statistical state itself.
//! 5. [`agentic`] + [`structural_clock`] — internal time for agents and quantum
//! machines, culminating in **Structural Proper Time**: the arc length of a
//! system's worldline through its own state manifold.
//!
//! ## The recipe
//!
//! Every construction follows the same six steps:
//!
//! 1. choose a closed total system;
//! 2. split it into *clock* ⊗ *rest*;
//! 3. pick a monotone internal variable (energy phase, entropy, modular flow,
//! structural distance…);
//! 4. define states conditioned on that variable;
//! 5. replace `d/dt` with `d/dτ`;
//! 6. recover ordinary physics when `τ` behaves like clock time.
//!
//! ```text
//! time ≠ background
//! time = ordered change measured from inside the system
//! ```
pub mod adaptive;
pub mod agentic;
pub mod agentic_time;
pub mod complex;
pub mod complex_matrix;
pub mod entropic;
pub mod entropy;
pub mod page_wootters;
pub mod real_matrix;
pub mod state;
pub mod structural_clock;
pub mod thermal;
pub mod weight_learning;
pub mod wheeler_dewitt;
pub mod witness;
// Convenience re-exports of the most-used types.
pub use complex::Complex;
pub use complex_matrix::CMatrix;
pub use real_matrix::RealMatrix;
pub use adaptive::{adaptive_alarm_step, adaptive_early_warning_lead, PageHinkley};
pub use agentic::CausalTimeline;
pub use agentic_time::{AgentHealth, AgentState, AgenticTime, AgenticWeights};
pub use entropic::EntropicClock;
pub use page_wootters::PageWootters;
pub use structural_clock::{
Clock, EntropyClock, Scenario, StateSnapshot, StructuralMetric, StructuralProperTime, WallClock,
};
#[cfg(test)]
mod integration_tests {
//! Cross-module checks tying the formalisms together.
use super::*;
#[test]
fn timeless_state_yields_emergent_evolution() {
// WheelerDeWitt kernel state == PageWootters static state, and
// conditioning it on the clock recovers Schrödinger dynamics.
let h = RealMatrix::from_fn(4, |r, c| {
if r == c {
(r as f64) - 1.5
} else if (r as i64 - c as i64).abs() == 1 {
0.25
} else {
0.0
}
});
let pw = PageWootters::new(h);
// The global state solves the timeless equation.
let j = wheeler_dewitt::bipartite_constraint(&pw.clock_hamiltonian(), &pw.h_r);
let psi = pw.global_static_state();
assert!(wheeler_dewitt::constraint_residual(&j, &psi) < 1e-8);
// Yet evolution emerges from it.
for &t in &[0.3, 1.1, 2.4] {
let f = complex::fidelity(&pw.conditional_state(t), &pw.schrodinger_state(t));
assert!(f > 1.0 - 1e-8);
}
}
#[test]
fn structural_time_beats_wall_time_on_all_axes() {
let sc = Scenario::default();
let traj = structural_clock::generate_scenario(&sc);
let spt = StructuralProperTime::new(StructuralMetric::default());
let wall = structural_clock::evaluate(&WallClock, &traj, sc.fail_index, 30, 4.0, 10);
let structural = structural_clock::evaluate(&spt, &traj, sc.fail_index, 30, 4.0, 10);
assert!(structural.lead > wall.lead);
assert!(structural.compression_error < wall.compression_error);
assert!(structural.causal_order_ok && wall.causal_order_ok);
}
}

View file

@ -0,0 +1,243 @@
//! PageWootters relational time.
//!
//! Time is not assumed; it emerges from correlations inside a globally
//! *static* entangled state of clock ⊗ rest.
//!
//! ## Exact construction
//!
//! Diagonalize the system Hamiltonian `H_R = Σ_k E_k |E_k><E_k|`. Build a clock
//! of the same dimension whose energy eigenstate `|k>_C` carries energy `-E_k`,
//! i.e. `H_C = diag(-E_0, …, -E_{d-1})`. The global state
//!
//! ```text
//! |Ψ> = (1/√d) Σ_k |k>_C ⊗ |E_k>_R
//! ```
//!
//! satisfies the WheelerDeWitt constraint **exactly**:
//!
//! ```text
//! Ĵ|Ψ> = (H_C ⊗ I + I ⊗ H_R)|Ψ> = (1/√d) Σ_k (-E_k + E_k)|k>|E_k> = 0.
//! ```
//!
//! Yet an internal observer who reads the clock *pointer* state
//! `|t>_C = Σ_k e^{iE_k t}|k>_C` recovers Schrödinger evolution:
//!
//! ```text
//! <t|Ψ>_R ∝ Σ_k e^{-iE_k t}|E_k>_R = e^{-iH_R t}|ψ_0>, |ψ_0> = Σ_k|E_k>.
//! ```
//!
//! Evolution is what the rest sector *looks like* conditioned on the clock.
//!
//! ## Scope and honest limitations
//!
//! 1. **Real-symmetric Hamiltonians only.** This construction (and the whole
//! numerical core it rests on) assumes `H_R` is real symmetric: it is
//! diagonalized by the real Jacobi eigensolver and the clock is built from its
//! real spectrum. Complex-Hermitian `H_R` is out of scope for v1.
//!
//! 2. **Born-rule weighting holds only for pure global states.** The
//! post-conditioning normalization performed in
//! [`PageWootters::conditional_state`] reproduces the Born-rule partial-trace
//! weight `‖⟨t|Ψ⟩‖²` **only because the global state `|Ψ⟩` is pure**. For a
//! mixed global state the correct conditional object is a conditioned density
//! operator, and a single normalized vector would not capture it. Do not read
//! the "fidelity = 1.0" result as holding for mixed `|Ψ⟩`.
//!
//! 3. **Single-time conditional states only — Kuchař's objection is out of
//! scope.** What is recovered here is the *single-time* conditional state
//! `ρ_R(t)`, correctly reproducing Schrödinger evolution (Page & Wootters
//! 1983; Giovannetti, Lloyd & Maccone, *Phys. Rev. D* 91, 084041, 2015). This
//! construction does **not** address Kuchař's two-time-correlation objection
//! (Kuchař 1992): naive conditioning gives the wrong propagator for
//! *two-time* correlation functions `⟨t₂|…|t₁⟩` without the conditional-
//! probability (or evolving-constants) machinery. v1 deliberately scopes to
//! single-time conditioning; multi-time correlators are future work. So
//! "evolution emerges, fidelity 1.0" means *single-time evolution is exactly
//! reproduced*, nothing stronger.
use crate::complex::{normalized, Complex};
use crate::complex_matrix::{exp_i_apply_from_spectrum, schrodinger_propagator};
use crate::real_matrix::RealMatrix;
use crate::state::condition_on_clock;
/// A relational clock paired with a system Hamiltonian.
pub struct PageWootters {
/// System (rest-sector) Hamiltonian.
pub h_r: RealMatrix,
/// System energy levels `E_k`.
pub energies: Vec<f64>,
/// Energy eigenvectors as columns (`|E_k>` is column `k`).
pub vecs: RealMatrix,
/// Hilbert-space dimension of each sector.
pub dim: usize,
/// The `t`-independent global static state `|Ψ>` (length `dim²`), computed
/// once in [`PageWootters::new`] (P2: hoisted out of the per-`t` path).
psi_static: Vec<Complex>,
/// The normalized reference state `|ψ_0> = Σ_k |E_k>` (length `dim`),
/// precomputed so cached-eigenbasis evolution never rebuilds it.
psi0: Vec<Complex>,
}
impl PageWootters {
/// Build from a real symmetric system Hamiltonian.
///
/// Diagonalizes `H_R` **once** here; every later `schrodinger_state(t)` /
/// `conditional_state(t)` reuses the cached spectrum and the cached static
/// state, so no per-`t` call re-runs the eigensolver or rebuilds the
/// `dim²`-length global vector.
pub fn new(h_r: RealMatrix) -> Self {
let (energies, vecs) = h_r.symmetric_eigen();
let dim = h_r.n;
// P2: build the t-independent static state |Ψ> once.
let inv = 1.0 / (dim as f64).sqrt();
let mut psi_static = vec![Complex::ZERO; dim * dim];
for k in 0..dim {
for r in 0..dim {
psi_static[k * dim + r] = Complex::real(inv * vecs.get(r, k));
}
}
// Reference state |ψ_0> = Σ_k |E_k> as a complex vector (P1 cache).
let psi0: Vec<Complex> = (0..dim)
.map(|r| {
let mut acc = 0.0;
for k in 0..dim {
acc += vecs.get(r, k);
}
Complex::real(acc)
})
.collect();
PageWootters {
h_r,
energies,
vecs,
dim,
psi_static,
psi0,
}
}
/// The reference state `|ψ_0> = Σ_k |E_k>` (equal superposition of energy
/// eigenstates) that the emergent dynamics evolve.
pub fn reference_state(&self) -> Vec<Complex> {
let d = self.dim;
(0..d)
.map(|r| {
let mut acc = 0.0;
for k in 0..d {
acc += self.vecs.get(r, k);
}
Complex::real(acc)
})
.collect()
}
/// The clock Hamiltonian `H_C = diag(-E_k)`.
pub fn clock_hamiltonian(&self) -> RealMatrix {
let neg: Vec<f64> = self.energies.iter().map(|e| -e).collect();
RealMatrix::diag(&neg)
}
/// The globally static entangled state `|Ψ>` (length `dim²`, `C ⊗ R` order).
///
/// `t`-independent; computed once in [`PageWootters::new`] and returned from
/// the cache here (P2). Cheap to clone for callers that need to own it.
pub fn global_static_state(&self) -> Vec<Complex> {
self.psi_static.clone()
}
/// The clock pointer (bra) for reading "time `t`": `|t>_C = Σ_k e^{iE_k t}|k>`.
pub fn clock_pointer(&self, t: f64) -> Vec<Complex> {
self.energies
.iter()
.map(|&e| Complex::phase(e * t))
.collect()
}
/// Conditional state of the rest sector when the clock reads `t`, normalized.
/// This is the *emergent* evolved state — derived purely from a static `|Ψ>`.
///
/// Conditions the **cached** static state (P2) on the clock pointer; no `dim²`
/// vector is materialized per call.
pub fn conditional_state(&self, t: f64) -> Vec<Complex> {
let bra = self.clock_pointer(t);
let raw = condition_on_clock(&self.psi_static, &bra, self.dim);
normalized(&raw)
}
/// The ordinary Schrödinger-evolved reference state `e^{-iH_R t}|ψ_0>`,
/// normalized — what the conditional state must reproduce.
///
/// P1: evolves directly in the **cached** energy eigenbasis,
/// `e^{-iH_R t}|ψ_0> = Σ_k e^{-iE_k t} ⟨E_k|ψ_0⟩ |E_k⟩`, which is `O(dim²)`
/// per call and never re-diagonalizes `H_R` or forms a propagator matrix.
pub fn schrodinger_state(&self, t: f64) -> Vec<Complex> {
// theta = -t : U(t) = e^{-iH_R t}.
let evolved = exp_i_apply_from_spectrum(&self.energies, &self.vecs, -t, &self.psi0);
normalized(&evolved)
}
/// The from-scratch Schrödinger-evolved reference state — diagonalizes
/// `H_R` afresh and forms the full propagator `U(t) = e^{-iH_R t}`. Kept as a
/// reference path for callers (and tests) that want to validate the cached
/// [`schrodinger_state`](Self::schrodinger_state) route against the
/// independent `H`-only implementation.
pub fn schrodinger_state_from_scratch(&self, t: f64) -> Vec<Complex> {
let u = schrodinger_propagator(&self.h_r, t);
let evolved = u.matvec(&self.reference_state());
normalized(&evolved)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::complex::fidelity;
fn sample_h() -> RealMatrix {
// A non-trivial symmetric 3-level Hamiltonian.
RealMatrix::from_fn(3, |r, c| if r == c { (r as f64) - 1.0 } else { 0.35 })
}
#[test]
fn evolution_emerges_from_static_state() {
let pw = PageWootters::new(sample_h());
for &t in &[0.0, 0.5, 1.3, 2.7, -1.1] {
let cond = pw.conditional_state(t);
let schro = pw.schrodinger_state(t);
let f = fidelity(&cond, &schro);
assert!(f > 1.0 - 1e-8, "t={t}: fidelity {f} too low");
}
}
#[test]
fn distinct_times_give_distinct_states() {
let pw = PageWootters::new(sample_h());
let a = pw.conditional_state(0.0);
let b = pw.conditional_state(1.5);
// Generic Hamiltonian: states at different clock readings differ.
assert!(fidelity(&a, &b) < 0.999);
}
/// P1 correctness gate: the cached-eigenbasis `schrodinger_state` must agree
/// component-for-component with the from-scratch propagator path. Both are
/// normalized identically, so this is an exact-up-to-roundoff equality (not
/// merely a fidelity / global-phase match).
#[test]
fn cached_evolution_equals_from_scratch_propagator() {
let pw = PageWootters::new(sample_h());
for &t in &[0.0, 0.5, 1.3, 2.7, -1.1, -3.4] {
let cached = pw.schrodinger_state(t);
let scratch = pw.schrodinger_state_from_scratch(t);
assert_eq!(cached.len(), scratch.len());
for (a, b) in cached.iter().zip(&scratch) {
assert!(
(a.re - b.re).abs() < 1e-12 && (a.im - b.im).abs() < 1e-12,
"t={t}: cached {a:?} != scratch {b:?}"
);
}
}
}
}

View file

@ -0,0 +1,352 @@
//! Dense real matrices with a symmetric eigensolver.
//!
//! The symmetric Jacobi eigendecomposition is the numerical workhorse of the
//! whole crate: every "time-from-the-inside" construction ultimately reduces to
//! the spectrum of some Hermitian operator (a Hamiltonian, a density matrix, or
//! a modular Hamiltonian). We keep real symmetric Hamiltonians/density matrices,
//! which is enough to drive complex unitary evolution downstream.
/// Row-major dense `n x n` real matrix.
#[derive(Clone, Debug, PartialEq)]
pub struct RealMatrix {
pub n: usize,
pub data: Vec<f64>,
}
impl RealMatrix {
/// Zero matrix of dimension `n`.
pub fn zeros(n: usize) -> Self {
RealMatrix {
n,
data: vec![0.0; n * n],
}
}
/// Identity matrix of dimension `n`.
pub fn identity(n: usize) -> Self {
let mut m = Self::zeros(n);
for i in 0..n {
m.set(i, i, 1.0);
}
m
}
/// Diagonal matrix from the supplied entries.
pub fn diag(d: &[f64]) -> Self {
let mut m = Self::zeros(d.len());
for (i, &v) in d.iter().enumerate() {
m.set(i, i, v);
}
m
}
/// Build from a closure `f(row, col)`.
pub fn from_fn(n: usize, f: impl Fn(usize, usize) -> f64) -> Self {
let mut m = Self::zeros(n);
for r in 0..n {
for c in 0..n {
m.set(r, c, f(r, c));
}
}
m
}
#[inline]
pub fn get(&self, r: usize, c: usize) -> f64 {
self.data[r * self.n + c]
}
#[inline]
pub fn set(&mut self, r: usize, c: usize, v: f64) {
self.data[r * self.n + c] = v;
}
/// Matrix product `self * other`.
pub fn matmul(&self, other: &RealMatrix) -> RealMatrix {
assert_eq!(self.n, other.n);
let n = self.n;
let mut out = RealMatrix::zeros(n);
for r in 0..n {
for k in 0..n {
let a = self.get(r, k);
if a == 0.0 {
continue;
}
for c in 0..n {
out.data[r * n + c] += a * other.get(k, c);
}
}
}
out
}
/// Matrix-vector product `self * v`.
pub fn matvec(&self, v: &[f64]) -> Vec<f64> {
assert_eq!(self.n, v.len());
let n = self.n;
let mut out = vec![0.0; n];
for r in 0..n {
let mut acc = 0.0;
for c in 0..n {
acc += self.get(r, c) * v[c];
}
out[r] = acc;
}
out
}
/// Column `c` as a vector.
pub fn column(&self, c: usize) -> Vec<f64> {
(0..self.n).map(|r| self.get(r, c)).collect()
}
/// Maximum absolute off-diagonal entry — a symmetry/convergence probe.
pub fn max_offdiag(&self) -> f64 {
let mut m = 0.0f64;
for r in 0..self.n {
for c in 0..self.n {
if r != c {
m = m.max(self.get(r, c).abs());
}
}
}
m
}
/// Eigendecomposition of a **symmetric** matrix via cyclic two-sided Jacobi
/// rotations.
///
/// Returns `(eigenvalues, eigenvectors)` where the eigenvectors are the
/// columns of the returned orthogonal matrix `V`, so `self == V * diag(λ) * Vᵀ`.
/// Robust and accurate for the small matrices used throughout this crate.
pub fn symmetric_eigen(&self) -> (Vec<f64>, RealMatrix) {
// Maximum number of cyclic Jacobi sweeps. A backstop only: with the
// relative convergence test below, well-conditioned symmetric matrices
// converge in well under 10 sweeps; the cap guards against a pathological
// input spinning forever.
const MAX_SWEEPS: usize = 100;
// Relative off-diagonal threshold. Converge when the off-diagonal
// Frobenius² is below `(REL_TOL)²` times the matrix Frobenius² — a
// *scale-invariant* criterion (the old absolute `off < 1e-28` was
// unreachable for large-norm matrices and silently relied on the cap).
const REL_TOL: f64 = 1e-14;
let n = self.n;
let mut a = self.clone();
let mut v = RealMatrix::identity(n);
if n == 0 {
return (Vec::new(), v);
}
if n == 1 {
return (vec![a.get(0, 0)], v);
}
// Scale reference for the relative test: the total Frobenius² of the
// (symmetric) matrix. Off-diagonal mass is measured against this, so the
// threshold tracks the matrix norm rather than an absolute constant.
let mut frob_sq = 0.0;
for i in 0..(n * n) {
frob_sq += a.data[i] * a.data[i];
}
// Convergence floor: off² must drop below tol² * scale. Guard against an
// all-zero matrix (frob_sq == 0), where any off² == 0 already converged.
let scale = if frob_sq > 0.0 { frob_sq } else { 1.0 };
let tol_sq = REL_TOL * REL_TOL * scale;
let mut converged = false;
for _sweep in 0..MAX_SWEEPS {
// Sum of squared off-diagonal elements — the Jacobi convergence measure.
let mut off = 0.0;
for p in 0..n {
for q in (p + 1)..n {
off += a.get(p, q).powi(2);
}
}
// `off` counts the strict upper triangle; the symmetric lower mirror
// doubles it, but comparing against the relative floor is unaffected
// by the constant factor.
if off < tol_sq {
converged = true;
break;
}
for p in 0..n {
for q in (p + 1)..n {
let apq = a.get(p, q);
if apq.abs() < 1e-300 {
continue;
}
let app = a.get(p, p);
let aqq = a.get(q, q);
// Rotation angle that zeros the (p, q) entry.
let theta = (aqq - app) / (2.0 * apq);
let t = if theta == 0.0 {
1.0
} else {
theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt())
};
let c = 1.0 / (t * t + 1.0).sqrt();
let s = t * c;
// Left rotation: update columns p, q of A.
for k in 0..n {
let akp = a.get(k, p);
let akq = a.get(k, q);
a.set(k, p, c * akp - s * akq);
a.set(k, q, s * akp + c * akq);
}
// Right rotation: update rows p, q of A.
for k in 0..n {
let apk = a.get(p, k);
let aqk = a.get(q, k);
a.set(p, k, c * apk - s * aqk);
a.set(q, k, s * apk + c * aqk);
}
// Accumulate the rotation into the eigenvector matrix.
for k in 0..n {
let vkp = v.get(k, p);
let vkq = v.get(k, q);
v.set(k, p, c * vkp - s * vkq);
v.set(k, q, s * vkp + c * vkq);
}
}
}
}
// R4 non-convergence guard. The previous implementation could exhaust
// the sweep cap and return an *unconverged* (still-off-diagonal) result
// with no signal at all. We cannot change the public signature — every
// caller across the crate destructures `(Vec<f64>, RealMatrix)` — so we
// surface non-convergence via a debug assertion that names the failure
// mode. In release builds the result is returned as before (best
// effort), but a genuinely non-convergent symmetric input now fails
// loudly in dev rather than silently.
debug_assert!(
converged,
"symmetric_eigen: Jacobi did not converge in {MAX_SWEEPS} sweeps \
(relative off-diagonal threshold {REL_TOL:e} not met) for n={n} \
returning an unconverged spectrum"
);
let eigvals: Vec<f64> = (0..n).map(|i| a.get(i, i)).collect();
(eigvals, v)
}
/// Reconstruct a symmetric matrix from a spectrum and an eigenvector matrix:
/// `V * diag(λ) * Vᵀ`.
pub fn from_spectrum(eigvals: &[f64], vecs: &RealMatrix) -> RealMatrix {
let n = vecs.n;
RealMatrix::from_fn(n, |r, c| {
let mut acc = 0.0;
for k in 0..n {
acc += vecs.get(r, k) * eigvals[k] * vecs.get(c, k);
}
acc
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn eigen_of_diagonal() {
let m = RealMatrix::diag(&[3.0, -1.0, 2.0]);
let (vals, _v) = m.symmetric_eigen();
let mut sorted = vals.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert!((sorted[0] - -1.0).abs() < 1e-10);
assert!((sorted[2] - 3.0).abs() < 1e-10);
}
#[test]
fn eigen_reconstructs() {
// Symmetric 2x2.
let m = RealMatrix::from_fn(2, |r, c| if r == c { 2.0 } else { 0.5 });
let (vals, v) = m.symmetric_eigen();
let recon = RealMatrix::from_spectrum(&vals, &v);
for i in 0..4 {
assert!((recon.data[i] - m.data[i]).abs() < 1e-9);
}
}
#[test]
fn eigenvectors_orthonormal() {
let m = RealMatrix::from_fn(3, |r, c| {
((r + 1) * (c + 1)) as f64 % 5.0 + if r == c { 1.0 } else { 0.0 }
});
// symmetrize
let m = RealMatrix::from_fn(3, |r, c| 0.5 * (m.get(r, c) + m.get(c, r)));
let (_vals, v) = m.symmetric_eigen();
let vt = RealMatrix::from_fn(3, |r, c| v.get(c, r));
let id = vt.matmul(&v);
for r in 0..3 {
for c in 0..3 {
let expect = if r == c { 1.0 } else { 0.0 };
assert!((id.get(r, c) - expect).abs() < 1e-9);
}
}
}
/// R4: near-degenerate stress test. Two eigenvalues separated by only
/// `1e-10` with tiny off-diagonal coupling — the regime where a poorly-tuned
/// Jacobi loop either stalls (absolute threshold) or returns non-orthonormal
/// vectors. With the relative criterion the solver must still converge to
/// orthonormal eigenvectors and the correct (near-degenerate) spectrum.
#[test]
fn near_degenerate_converges_orthonormal() {
let off = 1e-12;
let m = RealMatrix::from_fn(3, |r, c| {
let diag = [1.0, 1.0 + 1e-10, 2.0];
if r == c {
diag[r]
} else {
off
}
});
let (vals, v) = m.symmetric_eigen();
// Orthonormal eigenvectors: VᵀV = I.
let vt = RealMatrix::from_fn(3, |r, c| v.get(c, r));
let id = vt.matmul(&v);
for r in 0..3 {
for c in 0..3 {
let expect = if r == c { 1.0 } else { 0.0 };
assert!(
(id.get(r, c) - expect).abs() < 1e-9,
"VᵀV[{r}][{c}] = {} not orthonormal",
id.get(r, c)
);
}
}
// Reconstruction holds → spectrum + vectors are a valid decomposition,
// confirming convergence on the near-degenerate input.
let recon = RealMatrix::from_spectrum(&vals, &v);
for i in 0..9 {
assert!(
(recon.data[i] - m.data[i]).abs() < 1e-9,
"reconstruction mismatch at {i}: {} vs {}",
recon.data[i],
m.data[i]
);
}
// Eigenvalues are near {1, 1+1e-10, 2}; off-diagonal coupling shifts them
// by O(off) only. Sorted, the extreme values bracket correctly.
let mut sorted = vals.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert!(
(sorted[0] - 1.0).abs() < 1e-6,
"low eigenvalue {}",
sorted[0]
);
assert!(
(sorted[2] - 2.0).abs() < 1e-6,
"high eigenvalue {}",
sorted[2]
);
}
}

View file

@ -0,0 +1,104 @@
//! Bipartite pure states and the partial traces used to split a closed system
//! into a *clock* subsystem `C` and the *rest* `R`: `H = H_C ⊗ H_R`.
//!
//! A bipartite state vector is stored in row-major `C ⊗ R` order, so the
//! amplitude for clock index `c` and rest index `r` lives at `psi[c * dr + r]`.
use crate::complex::Complex;
use crate::complex_matrix::CMatrix;
/// Index of amplitude `|c>_C ⊗ |r>_R` inside a bipartite state vector.
#[inline]
pub fn idx(c: usize, r: usize, dr: usize) -> usize {
c * dr + r
}
/// Reduced density matrix of the *rest* sector,
/// `ρ_R = Tr_C |Ψ><Ψ|`, given a bipartite pure state.
pub fn reduced_rest(psi: &[Complex], dc: usize, dr: usize) -> CMatrix {
assert_eq!(psi.len(), dc * dr);
let mut rho = CMatrix::zeros(dr);
for r in 0..dr {
for rp in 0..dr {
let mut acc = Complex::ZERO;
for c in 0..dc {
acc += psi[idx(c, r, dr)] * psi[idx(c, rp, dr)].conj();
}
rho.set(r, rp, acc);
}
}
rho
}
/// Reduced density matrix of the *clock* sector, `ρ_C = Tr_R |Ψ><Ψ|`.
pub fn reduced_clock(psi: &[Complex], dc: usize, dr: usize) -> CMatrix {
assert_eq!(psi.len(), dc * dr);
let mut rho = CMatrix::zeros(dc);
for c in 0..dc {
for cp in 0..dc {
let mut acc = Complex::ZERO;
for r in 0..dr {
acc += psi[idx(c, r, dr)] * psi[idx(cp, r, dr)].conj();
}
rho.set(c, cp, acc);
}
}
rho
}
/// Project the clock onto a (not necessarily normalized) clock vector
/// `clock_bra` and return the resulting **unnormalized** state of the rest
/// sector — the PageWootters conditional state before renormalization.
///
/// `out[r] = Σ_c conj(clock_bra[c]) · psi[c, r]`.
pub fn condition_on_clock(psi: &[Complex], clock_bra: &[Complex], dr: usize) -> Vec<Complex> {
let dc = clock_bra.len();
assert_eq!(psi.len(), dc * dr);
let mut out = vec![Complex::ZERO; dr];
for r in 0..dr {
let mut acc = Complex::ZERO;
for c in 0..dc {
acc += clock_bra[c].conj() * psi[idx(c, r, dr)];
}
out[r] = acc;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::complex::vec_norm;
#[test]
fn product_state_traces_to_pure() {
// |0>_C ⊗ (|0> + |1>)/√2 in dc=2, dr=2.
let s = 1.0 / 2.0f64.sqrt();
let psi = vec![
Complex::real(s), // c0 r0
Complex::real(s), // c0 r1
Complex::ZERO, // c1 r0
Complex::ZERO, // c1 r1
];
let rho_r = reduced_rest(&psi, 2, 2);
// trace = 1
let tr = rho_r.get(0, 0) + rho_r.get(1, 1);
assert!((tr.re - 1.0).abs() < 1e-12 && tr.im.abs() < 1e-12);
}
#[test]
fn conditioning_extracts_branch() {
// Bell-like: (|0>|0> + |1>|1>)/√2. Conditioning clock on |0> yields |0>_R.
let s = 1.0 / 2.0f64.sqrt();
let psi = vec![
Complex::real(s),
Complex::ZERO,
Complex::ZERO,
Complex::real(s),
];
let bra0 = vec![Complex::ONE, Complex::ZERO];
let out = condition_on_clock(&psi, &bra0, 2);
assert!((vec_norm(&out) - s).abs() < 1e-12);
assert!(out[0].modulus() > out[1].modulus());
}
}

View file

@ -0,0 +1,563 @@
//! Structural Proper Time — a new form of agentic time.
//!
//! The idea, in one line: **time is the arc length of a system's worldline
//! through its own state manifold**, not a background coordinate.
//!
//! In relativity, proper time is the length a worldline accumulates as it moves
//! through spacetime. Here we keep the *geometry* and drop the *background*: an
//! agent (or a quantum machine, or a sensor stream) traces a path through a
//! state space, and its **structural proper time** is the accumulated,
//! metric-weighted change along that path. The composite metric is exactly the
//! five-channel form
//!
//! ```text
//! τ = f(ΔS, Δv, ΔG, ΔC, ΔE)
//! ```
//!
//! * `ΔS` — entropy change,
//! * `Δv` — vector / embedding movement,
//! * `ΔG` — graph topology change,
//! * `ΔC` — coherence change,
//! * `ΔE` — prediction-error change.
//!
//! The system stops asking "what happened at 12:01?" and asks "how much did
//! reality move?". A quiet hour with no state change adds ≈ 0 internal time; one
//! contradiction or boundary collapse is a large jump.
//!
//! This generalizes the three physics clocks of this crate (entropic time uses
//! `ΔS`; thermal time uses modular flow generated by the state; structural time
//! uses the whole manifold) and ships with an honest three-clock benchmark
//! comparing wall, entropy, and structural time on early-warning lead, history
//! compression, and causal-order preservation.
/// A snapshot of a system's structural state at one observation.
#[derive(Clone, Debug)]
pub struct StateSnapshot {
/// Geometry of the state (belief embedding, syndrome vector, boundary…). `Δv`
pub embedding: Vec<f64>,
/// Information content of the state in nats. `ΔS`
pub entropy: f64,
/// Coherence / order parameter in `[0, 1]` (1 = fully coherent/healthy). `ΔC`
pub coherence: f64,
/// Scalar summary of graph/boundary topology (e.g. mincut value, edge mass). `ΔG`
pub graph: f64,
/// Model prediction error / surprise at this state. `ΔE`
pub pred_error: f64,
}
impl StateSnapshot {
/// Full constructor with all five channels.
pub fn full(
embedding: Vec<f64>,
entropy: f64,
coherence: f64,
graph: f64,
pred_error: f64,
) -> Self {
StateSnapshot {
embedding,
entropy,
coherence,
graph,
pred_error,
}
}
/// Convenience constructor for the common embedding+entropy+coherence case
/// (graph and prediction-error channels default to zero).
pub fn new(embedding: Vec<f64>, entropy: f64, coherence: f64) -> Self {
StateSnapshot::full(embedding, entropy, coherence, 0.0, 0.0)
}
}
fn l2(a: &[f64], b: &[f64]) -> f64 {
a.iter()
.zip(b)
.map(|(x, y)| (x - y) * (x - y))
.sum::<f64>()
.sqrt()
}
/// A clock assigns an internal-time *increment* to a transition between two
/// snapshots. Increments are non-negative, so cumulative internal time is
/// monotone and preserves causal order.
pub trait Clock {
fn name(&self) -> &str;
fn tick(&self, prev: &StateSnapshot, cur: &StateSnapshot) -> f64;
/// Cumulative internal time over a trajectory (length `traj.len()`, starting
/// at 0 for the first snapshot).
fn cumulative(&self, traj: &[StateSnapshot]) -> Vec<f64> {
let mut out = Vec::with_capacity(traj.len());
let mut acc = 0.0;
for (i, s) in traj.iter().enumerate() {
if i > 0 {
acc += self.tick(&traj[i - 1], s).max(0.0);
}
out.push(acc);
}
out
}
/// Per-step internal-time increments (length `traj.len()`, first is 0).
fn increments(&self, traj: &[StateSnapshot]) -> Vec<f64> {
let mut out = vec![0.0];
for i in 1..traj.len() {
out.push(self.tick(&traj[i - 1], &traj[i]).max(0.0));
}
out
}
}
/// Wall-clock time: every step is worth exactly one tick, regardless of what
/// happened. The null hypothesis.
pub struct WallClock;
impl Clock for WallClock {
fn name(&self) -> &str {
"wall"
}
fn tick(&self, _prev: &StateSnapshot, _cur: &StateSnapshot) -> f64 {
1.0
}
}
/// Entropic clock: internal time advances with the magnitude of entropy change.
pub struct EntropyClock;
impl Clock for EntropyClock {
fn name(&self) -> &str {
"entropy"
}
fn tick(&self, prev: &StateSnapshot, cur: &StateSnapshot) -> f64 {
(cur.entropy - prev.entropy).abs()
}
}
/// Weights for the composite structural metric `τ = f(ΔS, Δv, ΔG, ΔC, ΔE)`.
#[derive(Clone, Copy, Debug)]
pub struct StructuralMetric {
pub w_embedding: f64, // Δv
pub w_entropy: f64, // ΔS
pub w_graph: f64, // ΔG
pub w_coherence: f64, // ΔC (coherence *loss* only)
pub w_pred_error: f64, // ΔE
/// Increments below this gate are treated as idle (no internal time).
pub gate: f64,
}
impl Default for StructuralMetric {
fn default() -> Self {
StructuralMetric {
w_embedding: 1.0,
w_entropy: 1.0,
w_graph: 0.5,
w_coherence: 1.0,
w_pred_error: 0.5,
gate: 0.0,
}
}
}
/// Structural Proper Time: internal time = composite structural arc length.
pub struct StructuralProperTime {
pub metric: StructuralMetric,
}
impl StructuralProperTime {
pub fn new(metric: StructuralMetric) -> Self {
StructuralProperTime { metric }
}
}
impl Clock for StructuralProperTime {
fn name(&self) -> &str {
"structural"
}
fn tick(&self, prev: &StateSnapshot, cur: &StateSnapshot) -> f64 {
let m = &self.metric;
let d_embed = l2(&prev.embedding, &cur.embedding); // Δv
let d_entropy = (cur.entropy - prev.entropy).abs(); // ΔS
let d_graph = (cur.graph - prev.graph).abs(); // ΔG
let coherence_loss = (prev.coherence - cur.coherence).max(0.0); // ΔC
let d_pred = (cur.pred_error - prev.pred_error).abs(); // ΔE
let raw = m.w_embedding * d_embed
+ m.w_entropy * d_entropy
+ m.w_graph * d_graph
+ m.w_coherence * coherence_loss
+ m.w_pred_error * d_pred;
if raw < m.gate {
0.0
} else {
raw
}
}
}
/// First step at which a clock's instantaneous rate exceeds `mean + k·std` of
/// its baseline rates (computed over the first `baseline_window` steps). `None`
/// if it never fires.
pub fn alarm_step(
clock: &dyn Clock,
traj: &[StateSnapshot],
baseline_window: usize,
k_sigma: f64,
) -> Option<usize> {
let inc = clock.increments(traj);
if traj.len() <= baseline_window + 1 {
return None;
}
let base: Vec<f64> = inc[1..=baseline_window].to_vec();
let mean = base.iter().sum::<f64>() / base.len() as f64;
let var = base.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / base.len() as f64;
let std = var.sqrt();
let threshold = mean + k_sigma * std;
for i in (baseline_window + 1)..traj.len() {
if inc[i] > threshold {
return Some(i);
}
}
None
}
/// Early-warning lead time: steps between the alarm and the failure. `0` if the
/// clock never raises an alarm before failure.
pub fn early_warning_lead(
clock: &dyn Clock,
traj: &[StateSnapshot],
fail_index: usize,
baseline_window: usize,
k_sigma: f64,
) -> usize {
match alarm_step(clock, traj, baseline_window, k_sigma) {
Some(a) if a <= fail_index => fail_index - a,
_ => 0,
}
}
/// Choose `budget` keyframe indices spaced evenly in a clock's internal time,
/// always including the first and last step.
pub fn keyframes(clock: &dyn Clock, traj: &[StateSnapshot], budget: usize) -> Vec<usize> {
let n = traj.len();
if budget >= n || budget < 2 {
return (0..n).collect();
}
let cum = clock.cumulative(traj);
let total = *cum.last().unwrap();
let mut frames = Vec::with_capacity(budget);
if total <= 0.0 {
for j in 0..budget {
frames.push(j * (n - 1) / (budget - 1));
}
frames.dedup();
return frames;
}
for j in 0..budget {
let level = total * j as f64 / (budget - 1) as f64;
let mut best = 0usize;
let mut bestd = f64::INFINITY;
for (i, &c) in cum.iter().enumerate() {
let d = (c - level).abs();
if d < bestd {
bestd = d;
best = i;
}
}
frames.push(best);
}
frames.sort_unstable();
frames.dedup();
if frames[0] != 0 {
frames.insert(0, 0);
}
if *frames.last().unwrap() != n - 1 {
frames.push(n - 1);
}
frames
}
/// Reconstruct the embedding trajectory by linear interpolation between
/// keyframes (in wall index) and return the maximum L2 reconstruction error.
/// Lower is better compression at a fixed keyframe budget.
pub fn compression_error(clock: &dyn Clock, traj: &[StateSnapshot], budget: usize) -> f64 {
let frames = keyframes(clock, traj, budget);
let mut max_err = 0.0f64;
for i in 0..traj.len() {
let mut lo = frames[0];
let mut hi = *frames.last().unwrap();
for w in frames.windows(2) {
if w[0] <= i && i <= w[1] {
lo = w[0];
hi = w[1];
break;
}
}
let recon: Vec<f64> = if hi == lo {
traj[lo].embedding.clone()
} else {
let alpha = (i - lo) as f64 / (hi - lo) as f64;
traj[lo]
.embedding
.iter()
.zip(&traj[hi].embedding)
.map(|(a, b)| a + alpha * (b - a))
.collect()
};
max_err = max_err.max(l2(&recon, &traj[i].embedding));
}
max_err
}
/// Smallest keyframe budget for which the clock's reconstruction error falls to
/// `tol` or below. Returns `traj.len()` if the tolerance is never met. The ratio
/// of two clocks' results is a direct "history compression" factor: how many
/// fewer samples structural time needs to preserve the trajectory to tolerance.
pub fn samples_to_tolerance(clock: &dyn Clock, traj: &[StateSnapshot], tol: f64) -> usize {
let n = traj.len();
for budget in 2..=n {
if compression_error(clock, traj, budget) <= tol {
return budget;
}
}
n
}
/// Agent "stuck" efficiency: external progress per unit of structural time
/// (internal churn). High structural time with little progress means the agent
/// is thrashing — lots of internal effort, no real movement.
pub fn progress_efficiency(structural_time_spent: f64, progress: f64) -> f64 {
if structural_time_spent <= 1e-12 {
f64::INFINITY
} else {
progress / structural_time_spent
}
}
/// Replanning trigger: fire when progress per unit structural time drops below
/// `threshold`. This replaces the usual "N steps with no result" heuristic with
/// "lots of internal change but no convergence" — the agent decides to replan
/// based on *state movement*, not wall-clock or step count.
pub fn should_replan(structural_time_spent: f64, progress: f64, threshold: f64) -> bool {
progress_efficiency(structural_time_spent, progress) < threshold
}
/// Whether cumulative internal time is monotone non-decreasing (causal order
/// preserved). True for any clock with non-negative ticks.
pub fn preserves_causal_order(clock: &dyn Clock, traj: &[StateSnapshot]) -> bool {
let cum = clock.cumulative(traj);
cum.windows(2).all(|w| w[1] + 1e-12 >= w[0])
}
/// A compact report for one clock on one trajectory.
#[derive(Clone, Debug)]
pub struct ClockReport {
pub name: String,
pub lead: usize,
pub compression_error: f64,
pub causal_order_ok: bool,
}
/// Benchmark a clock end to end.
pub fn evaluate(
clock: &dyn Clock,
traj: &[StateSnapshot],
fail_index: usize,
baseline_window: usize,
k_sigma: f64,
budget: usize,
) -> ClockReport {
ClockReport {
name: clock.name().to_string(),
lead: early_warning_lead(clock, traj, fail_index, baseline_window, k_sigma),
compression_error: compression_error(clock, traj, budget),
causal_order_ok: preserves_causal_order(clock, traj),
}
}
// ---------------------------------------------------------------------------
// Synthetic scenario generator (deterministic; no external RNG dependency).
// ---------------------------------------------------------------------------
/// Deterministic xorshift64* PRNG so the benchmark is reproducible.
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Rng(seed | 1)
}
fn next_f64(&mut self) -> f64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
let v = x.wrapping_mul(0x2545_F491_4F6C_DD1D);
((v >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
}
}
/// Parameters of the drift-to-failure scenario.
#[derive(Clone, Copy, Debug)]
pub struct Scenario {
pub dim: usize,
pub steps: usize,
/// Steps used to learn each clock's quiet baseline (must precede `embed_onset`).
pub baseline_window: usize,
/// First *structural* regime transition — the precursor that moves before
/// any thermodynamic signal.
pub embed_onset: usize,
/// Step where *entropy* begins to rise (a later regime transition: systems
/// fail structurally before they fail visibly).
pub entropy_onset: usize,
/// Step at which visible failure occurs.
pub fail_index: usize,
pub noise: f64,
pub seed: u64,
}
impl Default for Scenario {
fn default() -> Self {
Scenario {
dim: 8,
steps: 100,
baseline_window: 18,
embed_onset: 22,
entropy_onset: 60,
fail_index: 80,
noise: 0.01,
seed: 0xC0FFEE,
}
}
}
/// Ramp contribution of a transition centered at `center` (width 4) evaluated
/// at step `i`: 0 before, 1 after, linear across the transition.
fn ramp(i: usize, center: usize) -> f64 {
let half = 2.0;
let x = (i as f64 - (center as f64 - half)) / (2.0 * half);
x.clamp(0.0, 1.0)
}
/// Generate a **regime-transition** drift-to-failure trajectory: long quiet
/// plateaus punctuated by discrete boundary crossings that march the system
/// toward a failure attractor. Structure (embedding/graph/coherence) moves at
/// the early transitions; entropy only rises at the later ones. This is the
/// variable-rate, bursty regime where internal time genuinely beats wall time —
/// both for early warning (structure precedes the entropy signal) and for
/// compression (sample the transitions, skip the plateaus).
pub fn generate_scenario(sc: &Scenario) -> Vec<StateSnapshot> {
let mut rng = Rng::new(sc.seed);
let attractor: Vec<f64> = (0..sc.dim)
.map(|i| if i % 2 == 0 { 1.0 } else { -1.0 })
.collect();
// Four regime transitions, each advancing the system by a quarter toward
// the failure attractor.
let t1 = sc.embed_onset;
let t3 = sc.entropy_onset;
let t4 = sc.fail_index.saturating_sub(3);
let t2 = (t1 + t3) / 2;
let centers = [t1, t2, t3, t4];
let mut traj = Vec::with_capacity(sc.steps);
for i in 0..sc.steps {
// Accumulated regime level in [0, 1].
let level: f64 = centers.iter().map(|&c| 0.25 * ramp(i, c)).sum::<f64>();
// Embedding marches toward the attractor as the level rises.
let embedding: Vec<f64> = attractor
.iter()
.map(|&a| level * a + sc.noise * rng.next_f64())
.collect();
// Entropy only responds to the *late* regime (level past 0.5), i.e. it
// lags the structural signal.
let entropy =
0.2 + 1.8 * ((level - 0.5) / 0.5).clamp(0.0, 1.0) + sc.noise * rng.next_f64().abs();
// Coherence and graph topology track the structural level directly.
let coherence = (1.0 - level).clamp(0.0, 1.0);
let graph = level + sc.noise * rng.next_f64().abs();
// Prediction error spikes only in the final approach (level past 0.75).
let pred_error = ((level - 0.75) / 0.25).clamp(0.0, 1.0);
traj.push(StateSnapshot::full(
embedding, entropy, coherence, graph, pred_error,
));
}
traj
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ticks_non_negative_and_order_preserved() {
let sc = Scenario::default();
let traj = generate_scenario(&sc);
let spt = StructuralProperTime::new(StructuralMetric::default());
assert!(preserves_causal_order(&spt, &traj));
assert!(preserves_causal_order(&EntropyClock, &traj));
assert!(preserves_causal_order(&WallClock, &traj));
}
#[test]
fn structural_warns_earlier_than_entropy_and_wall() {
let sc = Scenario::default();
let traj = generate_scenario(&sc);
let spt = StructuralProperTime::new(StructuralMetric::default());
let bw = sc.baseline_window;
let lead_wall = early_warning_lead(&WallClock, &traj, sc.fail_index, bw, 4.0);
let lead_entropy = early_warning_lead(&EntropyClock, &traj, sc.fail_index, bw, 4.0);
let lead_struct = early_warning_lead(&spt, &traj, sc.fail_index, bw, 4.0);
// Wall time gives no structural early warning.
assert_eq!(lead_wall, 0);
// Structure precedes the thermodynamic (entropy) signal.
assert!(
lead_struct > lead_entropy,
"structural lead {lead_struct} should exceed entropy lead {lead_entropy}"
);
// Acceptance target: ≥ 2x the entropy clock's lead.
assert!(
lead_struct as f64 >= 2.0 * lead_entropy.max(1) as f64,
"structural lead {lead_struct} should be >= 2x entropy lead {lead_entropy}"
);
}
#[test]
fn structural_compresses_better_than_wall() {
let sc = Scenario::default();
let traj = generate_scenario(&sc);
let spt = StructuralProperTime::new(StructuralMetric::default());
let budget = 10;
let err_wall = compression_error(&WallClock, &traj, budget);
let err_struct = compression_error(&spt, &traj, budget);
assert!(
err_struct < err_wall,
"structural error {err_struct} should beat wall error {err_wall}"
);
}
#[test]
fn structural_needs_fewer_samples_for_tolerance() {
let sc = Scenario::default();
let traj = generate_scenario(&sc);
let spt = StructuralProperTime::new(StructuralMetric::default());
let tol = 0.3;
let wall_budget = samples_to_tolerance(&WallClock, &traj, tol);
let struct_budget = samples_to_tolerance(&spt, &traj, tol);
assert!(
struct_budget < wall_budget,
"structural needs {struct_budget} samples, wall needs {wall_budget}"
);
}
#[test]
fn replans_on_low_structural_efficiency() {
// Lots of internal churn, little progress -> replan.
assert!(should_replan(5.0, 0.1, 0.1));
// Healthy progress per unit churn -> keep going.
assert!(!should_replan(1.0, 0.9, 0.1));
// No churn at all -> not stuck (infinite efficiency).
assert!(!should_replan(0.0, 0.0, 0.1));
}
}

View file

@ -0,0 +1,118 @@
//! ConnesRovelli thermal time.
//!
//! The thermal-time hypothesis: physical time flow is generated by the
//! statistical state itself, not imposed from outside. Given a density matrix
//! `ρ`, the **modular Hamiltonian** is
//!
//! ```text
//! K = -ln ρ
//! ```
//!
//! and observables flow under modular time `s`:
//!
//! ```text
//! A(s) = e^{isK} A e^{-isK}, dA/ds = i[K, A].
//! ```
//!
//! For a Gibbs state `ρ = e^{-βH}/Z` we get `K = βH + (ln Z)·I`, so modular flow
//! *is* ordinary Hamiltonian evolution rescaled by `β`: physical time is
//! recovered from the thermodynamic state.
use crate::complex::Complex;
use crate::complex_matrix::{exp_i_symmetric, CMatrix};
use crate::real_matrix::RealMatrix;
const PROB_FLOOR: f64 = 1e-12;
/// Modular Hamiltonian `K = -ln ρ` for a real symmetric density matrix.
/// Eigenvalues below `PROB_FLOOR` are clamped to keep the logarithm finite.
pub fn modular_hamiltonian(rho: &RealMatrix) -> RealMatrix {
let (probs, vecs) = rho.symmetric_eigen();
let k_eigs: Vec<f64> = probs.iter().map(|&p| -(p.max(PROB_FLOOR)).ln()).collect();
RealMatrix::from_spectrum(&k_eigs, &vecs)
}
/// Modular flow `A(s) = e^{isK} A e^{-isK}`.
pub fn modular_flow(k: &RealMatrix, a: &CMatrix, s: f64) -> CMatrix {
let u = exp_i_symmetric(k, s);
let u_dag = exp_i_symmetric(k, -s);
u.matmul(a).matmul(&u_dag)
}
/// Modular flow generator `dA/ds = i[K, A]` at `s = 0`.
pub fn modular_generator(k: &RealMatrix, a: &CMatrix) -> CMatrix {
let kc = CMatrix::from_real(k);
CMatrix::commutator(&kc, a).scale(Complex::I)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entropic::gibbs_density;
fn sample_h() -> RealMatrix {
RealMatrix::from_fn(3, |r, c| if r == c { r as f64 } else { 0.2 })
}
fn sample_observable() -> CMatrix {
// A Hermitian observable.
let mut a = CMatrix::zeros(3);
a.set(0, 1, Complex::new(0.0, 1.0));
a.set(1, 0, Complex::new(0.0, -1.0));
a.set(2, 2, Complex::real(0.5));
a
}
#[test]
fn modular_hamiltonian_of_gibbs_is_beta_h() {
let h = sample_h();
let beta = 0.7;
let rho = gibbs_density(&h, beta);
let k = modular_hamiltonian(&rho);
// K should equal βH + cI for some constant c. Check K - βH is a multiple
// of the identity by verifying all diagonal-shifted off-diagonals match.
let diff = RealMatrix::from_fn(3, |r, c| k.get(r, c) - beta * h.get(r, c));
// off-diagonal entries of diff ≈ 0
assert!(diff.max_offdiag() < 1e-7);
// diagonal entries of diff are all equal (the additive constant)
let c0 = diff.get(0, 0);
for i in 1..3 {
assert!((diff.get(i, i) - c0).abs() < 1e-7);
}
}
#[test]
fn generator_matches_finite_difference() {
let h = sample_h();
let rho = gibbs_density(&h, 0.9);
let k = modular_hamiltonian(&rho);
let a = sample_observable();
let gen = modular_generator(&k, &a);
let ds = 1e-5;
let fwd = modular_flow(&k, &a, ds);
let bwd = modular_flow(&k, &a, -ds);
// central difference of A(s)
let fd = fwd.sub(&bwd).scale(Complex::real(1.0 / (2.0 * ds)));
assert!(gen.sub(&fd).frob_norm() < 1e-4);
}
#[test]
fn modular_flow_equals_rescaled_physical_evolution() {
// For a Gibbs state, modular flow at parameter s == physical evolution
// e^{iβHs} A e^{-iβHs} (the ln Z phase cancels).
let h = sample_h();
let beta = 0.6;
let rho = gibbs_density(&h, beta);
let k = modular_hamiltonian(&rho);
let a = sample_observable();
let s = 0.8;
let modular = modular_flow(&k, &a, s);
let u = exp_i_symmetric(&h, beta * s);
let u_dag = exp_i_symmetric(&h, -beta * s);
let physical = u.matmul(&a).matmul(&u_dag);
assert!(modular.sub(&physical).frob_norm() < 1e-7);
}
}

View file

@ -0,0 +1,579 @@
//! Learned agentic-time channel weights.
//!
//! The hand-set [`crate::agentic_time::AgenticWeights`] (contradiction 1.5,
//! belief 1.0, …) are a guess. This module **learns** the per-channel weights
//! from labelled outcomes and measures, honestly, whether a learned composition
//! of the channels beats two fair competitors:
//!
//! 1. the hand-set weights used as a fixed linear scorer, and
//! 2. the single best individual channel (the fair "one scalar" baseline).
//!
//! The learner is a plain L2-regularized logistic regression (batch gradient
//! descent, feature standardization) — no external deps. The fitted
//! coefficients double as **interpretable** channel importances, and their
//! non-negative part yields clock weights for [`crate::agentic_time::AgenticTime`].
//!
//! ## Honesty guards
//!
//! * **Held-out evaluation.** Weights are fit on a train split of trace seeds
//! and every reported number is computed on a disjoint validation split.
//! * **Circularity guard.** [`FeatureMode::Honest`] drops the contradiction
//! channel, because in these synthetic traces failure correlates with rising
//! contradiction by construction; the meaningful question is whether the
//! *other* channels (plan thrash, belief jitter, retrieval instability, goal
//! reopening) predict failure on their own.
//! * **Negative results are reported, not hidden.** The verdict prints whether
//! learning actually beats the baselines, even when it does not.
//!
//! This is a synthetic-data harness: a positive result here is *evidence that
//! the channel composition carries signal worth pursuing on real traces*, not a
//! production claim. Real labelled traces are required to clear ADR-251
//! invariant §4 (baseline dominance).
use crate::agentic_time::AgentState;
/// Which channels feed the learner.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FeatureMode {
/// All six channels (belief, memory, retrieval, goal, contradiction, plan).
Full,
/// Drop the contradiction channel (circularity guard).
Honest,
}
impl FeatureMode {
/// Human-readable channel names in feature order.
pub fn channel_names(self) -> &'static [&'static str] {
match self {
FeatureMode::Full => &[
"belief",
"memory",
"retrieval",
"goal_graph",
"contradiction",
"plan",
],
FeatureMode::Honest => &["belief", "memory", "retrieval", "goal_graph", "plan"],
}
}
pub fn dim(self) -> usize {
self.channel_names().len()
}
}
fn l2(a: &[f64], b: &[f64]) -> f64 {
a.iter()
.zip(b)
.map(|(x, y)| (x - y) * (x - y))
.sum::<f64>()
.sqrt()
}
/// Per-step channel-movement feature vector for a transition `prev -> cur`.
pub fn step_features(prev: &AgentState, cur: &AgentState, mode: FeatureMode) -> Vec<f64> {
let belief = l2(&prev.belief, &cur.belief);
let memory = l2(&prev.memory, &cur.memory);
let retrieval = l2(&prev.retrieval, &cur.retrieval);
let goal = (cur.goal_graph - prev.goal_graph).abs();
let contradiction = (cur.contradiction - prev.contradiction).abs();
let plan = l2(&prev.plan, &cur.plan);
match mode {
FeatureMode::Full => vec![belief, memory, retrieval, goal, contradiction, plan],
FeatureMode::Honest => vec![belief, memory, retrieval, goal, plan],
}
}
// ---------------------------------------------------------------------------
// Labelled synthetic traces (deterministic).
// ---------------------------------------------------------------------------
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Rng(seed | 1)
}
fn unit(&mut self) -> f64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
let v = x.wrapping_mul(0x2545_F491_4F6C_DD1D);
((v >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
}
/// Uniform in (0, 1].
fn unit01(&mut self) -> f64 {
(self.unit() + 1.0) * 0.5 + 1e-12
}
/// Standard normal via BoxMuller.
fn gaussian(&mut self) -> f64 {
let u1 = self.unit01();
let u2 = self.unit01();
(-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
}
}
/// A labelled trace: the agent states plus the failure step (if any).
pub struct LabeledTrace {
pub states: Vec<AgentState>,
pub fail_index: Option<usize>,
}
/// Generate one labelled synthetic trace. `will_fail` traces thrash (plan
/// oscillation, belief jitter, retrieval instability, goal reopening, rising
/// contradiction) before a failure step; healthy traces converge steadily.
pub fn synth_trace(seed: u64, will_fail: bool) -> LabeledTrace {
let dim = 6;
let steps = 100;
let mut rng = Rng::new(seed);
let target: Vec<f64> = (0..dim)
.map(|i| if i % 2 == 0 { 1.0 } else { -1.0 })
.collect();
// Seed-varied schedule for failing traces.
let fail_index = if will_fail {
Some(70 + (seed % 13) as usize)
} else {
None
};
let onset = fail_index.map(|f| f.saturating_sub(35));
let mut states = Vec::with_capacity(steps);
let mut tokens = 0u64;
let mut prev_belief = vec![0.0; dim];
for i in 0..steps {
tokens += 120 + (rng.unit().abs() * 12.0) as u64;
let thrashing = matches!((onset, fail_index), (Some(o), Some(f)) if i >= o && i < f + 4);
let (belief, plan, retrieval, contradiction, goal) = if thrashing {
let osc = if i % 2 == 0 { 1.0 } else { -1.0 };
let o = onset.unwrap();
let f = fail_index.unwrap();
let p = (i - o) as f64 / (f - o).max(1) as f64;
let belief: Vec<f64> = target
.iter()
.map(|&t| t + 0.3 * osc + 0.06 * rng.unit())
.collect();
let plan: Vec<f64> = target
.iter()
.map(|&t| t + 0.8 * osc + 0.10 * rng.unit())
.collect();
let retrieval: Vec<f64> = target.iter().map(|&t| t + 0.4 * rng.unit()).collect();
let contradiction = (0.05 + 0.9 * p).min(0.95);
let goal = 1.0 + (i - o) as f64 * 0.05;
(belief, plan, retrieval, contradiction, goal)
} else {
// Healthy convergence (also the early phase of failing traces).
let frac = i as f64 / steps as f64;
let belief: Vec<f64> = target
.iter()
.map(|&t| frac * t + 0.02 * rng.unit())
.collect();
let plan = belief.clone();
let retrieval: Vec<f64> = target
.iter()
.map(|&t| frac * t + 0.02 * rng.unit())
.collect();
let contradiction = 0.05 + 0.02 * rng.unit().abs();
let goal = (1.0 - frac).max(0.0);
(belief, plan, retrieval, contradiction, goal)
};
let memory = prev_belief.clone();
prev_belief = belief.clone();
states.push(AgentState {
belief,
memory,
retrieval,
goal_graph: goal,
contradiction,
plan,
tokens,
});
}
LabeledTrace { states, fail_index }
}
/// Build a per-step classification dataset from labelled traces. A step is
/// positive iff a failure occurs within `horizon` steps ahead; steps at or after
/// the failure are dropped (we predict the *approach*, not the aftermath).
pub fn build_dataset(
traces: &[LabeledTrace],
horizon: usize,
mode: FeatureMode,
) -> (Vec<Vec<f64>>, Vec<f64>) {
let mut x = Vec::new();
let mut y = Vec::new();
for tr in traces {
for i in 1..tr.states.len() {
match tr.fail_index {
Some(f) => {
if i >= f {
continue; // at/after failure: drop
}
let label = if f - i <= horizon { 1.0 } else { 0.0 };
x.push(step_features(&tr.states[i - 1], &tr.states[i], mode));
y.push(label);
}
None => {
x.push(step_features(&tr.states[i - 1], &tr.states[i], mode));
y.push(0.0);
}
}
}
}
(x, y)
}
// ---------------------------------------------------------------------------
// Logistic regression with feature standardization.
// ---------------------------------------------------------------------------
/// A fitted logistic-regression scorer over standardized channel features.
#[derive(Clone, Debug)]
pub struct LearnedWeights {
/// Feature dimensionality.
pub dim: usize,
/// Coefficients in standardized-feature space (interpretable importances).
pub coef: Vec<f64>,
pub bias: f64,
/// Per-feature training mean (standardization).
pub mean: Vec<f64>,
/// Per-feature training std (standardization).
pub std: Vec<f64>,
}
fn sigmoid(z: f64) -> f64 {
1.0 / (1.0 + (-z).exp())
}
impl LearnedWeights {
/// Fit by L2-regularized logistic regression (batch GD) over `dim` features.
pub fn fit(
x: &[Vec<f64>],
y: &[f64],
dim: usize,
iters: usize,
lr: f64,
l2_reg: f64,
) -> LearnedWeights {
let d = dim;
let n = x.len().max(1);
// Standardize columns.
let mut mean = vec![0.0; d];
for row in x {
for j in 0..d {
mean[j] += row[j];
}
}
for m in &mut mean {
*m /= n as f64;
}
let mut std = vec![0.0; d];
for row in x {
for j in 0..d {
std[j] += (row[j] - mean[j]).powi(2);
}
}
for s in &mut std {
*s = (*s / n as f64).sqrt().max(1e-9);
}
let z = |row: &[f64], coef: &[f64], bias: f64| -> f64 {
let mut acc = bias;
for j in 0..d {
acc += coef[j] * (row[j] - mean[j]) / std[j];
}
acc
};
let mut coef = vec![0.0; d];
let mut bias = 0.0;
for _ in 0..iters {
let mut g = vec![0.0; d];
let mut gb = 0.0;
for (row, &label) in x.iter().zip(y) {
let p = sigmoid(z(row, &coef, bias));
let err = p - label;
for j in 0..d {
g[j] += err * (row[j] - mean[j]) / std[j];
}
gb += err;
}
for j in 0..d {
coef[j] -= lr * (g[j] / n as f64 + l2_reg * coef[j]);
}
bias -= lr * gb / n as f64;
}
LearnedWeights {
dim,
coef,
bias,
mean,
std,
}
}
/// Predicted failure-approach probability for a raw feature vector.
pub fn predict(&self, features: &[f64]) -> f64 {
let d = self.dim;
let mut acc = self.bias;
for j in 0..d {
acc += self.coef[j] * (features[j] - self.mean[j]) / self.std[j];
}
sigmoid(acc)
}
/// Non-negative clock weights derived from the learned coefficients (the
/// positive part, since a clock increment must stay non-negative).
pub fn clock_weights(&self) -> Vec<f64> {
self.coef.iter().map(|c| c.max(0.0)).collect()
}
/// Reconstruct a model from persisted parameters (used when loading a sealed
/// artifact for verification).
pub fn from_params(
dim: usize,
coef: Vec<f64>,
bias: f64,
mean: Vec<f64>,
std: Vec<f64>,
) -> LearnedWeights {
LearnedWeights {
dim,
coef,
bias,
mean,
std,
}
}
}
/// A controlled **diffuse weak-signal** benchmark: `dim` Gaussian features where
/// the positive class shifts each feature `j` by `mus[j]` standard deviations.
/// Some channels carry weak signal, some are pure noise (`mu = 0`). This is the
/// regime the composition thesis targets — no single channel separates the
/// classes well, but their *weighted* combination does, and because the per-
/// channel strengths differ, the optimal weights are non-uniform (so learning
/// beats an equal-weight guess too).
///
/// Returns `(X, y)` with `n_per_class` positives and `n_per_class` negatives.
/// Deterministic in `seed`. This is explicitly a synthetic signal-composition
/// benchmark, NOT agent traces — it proves the *learner* works when its
/// assumption (distributed weak signal of varying strength) holds.
pub fn diffuse_dataset(n_per_class: usize, mus: &[f64], seed: u64) -> (Vec<Vec<f64>>, Vec<f64>) {
let d = mus.len();
let mut rng = Rng::new(seed);
let mut x = Vec::with_capacity(2 * n_per_class);
let mut y = Vec::with_capacity(2 * n_per_class);
for k in 0..2 * n_per_class {
let label = if k % 2 == 0 { 1.0 } else { 0.0 };
let row: Vec<f64> = (0..d).map(|j| label * mus[j] + rng.gaussian()).collect();
x.push(row);
y.push(label);
}
(x, y)
}
/// Rank-based ROC AUC (MannWhitney). 0.5 = chance, 1.0 = perfect ranking.
pub fn auc(scores: &[f64], labels: &[f64]) -> f64 {
let pos: Vec<f64> = scores
.iter()
.zip(labels)
.filter(|(_, &l)| l > 0.5)
.map(|(&s, _)| s)
.collect();
let neg: Vec<f64> = scores
.iter()
.zip(labels)
.filter(|(_, &l)| l <= 0.5)
.map(|(&s, _)| s)
.collect();
if pos.is_empty() || neg.is_empty() {
return 0.5;
}
let mut wins = 0.0;
for &p in &pos {
for &nn in &neg {
if p > nn {
wins += 1.0;
} else if (p - nn).abs() < 1e-12 {
wins += 0.5;
}
}
}
wins / (pos.len() * neg.len()) as f64
}
/// Score a dataset with a fixed non-negative weight vector (a linear clock-style
/// scorer) — used to evaluate the hand-set weights as a competitor.
pub fn linear_scores(x: &[Vec<f64>], weights: &[f64]) -> Vec<f64> {
x.iter()
.map(|row| row.iter().zip(weights).map(|(a, b)| a * b).sum())
.collect()
}
/// AUC of the single best individual channel (the fair "one scalar" baseline).
pub fn best_single_channel_auc(x: &[Vec<f64>], y: &[f64], dim: usize) -> (usize, f64) {
let mut best = (0usize, 0.0f64);
for j in 0..dim {
let col: Vec<f64> = x.iter().map(|r| r[j]).collect();
let a = auc(&col, y);
// A channel can be anti-correlated; take the stronger of a and 1-a.
let a = a.max(1.0 - a);
if a > best.1 {
best = (j, a);
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
/// Build disjoint train/val trace seeds, half failing / half healthy.
fn split_traces(n_per_class: usize, train_frac: f64) -> (Vec<LabeledTrace>, Vec<LabeledTrace>) {
let mut train = Vec::new();
let mut val = Vec::new();
let cut = (n_per_class as f64 * train_frac) as u64;
for s in 0..n_per_class as u64 {
for will_fail in [true, false] {
let seed = (s + 1) * 2_654_435_761 + will_fail as u64;
let tr = synth_trace(seed, will_fail);
if s < cut {
train.push(tr);
} else {
val.push(tr);
}
}
}
(train, val)
}
#[test]
fn learned_weights_beat_chance_on_held_out() {
let (train, val) = split_traces(40, 0.6);
let horizon = 12;
let mode = FeatureMode::Honest;
let (xtr, ytr) = build_dataset(&train, horizon, mode);
let (xva, yva) = build_dataset(&val, horizon, mode);
let model = LearnedWeights::fit(&xtr, &ytr, mode.dim(), 600, 0.3, 1e-3);
let scores: Vec<f64> = xva.iter().map(|r| model.predict(r)).collect();
let learned_auc = auc(&scores, &yva);
// Even WITHOUT the contradiction channel, the composed signal should be
// clearly better than chance on held-out traces.
assert!(
learned_auc > 0.7,
"held-out honest-mode AUC {learned_auc} should beat chance"
);
}
#[test]
fn learned_beats_handset_weights() {
// Learning the weights should be no worse than the hand-set guess: this
// is the defensible, robust claim. (Whether it beats the *best single
// channel* is a separate, data-dependent question — see the next test.)
let (train, val) = split_traces(40, 0.6);
let horizon = 12;
let mode = FeatureMode::Honest;
let (xtr, ytr) = build_dataset(&train, horizon, mode);
let (xva, yva) = build_dataset(&val, horizon, mode);
let model = LearnedWeights::fit(&xtr, &ytr, mode.dim(), 600, 0.3, 1e-3);
let learned: Vec<f64> = xva.iter().map(|r| model.predict(r)).collect();
let learned_auc = auc(&learned, &yva);
// Hand-set weights (default AgenticWeights, contradiction dropped for
// Honest mode): belief 1.0, memory 0.5, retrieval 0.5, goal 1.0, plan 1.0.
let handset = [1.0, 0.5, 0.5, 1.0, 1.0];
let handset_auc = auc(&linear_scores(&xva, &handset), &yva);
assert!(
learned_auc >= handset_auc - 1e-9,
"learned {learned_auc} should be >= hand-set {handset_auc}"
);
}
#[test]
fn honest_finding_single_channel_is_a_strong_baseline() {
// HONEST NEGATIVE-ish RESULT (documented, not hidden): on this synthetic
// generator the failure signal is concentrated in ONE planted channel
// (plan thrash), so the best single channel is a strong baseline that the
// learned multi-channel composition does NOT clearly beat. This mirrors
// ADR-251 §4: composition only earns its keep when signal is spread
// across several weak channels — which is a property of REAL traces, not
// this single-dominant-signal synthetic. We assert the relationship that
// actually holds so the test documents the truth rather than a wished-for
// win.
let (train, val) = split_traces(40, 0.6);
let mode = FeatureMode::Honest;
let (xtr, ytr) = build_dataset(&train, 12, mode);
let (xva, yva) = build_dataset(&val, 12, mode);
let model = LearnedWeights::fit(&xtr, &ytr, mode.dim(), 600, 0.3, 1e-3);
let learned_auc = auc(
&xva.iter().map(|r| model.predict(r)).collect::<Vec<_>>(),
&yva,
);
let (_, single_auc) = best_single_channel_auc(&xva, &yva, mode.dim());
// Both are strong; learning is competitive (within a small margin) but
// does not beat the dominant single channel on synthetic data.
assert!(learned_auc > 0.7 && single_auc > 0.7);
assert!(
(learned_auc - single_auc).abs() < 0.08,
"learned {learned_auc} and best-single {single_auc} should be close"
);
}
#[test]
fn clock_weights_are_non_negative() {
let (train, _val) = split_traces(20, 1.0);
let (xtr, ytr) = build_dataset(&train, 12, FeatureMode::Full);
let model = LearnedWeights::fit(&xtr, &ytr, FeatureMode::Full.dim(), 300, 0.3, 1e-3);
assert!(model.clock_weights().iter().all(|&w| w >= 0.0));
}
/// In the regime the composition thesis actually targets — signal spread
/// weakly across channels of *differing* strength, with pure-noise channels
/// present — the learned composition beats BOTH the best single channel AND
/// the equal-weight hand-set guess, on a held-out split. This is a clean
/// existence proof that the learner earns its keep when its assumption holds.
#[test]
fn learned_beats_both_baselines_on_diffuse_signal() {
// 6 channels: two strong-ish, two weak, two pure noise.
let mus = [0.7, 0.6, 0.3, 0.3, 0.0, 0.0];
let d = mus.len();
let (xtr, ytr) = diffuse_dataset(2000, &mus, 0xD1FF);
let (xva, yva) = diffuse_dataset(2000, &mus, 0x5EED);
let model = LearnedWeights::fit(&xtr, &ytr, d, 400, 0.3, 1e-4);
let learned_auc = auc(
&xva.iter().map(|r| model.predict(r)).collect::<Vec<_>>(),
&yva,
);
// Equal-weight hand-set guess (a fair "just sum the channels" baseline).
let equal = vec![1.0; d];
let handset_auc = auc(&linear_scores(&xva, &equal), &yva);
let (_, single_auc) = best_single_channel_auc(&xva, &yva, d);
assert!(
learned_auc > single_auc + 0.02,
"learned {learned_auc} should beat best single channel {single_auc}"
);
assert!(
learned_auc > handset_auc + 0.005,
"learned {learned_auc} should beat equal-weight handset {handset_auc}"
);
}
}

View file

@ -0,0 +1,214 @@
//! WheelerDeWitt timeless constraint.
//!
//! The quantum state of a closed universe obeys `Ĥ|Ψ> = 0` — there is no
//! external time parameter. "Time" must be found *inside* the state. This
//! module builds bipartite constraint operators `Ĵ = H_C ⊗ I + I ⊗ H_R` and
//! locates their physical (kernel) states.
//!
//! ## What is trivial here vs. what is discriminating
//!
//! The constraint `Ĵ = H_C ⊗ I + I ⊗ H_R` has eigenvalues `{aᵢ + bⱼ}` over all
//! pairs of eigenvalues `aᵢ` of `H_C` and `bⱼ` of `H_R`. A kernel (a physical
//! timeless state) exists **iff** some `aᵢ = bⱼ`, i.e. iff the clock spectrum
//! and the (negated) rest spectrum overlap.
//!
//! In the PageWootters construction the clock is *built* with `H_C = diag(Eₖ)`,
//! so the spectra match by construction and the kernel's existence is therefore
//! **trivial-by-construction** — verifying it is a *consistency check*, not a
//! discovery. The discriminating physical content of the module is the
//! complementary statement: for a *generic* clock Hamiltonian whose spectrum is
//! not `spectrum(H_R)`, the constraint has **no zero eigenvalue at all** — the
//! physical Hilbert space is *empty*. That is what makes the constraint a real
//! constraint rather than a tautology, and it is tested in
//! [`tests::generic_clock_yields_empty_physical_space`].
use crate::complex::Complex;
use crate::complex_matrix::CMatrix;
use crate::real_matrix::RealMatrix;
use crate::state::idx;
/// Build the bipartite constraint `Ĵ = H_C ⊗ I_{dr} + I_{dc} ⊗ H_R`.
///
/// Physical states `|Ψ>` of the joint clock+rest system satisfy `Ĵ|Ψ> = 0`:
/// the total "energy" (clock + rest) is constrained to vanish, which is what
/// removes the external time parameter.
pub fn bipartite_constraint(h_c: &RealMatrix, h_r: &RealMatrix) -> RealMatrix {
let dc = h_c.n;
let dr = h_r.n;
let n = dc * dr;
let mut j = RealMatrix::zeros(n);
for c in 0..dc {
for cp in 0..dc {
let hc = h_c.get(c, cp);
for r in 0..dr {
for rp in 0..dr {
let mut v = 0.0;
if r == rp {
v += hc; // H_C ⊗ I
}
if c == cp {
v += h_r.get(r, rp); // I ⊗ H_R
}
if v != 0.0 {
j.set(idx(c, r, dr), idx(cp, rp, dr), v);
}
}
}
}
}
j
}
/// A physical (timeless) state: the eigenvector of the constraint with the
/// eigenvalue closest to zero.
pub struct PhysicalState {
/// The constraint eigenvalue actually achieved (≈ 0 for a true kernel).
pub eigenvalue: f64,
/// The normalized physical state vector.
pub state: Vec<f64>,
}
/// Find the physical state `|Ψ>` solving `Ĵ|Ψ> ≈ 0` — the kernel direction of
/// the constraint operator.
pub fn solve_constraint(j: &RealMatrix) -> PhysicalState {
let (vals, vecs) = j.symmetric_eigen();
let mut best = 0usize;
for k in 1..vals.len() {
if vals[k].abs() < vals[best].abs() {
best = k;
}
}
PhysicalState {
eigenvalue: vals[best],
state: vecs.column(best),
}
}
/// Residual `‖Ĵ|Ψ>‖` for a (possibly complex) state vector — the degree to
/// which the timeless equation `Ĥ|Ψ> = 0` is satisfied.
pub fn constraint_residual(j: &RealMatrix, psi: &[Complex]) -> f64 {
let jc = CMatrix::from_real(j);
let out = jc.matvec(psi);
out.iter().map(|z| z.norm_sqr()).sum::<f64>().sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::page_wootters::PageWootters;
fn sample_h() -> RealMatrix {
RealMatrix::from_fn(3, |r, c| if r == c { (r as f64) - 1.0 } else { 0.3 })
}
#[test]
fn constructed_page_wootters_state_lies_in_kernel() {
// CONSISTENCY CHECK (not a discovery): with the energy-matched clock
// `H_C = diag(Eₖ)`, the PageWootters state is *built* term-by-term to
// be annihilated by Ĵ, so it is in the kernel by construction. This test
// confirms the construction is internally consistent — it cannot fail
// for a correct implementation and is not evidence that the constraint
// constrains. The discriminating test is below.
let pw = PageWootters::new(sample_h());
let j = bipartite_constraint(&pw.clock_hamiltonian(), &pw.h_r);
let psi = pw.global_static_state();
let residual = constraint_residual(&j, &psi);
assert!(residual < 1e-8, "residual {residual} should be ~0");
}
#[test]
fn energy_matched_clock_has_zero_eigenvalue_by_construction() {
// CONSISTENCY CHECK: because `H_C = diag(Eₖ)`, every diagonal pair
// (a = b) contributes an eigenvalue `Eₖ + Eₖ = 0`, so a kernel is
// guaranteed to exist. Verifying it is a sanity check on the eigensolver,
// not a physical discovery.
let pw = PageWootters::new(sample_h());
let j = bipartite_constraint(&pw.clock_hamiltonian(), &pw.h_r);
let phys = solve_constraint(&j);
assert!(phys.eigenvalue.abs() < 1e-8);
// Kernel state is a unit vector.
let norm: f64 = phys.state.iter().map(|x| x * x).sum::<f64>().sqrt();
assert!((norm - 1.0).abs() < 1e-8);
}
#[test]
fn generic_clock_yields_empty_physical_space() {
// DISCRIMINATING TEST — this is the one that can actually fail and that
// proves the constraint constrains. Build Ĵ from a GENERIC clock
// Hamiltonian `H_C` that is NOT `H_R`, and whose spectrum does not
// match `spectrum(H_R)`. Then Ĵ has eigenvalues {aᵢ + bⱼ} with no
// (aᵢ = bⱼ) coincidence, so there is NO eigenvalue near zero: the
// physical (timeless) Hilbert space is EMPTY.
//
// Why this is the real content: finding the kernel for the energy-matched
// clock is trivial-by-construction (see the consistency checks above).
// Emptiness for a generic clock is what distinguishes a genuine
// constraint from a tautology — it shows the kernel's existence is
// *special* to the energy-matched clock, not automatic.
let h_r = sample_h();
let (energies, _v) = h_r.symmetric_eigen();
// A deterministic generic real-symmetric clock Hamiltonian, chosen so its
// spectrum is well separated from spectrum(H_R). We start from a base
// and, if any accidental near-degeneracy aᵢ ≈ bⱼ shows up, perturb the
// diagonal by a fixed offset until the minimum |aᵢ + bⱼ| clears a margin.
let min_gap = 0.25;
let mut offset = 0.0f64;
let (h_c, min_sum) = loop {
let off = offset;
// Generic: distinct diagonal, non-trivial off-diagonal, NOT diag(Eₖ).
let h_c = RealMatrix::from_fn(3, |r, c| {
if r == c {
// 7, 8, 9 (+ offset): far from E (which are ≈ 2..1 here),
// and not a permutation of Eₖ.
(7 + r) as f64 + off
} else {
0.2
}
});
let (a_vals, _) = h_c.symmetric_eigen();
let mut min_sum = f64::INFINITY;
for &a in &a_vals {
for &b in &energies {
min_sum = min_sum.min((a + b).abs());
}
}
if min_sum > min_gap {
break (h_c, min_sum);
}
// Deterministic perturbation to escape any accidental coincidence.
offset += 0.5;
assert!(
offset < 100.0,
"failed to find a generic non-matching clock"
);
};
// Sanity: the chosen clock is genuinely not the energy-matched one.
let matched = RealMatrix::diag(&energies.iter().map(|e| -e).collect::<Vec<_>>());
let max_diff = (0..3)
.flat_map(|r| (0..3).map(move |c| (r, c)))
.map(|(r, c)| (h_c.get(r, c) - matched.get(r, c)).abs())
.fold(0.0f64, f64::max);
assert!(
max_diff > 1.0,
"test clock must differ from the matched clock"
);
let j = bipartite_constraint(&h_c, &h_r);
let (j_vals, _) = j.symmetric_eigen();
let nearest_zero = j_vals.iter().map(|v| v.abs()).fold(f64::INFINITY, f64::min);
// No eigenvalue within 1e-9 of zero ⇒ empty physical Hilbert space.
assert!(
nearest_zero > 1e-9,
"generic clock must leave NO kernel; nearest eigenvalue to zero was {nearest_zero} \
(predicted lower bound min|aᵢ+bⱼ| = {min_sum})"
);
// The predicted bound and the measured spectrum agree.
assert!(
(nearest_zero - min_sum).abs() < 1e-6,
"measured nearest-zero {nearest_zero} should match the eigenvalue-sum prediction {min_sum}"
);
}
}

View file

@ -0,0 +1,310 @@
//! Witness chains — tamper-evident, reproducible provenance for trained models.
//!
//! A witness chain is a hash-linked ledger of training runs. Each record seals
//! the hashes of its inputs (dataset + config), the resulting model, and the
//! held-out metrics, then links to the previous record's hash. Anyone can
//! recompute the hashes from the committed model + a re-run and confirm:
//!
//! 1. **integrity** — the stored metrics/model match their hashes;
//! 2. **chain continuity** — each record links to the prior one;
//! 3. **reproducibility** — re-training with the same data + config yields the
//! same `model_hash` (the learner is deterministic), so the sealed metrics
//! are checkable rather than asserted.
//!
//! This is "proof" in the sense of *verifiable provenance* — it proves the
//! reported numbers correspond to the committed model and are reproducible. It
//! does **not** prove the model beats real-world SOTA; that requires real
//! labelled data (ADR-251 §4).
//!
//! Hashing is FNV-1a (64-bit) — deterministic, dependency-free, and adequate for
//! provenance/integrity (not a cryptographic commitment).
/// FNV-1a 64-bit hash.
pub fn fnv1a64(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
/// Round to 6 decimals so float metrics serialize and re-hash exactly.
fn round6(x: f64) -> f64 {
(x * 1e6).round() / 1e6
}
/// Hash a slice of f64 by their canonical (rounded) bit patterns.
pub fn hash_f64s(xs: &[f64]) -> u64 {
let mut bytes = Vec::with_capacity(xs.len() * 8);
for &x in xs {
bytes.extend_from_slice(&round6(x).to_bits().to_le_bytes());
}
fnv1a64(&bytes)
}
/// Hash a 2-D dataset plus its labels into a single content hash.
pub fn hash_dataset(x: &[Vec<f64>], y: &[f64]) -> u64 {
let mut acc: u64 = fnv1a64(&(x.len() as u64).to_le_bytes());
for (row, &label) in x.iter().zip(y) {
let mut h = hash_f64s(row);
h ^= (label.to_bits()).rotate_left(17);
acc = acc.wrapping_mul(0x100_0000_01b3) ^ h;
}
acc
}
/// One sealed training-run record.
#[derive(Clone, Debug, PartialEq)]
pub struct WitnessRecord {
pub index: u64,
pub prev: u64,
pub data_hash: u64,
pub config_hash: u64,
pub model_hash: u64,
pub val_auc: f64,
pub single_auc: f64,
pub handset_auc: f64,
/// Sealing hash over all fields above (incl. `prev`).
pub hash: u64,
}
impl WitnessRecord {
/// Canonical byte encoding used for the sealing hash.
fn payload(&self) -> Vec<u8> {
let mut b = Vec::with_capacity(8 * 8);
b.extend_from_slice(&self.index.to_le_bytes());
b.extend_from_slice(&self.prev.to_le_bytes());
b.extend_from_slice(&self.data_hash.to_le_bytes());
b.extend_from_slice(&self.config_hash.to_le_bytes());
b.extend_from_slice(&self.model_hash.to_le_bytes());
b.extend_from_slice(&round6(self.val_auc).to_bits().to_le_bytes());
b.extend_from_slice(&round6(self.single_auc).to_bits().to_le_bytes());
b.extend_from_slice(&round6(self.handset_auc).to_bits().to_le_bytes());
b
}
/// Seal a record: compute its `hash` from its content and `prev`.
#[allow(clippy::too_many_arguments)]
pub fn seal(
index: u64,
prev: u64,
data_hash: u64,
config_hash: u64,
model_hash: u64,
val_auc: f64,
single_auc: f64,
handset_auc: f64,
) -> WitnessRecord {
let mut r = WitnessRecord {
index,
prev,
data_hash,
config_hash,
model_hash,
val_auc,
single_auc,
handset_auc,
hash: 0,
};
r.hash = fnv1a64(&r.payload());
r
}
/// Recompute the sealing hash and compare to the stored one.
pub fn is_intact(&self) -> bool {
fnv1a64(&self.payload()) == self.hash
}
/// Serialize to a single pipe-delimited line (hex hashes, 6-dp metrics).
pub fn to_line(&self) -> String {
format!(
"{}|{:016x}|{:016x}|{:016x}|{:016x}|{:.6}|{:.6}|{:.6}|{:016x}",
self.index,
self.prev,
self.data_hash,
self.config_hash,
self.model_hash,
round6(self.val_auc),
round6(self.single_auc),
round6(self.handset_auc),
self.hash
)
}
/// Parse a line produced by [`WitnessRecord::to_line`].
pub fn from_line(line: &str) -> Option<WitnessRecord> {
let p: Vec<&str> = line.trim().split('|').collect();
if p.len() != 9 {
return None;
}
Some(WitnessRecord {
index: p[0].parse().ok()?,
prev: u64::from_str_radix(p[1], 16).ok()?,
data_hash: u64::from_str_radix(p[2], 16).ok()?,
config_hash: u64::from_str_radix(p[3], 16).ok()?,
model_hash: u64::from_str_radix(p[4], 16).ok()?,
val_auc: p[5].parse().ok()?,
single_auc: p[6].parse().ok()?,
handset_auc: p[7].parse().ok()?,
hash: u64::from_str_radix(p[8], 16).ok()?,
})
}
}
/// A hash-linked chain of witness records.
#[derive(Clone, Debug, Default)]
pub struct WitnessChain {
pub records: Vec<WitnessRecord>,
}
impl WitnessChain {
pub fn new() -> Self {
WitnessChain::default()
}
/// Tip hash (0 for an empty chain).
pub fn tip(&self) -> u64 {
self.records.last().map(|r| r.hash).unwrap_or(0)
}
/// Append a pre-sealed record whose `prev` must equal the current tip.
pub fn append(&mut self, record: WitnessRecord) -> Result<(), String> {
if record.prev != self.tip() {
return Err(format!(
"record {} prev {:016x} does not link to tip {:016x}",
record.index,
record.prev,
self.tip()
));
}
if !record.is_intact() {
return Err(format!("record {} fails its own seal", record.index));
}
self.records.push(record);
Ok(())
}
/// Seal a fresh record from raw inputs and append it.
#[allow(clippy::too_many_arguments)]
pub fn seal_and_append(
&mut self,
data_hash: u64,
config_hash: u64,
model_hash: u64,
val_auc: f64,
single_auc: f64,
handset_auc: f64,
) -> Result<(), String> {
let rec = WitnessRecord::seal(
self.records.len() as u64,
self.tip(),
data_hash,
config_hash,
model_hash,
val_auc,
single_auc,
handset_auc,
);
self.append(rec)
}
/// Verify every record's seal and the chain links. Returns the verified
/// length or the first inconsistency.
pub fn verify(&self) -> Result<usize, String> {
let mut prev = 0u64;
for (i, r) in self.records.iter().enumerate() {
if r.index as usize != i {
return Err(format!("record {i} has out-of-order index {}", r.index));
}
if r.prev != prev {
return Err(format!("record {i} broken link"));
}
if !r.is_intact() {
return Err(format!("record {i} tampered (seal mismatch)"));
}
prev = r.hash;
}
Ok(self.records.len())
}
pub fn to_text(&self) -> String {
let mut s = String::from(
"# emergent-time witness chain\n\
# index|prev|data_hash|config_hash|model_hash|val_auc|single_auc|handset_auc|hash\n",
);
for r in &self.records {
s.push_str(&r.to_line());
s.push('\n');
}
s
}
pub fn from_text(text: &str) -> WitnessChain {
let mut chain = WitnessChain::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(r) = WitnessRecord::from_line(line) {
chain.records.push(r);
}
}
chain
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seal_is_deterministic_and_intact() {
let a = WitnessRecord::seal(0, 0, 11, 22, 33, 0.9, 0.7, 0.8);
let b = WitnessRecord::seal(0, 0, 11, 22, 33, 0.9, 0.7, 0.8);
assert_eq!(a, b);
assert!(a.is_intact());
}
#[test]
fn chain_links_and_verifies() {
let mut c = WitnessChain::new();
c.seal_and_append(1, 2, 3, 0.90, 0.70, 0.80).unwrap();
c.seal_and_append(4, 5, 6, 0.92, 0.71, 0.81).unwrap();
assert_eq!(c.verify().unwrap(), 2);
// Each record links to the previous.
assert_eq!(c.records[1].prev, c.records[0].hash);
}
#[test]
fn tamper_is_detected() {
let mut c = WitnessChain::new();
c.seal_and_append(1, 2, 3, 0.90, 0.70, 0.80).unwrap();
c.seal_and_append(4, 5, 6, 0.92, 0.71, 0.81).unwrap();
// Flip a metric without resealing → seal mismatch.
c.records[0].val_auc = 0.99;
assert!(c.verify().is_err());
}
#[test]
fn text_round_trip_preserves_verification() {
let mut c = WitnessChain::new();
c.seal_and_append(1, 2, 3, 0.901234, 0.706789, 0.812345)
.unwrap();
c.seal_and_append(4, 5, 6, 0.923456, 0.711111, 0.815555)
.unwrap();
let text = c.to_text();
let parsed = WitnessChain::from_text(&text);
assert_eq!(parsed.records, c.records);
assert_eq!(parsed.verify().unwrap(), 2);
}
#[test]
fn dataset_hash_is_order_sensitive() {
let x1 = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
let x2 = vec![vec![3.0, 4.0], vec![1.0, 2.0]];
let y = vec![1.0, 0.0];
assert_ne!(hash_dataset(&x1, &y), hash_dataset(&x2, &y));
}
}

View file

@ -0,0 +1,412 @@
# ADR-251: Agentic Time as a First-Class Runtime Primitive
- **Status**: proposed
- **Date**: 2026-06-13
- **Deciders**: ruv
- **Tags**: agents, ruflo, ruvector, ruqu, memory, causality, temporal-embeddings, evaluation, governance
## Context
Current agent runtimes measure execution with external counters: wall-clock
duration, step count, token count, tool-call count, retry count, message order,
trace length. These are useful for billing, observability, and debugging, but
they are weak indicators of actual cognition:
- an agent can run for thirty minutes and make no meaningful progress;
- an agent can receive one tool result in one second and have its entire plan
invalidated;
- an agent can consume ten thousand tokens and become *less* certain;
- an agent can finish in four steps while accumulating hidden contradictions.
Chronological time is not enough. We need an internal notion of time for agents.
This decision is grounded in the physics of emergent/relational time — where
apparent evolution arises from correlations between a clock subsystem and the
rest of a closed system (PageWootters), where an internal clock can be defined
from entropy exchange between an observed and a hidden sector (Barontini's
cold-atom mini-universe, *Phys. Rev. Research* 2026), and where time flow can be
generated by the statistical state itself (ConnesRovelli thermal time). The
engineering claim, however, is **not** metaphysical. It is:
> Agent systems should use internal state movement as a control variable.
The four physics formalisms and the agentic clock are implemented and tested in
`crates/emergent-time` (this crate is the reference kernel for the decision
below).
## Decision
Add **Agentic Time** as a first-class runtime primitive across Ruflo, RuVector,
and RuQu. Agentic Time is the *accumulated meaningful movement through agent
state space*. A tick is emitted whenever the agent crosses a measurable state
boundary (a belief update, a contradicting retrieval, a tool result that
invalidates an assumption, a memory shift, a goal-graph change, a verification,
a governance-mode change, a plan-branch collapse, entering/exiting a loop).
Agentic Time is simultaneously a runtime signal, an evaluation signal, and a
memory-indexing signal.
> Wall-clock time answers "when did this happen?".
> Agentic Time answers "how much did the agent change?".
## Definition
For agent state `Aᵢ` at step `i`:
```
τ_a = Σ_i w_i · d(A_i, A_{i-1})
```
The distance `d` is composite over channels: belief (`ΔB`), memory (`ΔM`),
retrieval (`ΔR`), goal-graph (`ΔG`), error/contradiction (`ΔE`), plan (`ΔP`),
and (in the full model) tool surprise, verification, and risk deltas. A small
chronological step can produce a large Agentic Time tick; a large chronological
interval can produce almost none.
The **Agentic Time Index (ATI)** is progress per unit of internal change:
```
ATI = Δprogress / Δτ_a
```
High ATI = healthy execution; near-zero = stuckness; negative = accumulating
confusion. ATI drives a seven-state health classifier: **Healthy, Drifting (Exploring),
Stuck, NeedsReplan (Looping), Contradicting, Collapsing, NeedsHumanReview (Escalating)**.
## Architecture (five layers)
1. **Trace capture** — every step emits a structured `AgentState` event; the
trace stays chronological, Agentic Time is computed on top.
2. **State vectors (RuVector)** — each channel embedded into its own vector
space; default local embedder small/fast/deterministic for repeatable eval.
3. **State graph** — each run is a graph (beliefs, goals, plans, evidence,
contradictions, decisions); graph movement (community shift, mincut
weakening, new contradiction component, plan-branch collapse) contributes to
Agentic Time.
4. **Agentic Time kernel** — computes ticks (delta + class + reason +
recommended action). Implemented in `agentic_time.rs::AgenticTime`.
5. **Control (Ruflo)** — maps tick patterns to actions: continue, replan,
retrieve, verify, switch model, spawn specialist, collapse branch, ask human,
stop, archive.
### RuVector responsibilities
Embed/store state snapshots, search similar trajectories, detect drift from
success traces and movement toward failure attractors, index causal pivots,
compress low-movement intervals, preserve high-movement boundaries. New
collections: `agent_state_vectors`, `agent_trace_vectors`, `agent_goal_graphs`,
`agent_memory_graphs`, `agent_failure_attractors`, `agent_success_attractors`,
`agentic_time_ticks`, `causal_pivots`, `replay_boundaries`,
`governance_boundaries`.
### RuQu responsibilities
Treat agent state as a distribution over futures
`Ψ_a = α·success + β·failure + γ·loop + δ·escalation`; maintain plan-possibility
amplitudes, estimate collapse risk, simulate branches, score collapse events,
generate counterfactual replay candidates. v1 needs no quantum hardware — a
quantum-inspired transition model suffices. RuQu contributes to Agentic Time
when uncertainty changes meaningfully.
## Invariants (enforced/verified in the reference crate)
1. **Non-negativity** — every Agentic Time delta is `≥ 0` (ATI can go negative;
time movement cannot). *Verified: `ticks_non_negative_and_order_preserved`.*
2. **Monotonic accumulation** — total Agentic Time never decreases.
3. **Replay stability** — same trace + same model versions ⇒ same ticks
(deterministic; the synthetic generators use a seeded xorshift PRNG).
4. **Baseline dominance** — Agentic Time must beat at least one simple baseline
before controlling production execution. **This gate is currently UNMET.** On
the synthetic trace the agentic clock does **not** beat the fair
`WindowedDeltaClock` baseline, and on **real recorded traces (M3, now done)**
it does not beat it either: across the two real Claude-Code session
transcripts available for this repo the contradiction-free *honest* agentic
clock scores **0 win / 1 tie / 1 loss** vs the fair windowed baseline (see
**Honest limitations** §3 and the `real_trace_eval` example). **M3b then
implemented the exact fix M3 proposed — an adaptive-window (PageHinkley)
detector instead of the fixed `mean + kσ` baseline — applied identically to
both sides, and the verdict did NOT improve: the honest clock goes to 0 win /
0 tie / 2 loss under the adaptive detector (the adaptive detector makes the
*fair baseline* fire much earlier, while the honest agentic signal's genuine
movement still arrives only after the event).** Agentic Time is therefore
**not yet cleared to control production execution as an early-warning lead**;
its defensible value at this point is diagnostic (per-channel attribution +
health classifier), not a raw-lead win.
5. **Explainability** — every tick carries a human-readable reason and a class.
*Implemented: `AgenticTime::explain → Tick { delta, class, reason, …per-channel }`.*
## Reference implementation
`crates/emergent-time` ships the kernel and an honest benchmark:
| ADR concept | Module / symbol |
|---|---|
| Emergent-time recipe (clock ⊗ rest, condition, `d/dt → d/dτ`) | `lib.rs` crate docs |
| WheelerDeWitt timeless constraint `Ĥ\|Ψ⟩=0` | `wheeler_dewitt` |
| PageWootters relational clock (evolution from a static state) | `page_wootters` |
| Entropic time `τ_S=(SS₀)/k` (cold-atom analogue) | `entropic` |
| Thermal time `K=ln ρ`, `A(s)=e^{isK}A e^{-isK}` | `thermal` |
| Causal event time (`τ` from causal structure) | `agentic::CausalTimeline` |
| Structural Proper Time (state-manifold arc length) | `structural_clock` |
| **Agentic Time** `τ_a=f(ΔB,ΔM,ΔR,ΔG,ΔE,ΔP)` | `agentic_time::AgenticTime` |
| ATI + 7 health states | `agentic_time::{agentic_time_index, classify, AgentHealth}` |
| Explainable ticks (class + reason) | `agentic_time::{Tick, TickClass}` |
| Clock benchmark (wall/step/token/agentic) | `agentic_time::early_warning_lead` |
| **Fair baseline** (windowed z-score change-point detector) | `agentic_time::WindowedDeltaClock` |
| **M3 real-trace defensibility gate** (real Claude-Code traces, pre-registered) | `examples/real_trace_eval.rs` |
| M3 circularity guard (contradiction-free honest variant) | `agentic_time` test `contradiction_free_weights_blind_to_error_channel` |
| **M3b adaptive detector** (PageHinkley; the fix M3 proposed) | `adaptive::{PageHinkley, adaptive_alarm_step, adaptive_early_warning_lead}` |
| M3b adaptive detector wired into the real-trace gate (fixed vs adaptive) | `examples/real_trace_eval.rs` (prints both) |
Benchmark result on the bundled synthetic failing-workflow trace (a healthy
phase, then a plan-thrash onset where the plan oscillates and contradictions
climb while progress stalls, culminating in failure): the wall, step-count, and
token-count clocks give **0** early-warning lead — but, as the **Honest
limitations** section below details, this is a *coverage gap by construction*
(they emit a constant rate, so their alarm cannot fire), **not** a measured
competitive loss. Against the **fair** `WindowedDeltaClock` baseline (a
rolling-window z-score change-point detector on a single cheap scalar), the
agentic clock does **not** win on this designed trace — the fair baseline fires at
least as early. Agentic Time's ~40-step lead and the structural clock's **2.8×**
figure over the entropy clock are properties of the *constructed* trace (the lead
scales with how far the planted structural precursor precedes the failure signal),
not a head-to-head win; that win, if it exists, must be demonstrated on a real
trace (M3). **M3 is now done and the win did not materialize: on real traces the
agentic clock scores 0 win / 1 tie / 1 loss vs the fair baseline (see Honest
limitations §3). M3b tried the adaptive-detector fix M3 proposed (PageHinkley)
and it did not rescue the claim either — 0 win / 0 tie / 2 loss — so the null is
now more robust.** Compression to a fixed tolerance is currently ~1.3×; the ≥5×/
95%-retention compression target is a stretch goal pending the graph layer.
## Acceptance criteria
Adopt Agentic Time when, on `agentic_time_bench` over real Ruflo traces, it
either improves failure-prediction F1 by ≥ 25% over wall-clock features **or**
gives ≥ 2× earlier warning for failure/loop states; trace compression preserves
≥ 95% of predictive performance; and every tick has an audit-ready explanation.
Do **not** adopt if it cannot beat step count, token count, and wall clock, or
if it creates unstable control loops, or if ticks cannot be explained.
## Honest limitations
The reference crate's M1 hardening surfaced several places where rigor and
marketing had diverged. These are stated plainly so the implementation is not
over-read:
1. **WheelerDeWitt is constructive, not a discovery.** With the energy-matched
clock `H_C = diag(Eₖ)`, the constraint `Ĵ = H_C ⊗ I + I ⊗ H_R` has a kernel
*by construction* (the diagonal `a = b` pairs contribute eigenvalue `Eₖ + Eₖ
= 0`), and the PageWootters state is built term-by-term to be annihilated.
The original "kernel" tests could not fail for any input — a tautology. The
crate now (a) relabels those as *consistency checks* and (b) adds a
**discriminating emptiness test** (`generic_clock_yields_empty_physical_space`):
for a generic clock Hamiltonian whose spectrum is not `spectrum(H_R)`, `Ĵ`
has **no** eigenvalue within `1e-9` of zero — the physical Hilbert space is
empty. That emptiness, not the kernel's existence, is the falsifiable content.
2. **Entropic time here is a β-sweep, not closed-system irreversible dynamics.**
The `entropic` module sweeps the inverse temperature `β` of a Gibbs ensemble
`ρ = e^{βH}/Z` and reads off `S(β)`. It is a one-parameter equilibrium family
— an *analogue of* a cold-atom mini-universe, not a simulation of one; there is
no hidden sector exchanging entropy in real time. The flagship test was
tautological (it checked `τ = (SS₀)/k` arithmetic, true by definition). The
crate now keeps that as the *reparametrization-formula* test and adds a
discriminating one that verifies the clock rate against the **independently
measured** entropy production `dS/dβ` of the real thermal state, and that the
entropy curve is genuinely non-trivial (varying, correctly signed).
3. **The benchmark is a coverage-gap demo, not a competitive win.** On the
bundled synthetic failing-workflow trace, the wall / step / token clocks give
`0` early-warning lead — but that is because they emit a *constant* per-step
rate (zero baseline variance ⇒ a `mean + k·σ` alarm cannot fire by
construction), i.e. they are strawmen, not measured losers. The crate now adds
a **fair baseline**`WindowedDeltaClock`, a rolling-window z-score
change-point detector on a single cheap scalar (token-delta or belief-shift).
On the designed trace this fair baseline **fires at least as early as the
agentic clock** (belief-shift windowed lead ≈ 60 vs agentic ≈ 40; token-delta
windowed trips early on quantization noise). The honest conclusion: **the
agentic clock does not beat a fair baseline on synthetic data.** The 2.8×/
~40-step figures are properties of the *constructed* trace (the lead scales
with how far the structural precursor was planted ahead of the entropy/failure
signal), not a measured competitive advantage. A genuine head-to-head — where
the agentic clock's multi-channel composition is expected to win precisely when
*no single scalar* carries the signal — requires a **real trace vs the fair
windowed baseline**. That is **M3**, and it is **now done** (see below).
**M3 result (real traces, done — and it is a null/mixed, reported honestly).**
The `examples/real_trace_eval.rs` harness runs the comparison on **real
recorded agent traces**: the Claude-Code session transcripts for this repo
(`~/.claude/projects/C--Users-ruv-ruvector/*.jsonl`) — real tool-use
sequences, retries, and `is_error` events, not synthetic data. (We deliberately
did **not** use `.ruvector/intelligence.json`: its 51 "trajectories" are flat
single-event *success* records — every one `outcome = completed`, reward ∈
[0.8, 1.0], no multi-step structure, **no failure events** — so it can neither
expose the agentic channels nor define an event-to-predict; using it would have
been dishonest.)
- **Channel mapping (documented heuristic proxies, not planted signals).** Each
step (an assistant turn issuing ≥1 tool call) maps real signals to channels:
belief = TF vector over **tool types**; memory = cumulative **distinct files
touched**; retrieval = **Read/Grep/search** volume; goal-graph = arrival of a
**new user prompt**; contradiction = step **`is_error` rate**; plan = assistant
**text length + same-tool repetition** (plan thrash).
- **Event-to-predict, defined independently of the channels.** A real **error
cascade** — first step with ≥2 harness `is_error` results inside a 4-step
span. Errors come from Claude Code's `is_error` flag, **not** from any agentic
delta, so predicting it is a genuine forecast, not a tautology.
- **Circularity guard.** Because `contradiction` is derived from the same
`is_error` flag that defines the event, the **honest** variant zeroes the
contradiction weight: the clock must predict the cascade from
belief/memory/retrieval/goal/plan movement **alone**, blind to the error
signal. (Locked in by the unit test
`contradiction_free_weights_blind_to_error_channel`.)
- **Pre-registered, before any lead was computed:** baseline window = 10, alarm
= mean + 3σ, metric = early-warning lead in steps, event = ≥2 errors / 4
steps. Not tuned to the outcome.
- **Measured lead distribution (n = 2 scoreable real traces):**
| clock | trace 31cca102 (550 steps, event @37) | trace 9d1a949d (127 steps, event @29) |
|---|---|---|
| wall / step | 0 / 0 | 0 / 0 |
| token-count (constant) | 15 | 17 |
| **fair baseline** (token-delta) | 15 | 0 |
| **fair baseline** (belief-shift) | 0 (never fires) | 0 (never fires) |
| **agentic-honest** (the gate) | **0 (never fires)** | **0 (never fires)** |
| agentic-full (diagnostic, sees error) | 0 | 0 |
Per-trace verdict: **0 win / 1 tie / 1 loss** for agentic-honest vs the fair
baseline (the fair token-delta baseline wins on 31cca102 with a 15-step lead;
both tie at 0 on 9d1a949d).
- **Why the honest clock never fires — and why that is a *real* finding, not a
degenerate clock.** The honest composite signal is **alive**, not flat
(per-step increments: mean ≈ 1.5, max ≈ 4.4). It never alarms because the
agent's **early exploratory churn sets a high `mean + 3σ` bar** (≈ 4.55.4),
and later genuine movement (max ≈ 3.4 before the event) never clears it. The
fair *token-delta* baseline fires precisely because token cadence is steadier
early, giving it a tighter baseline. This is a property of real agent traces:
a multi-channel `mean + kσ` change-point alarm is *disfavoured* by early
high-variance exploration. (The harness prints this diagnostic per trace.)
**Honest conclusion.** On real traces the agentic clock **does not beat the
fair windowed baseline** (0 win / 1 tie / 1 loss). This is consistent with the
synthetic M1 finding and **does not manufacture a win**. The defensible
contribution of the primitive, on the present evidence, is as an
**explainable/diagnostic** signal — the per-channel attribution
(`Tick { class, reason, …per-channel }`) and the seven-state health classifier
**not** a raw early-warning-lead advantage. Two caveats bound even this null:
the sample is tiny (n = 2, not significance-tested), and every channel is a
documented heuristic proxy over transcript text, not an instrumented
agent-state stream (a planted signal is ruled out by the contradiction-free
variant, but proxy noise is real). A larger corpus of instrumented traces, a
detector less sensitive to early-exploration variance (e.g. ADWIN-style
adaptive windows rather than a fixed early baseline), or richer channels could
still change this verdict — but until they do, the crate claims only the
diagnostic value, honestly.
**M3b — the adaptive-detector fix M3 proposed, tried and reported honestly.**
M3 named the specific cause of the honest null (a *frozen* `mean + kσ`
baseline poisoned by early-exploration churn) and the specific fix (an
*adaptive-window* detector whose reference statistic keeps moving). M3b
implements that fix as a **PageHinkley test** (Page 1954; Hinkley 1970;
`src/adaptive.rs`): it tracks the cumulative deviation of each increment from
a **running mean** (not a frozen window), alarming when the cumulative rise
above its running minimum exceeds `λ`, with `δ` tolerating normal jitter. It
is unit-tested to fire on a real step-change and stay silent on stationary
noise, and — crucially — is applied **identically to the agentic clock AND the
fair baseline** (same `δ`, same `λ`), so any verdict change is a fair
same-detector-both-sides result, not an artifact. Parameters were
**pre-registered** (`δ = 0.15`, `λ = 5.0`, upward form) before any adaptive
lead was computed.
- **Adaptive lead distribution (same n = 2 real traces, fixed-vs-adaptive):**
| clock | 31cca102 (event @37) fixed → adaptive | 9d1a949d (event @29) fixed → adaptive |
|---|---|---|
| fair baseline (token-delta) | 15 → 15 | 0 → 27 |
| fair baseline (belief-shift) | 0 → **32** | 0 → **25** |
| **agentic-honest** (the gate) | **0 → 0** (alarm @75, after event) | **0 → 0** (alarm @49, after event) |
| agentic-full (diagnostic) | 0 → 0 | 0 → 0 |
Per-trace verdict under the adaptive detector: **0 win / 0 tie / 2 loss** for
agentic-honest vs the fair baseline (worse than the fixed-window 0/1/1,
because the adaptive detector makes the *fair baseline* fire much earlier
while the honest agentic alarm still lands only after the event).
- **What the adaptive detector did and did not do.** It *worked as designed*:
the fair belief-shift baseline, which never fired under the fixed window, now
alarms with leads of 32 and 25 — the running-mean reference is genuinely
more sensitive and no longer poisoned by early variance. But it did **not**
rescue the agentic-honest clock: on both traces the honest composite's first
adaptive alarm (steps 75 and 49) lands *after* the error cascade (steps 37
and 29), so its lead stays 0. The honest reading is that the agentic-honest
composite simply does **not carry an early precursor** for these specific
error-cascade events — the genuine multi-channel movement arrives with or
after the trouble, not before it.
**M3b honest conclusion — the fix did not rescue the claim; the null is now
more robust.** We implemented the exact remedy M3 diagnosed (an adaptive
running-reference detector instead of a frozen early baseline) and ran it
fairly on both sides. The verdict did not flip in the agentic clock's favour —
it moved against it (0/1/1 → 0/2 loss). This is a **legitimate, valuable
result**: a proposed fix tried and shown not to work removes a standing
"but maybe with a better detector…" objection. The defensible contribution of
Agentic Time remains its **explainable/diagnostic** value (per-channel
attribution + the seven-state health classifier), not a raw early-warning-lead
advantage. The same caveats bound this stronger null: n = 2 is tiny and not
significance-tested, and the channels are heuristic proxies. Had the adaptive
detector produced a fair win, the n = 2 caveat would have *demanded* a larger
pre-registered corpus before any claim — and that same larger corpus is the
only thing that could still overturn this null. Both the fixed-window and
adaptive numbers are printed side-by-side by `examples/real_trace_eval.rs`.
4. **PageWootters scope.** The construction is valid for real-symmetric
Hamiltonians; the post-conditioning normalization equals the Born-rule
partial-trace weight only for *pure* global states; and it recovers
*single-time* conditional states correctly (PageWootters 1983; Giovannetti
LloydMaccone 2015) but does **not** address Kuchař's two-time-correlation
objection (Kuchař 1992) — multi-time correlators are out of scope for v1.
### Prior art and what is actually new
The agentic-drift detection problem is closest to **concept-drift detection in
process mining**: ADWIN (Bifet & Gavaldà 2007) and, specifically for process
behavior, Ostovar et al., *"Detecting Drift from Event Streams of Unpredictable
Business Processes"* / "concept drift in process mining" (2016), which run
windowed statistical tests over event streams to flag behavioral change. The fair
`WindowedDeltaClock` baseline is squarely in that family, and on synthetic data it
is competitive — as it should be. **What is new here is not the change-point
detector** but the *physics-grounded composite framing*: treating the agent's
movement as a **state-manifold arc length** over a weighted multi-channel state
(belief/memory/retrieval/goal/contradiction/plan, plus structural proper time and
causal/relational clocks) and exposing it as a **single unified runtime primitive**
that is simultaneously a control signal, an evaluation signal, and a
memory-indexing signal. The bet (unproven until M3) is that this composite beats
single-scalar windowed detectors exactly when no individual observable carries the
precursor.
## Non-goals
Not a theory of consciousness; does not claim agents are conscious; does not
require quantum hardware; does not replace wall-clock telemetry or deterministic
execution logs; does not make safety decisions without audit records. **Do not
market this as proof that physical time is unreal** — it is a better operational
clock for autonomous systems.
## Consequences
**Benefits**: earlier failure warning, evidence-backed replanning triggers,
causal-pivot replay, movement-aware trace compression, governance-aware control,
cost reduction, richer observability (the key visual is the same trace plotted in
chronological time vs Agentic Time). **Costs**: more trace data, more embeddings,
more runtime/calibration complexity, mandatory baselines. **Risk**: the primitive
is valuable only if it beats simple baselines, so baselines are mandatory in
every evaluation.
## Rollout
Phase 1 trace instrumentation → Phase 2 RuVector embeddings → Phase 3 kernel
(done in `emergent-time`) → Phase 4 Ruflo control loop → Phase 5 RuQu uncertainty
layer → Phase 6 `agentic_time_bench` + report.
> Agents should not measure time by seconds. They should measure time by
> meaningful change.