mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-07-09 17:28:42 +00:00
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:
parent
8f2b4bd822
commit
44a836d57e
28 changed files with 7274 additions and 0 deletions
93
.github/workflows/emergent-time-ci.yml
vendored
Normal file
93
.github/workflows/emergent-time-ci.yml
vendored
Normal 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; }; }
|
||||
# Wheeler–DeWitt: the discriminating test (a generic clock yields no kernel)
|
||||
check "generic_clock_yields_empty_physical_space"
|
||||
# Page–Wootters 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
7
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
33
crates/emergent-time/Cargo.toml
Normal file
33
crates/emergent-time/Cargo.toml
Normal 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"
|
||||
64
crates/emergent-time/README.md
Normal file
64
crates/emergent-time/README.md
Normal 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` | Connes–Rovelli 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 Page–Hinkley 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 Page–Hinkley | 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
|
||||
|
||||
Wheeler–DeWitt (DeWitt 1967); Page & Wootters, "Evolution without evolution" (1983); Giovannetti–Lloyd–Maccone (2015); Connes & Rovelli, thermal time (1994); Page–Hinkley (Page 1954, Hinkley 1970); ADWIN (Bifet–Gavaldà 2007). See `docs/adr/ADR-251-agentic-time.md` for the full design record and limitations.
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0.
|
||||
83
crates/emergent-time/benches/clocks.rs
Normal file
83
crates/emergent-time/benches/clocks.rs
Normal 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);
|
||||
229
crates/emergent-time/examples/emergent_time.rs
Normal file
229
crates/emergent-time/examples/emergent_time.rs
Normal 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. Wheeler–DeWitt: 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. Page–Wootters: 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");
|
||||
}
|
||||
114
crates/emergent-time/examples/learn_weights.rs
Normal file
114
crates/emergent-time/examples/learn_weights.rs
Normal 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.");
|
||||
}
|
||||
1000
crates/emergent-time/examples/real_trace_eval.rs
Normal file
1000
crates/emergent-time/examples/real_trace_eval.rs
Normal file
File diff suppressed because it is too large
Load diff
224
crates/emergent-time/examples/train_model.rs
Normal file
224
crates/emergent-time/examples/train_model.rs
Normal 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)
|
||||
}
|
||||
11
crates/emergent-time/models/agentic_weights.witness.txt
Normal file
11
crates/emergent-time/models/agentic_weights.witness.txt
Normal 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
|
||||
461
crates/emergent-time/src/adaptive.rs
Normal file
461
crates/emergent-time/src/adaptive.rs
Normal 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
|
||||
//! **Page–Hinkley 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 Page–Hinkley 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, Page–Hinkley 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. 100–115 — 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 "Page–Hinkley" 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 Page–Hinkley 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) Page–Hinkley test.
|
||||
pub fn upward(delta: f64, lambda: f64) -> Self {
|
||||
PageHinkley {
|
||||
delta,
|
||||
lambda,
|
||||
upward: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a downward (decrease-detecting) Page–Hinkley test.
|
||||
pub fn downward(delta: f64, lambda: f64) -> Self {
|
||||
PageHinkley {
|
||||
delta,
|
||||
lambda,
|
||||
upward: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the Page–Hinkley 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 Page–Hinkley 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 Page–Hinkley 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 Page–Hinkley 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,
|
||||
"Page–Hinkley 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 Page–Hinkley 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
162
crates/emergent-time/src/agentic.rs
Normal file
162
crates/emergent-time/src/agentic.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
907
crates/emergent-time/src/agentic_time.rs
Normal file
907
crates/emergent-time/src/agentic_time.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
188
crates/emergent-time/src/complex.rs
Normal file
188
crates/emergent-time/src/complex.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
290
crates/emergent-time/src/complex_matrix.rs
Normal file
290
crates/emergent-time/src/complex_matrix.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
255
crates/emergent-time/src/entropic.rs
Normal file
255
crates/emergent-time/src/entropic.rs
Normal 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/dλ) = {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");
|
||||
}
|
||||
}
|
||||
138
crates/emergent-time/src/entropy.rs
Normal file
138
crates/emergent-time/src/entropy.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
117
crates/emergent-time/src/lib.rs
Normal file
117
crates/emergent-time/src/lib.rs
Normal 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`] — Connes–Rovelli 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() {
|
||||
// Wheeler–DeWitt kernel state == Page–Wootters 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);
|
||||
}
|
||||
}
|
||||
243
crates/emergent-time/src/page_wootters.rs
Normal file
243
crates/emergent-time/src/page_wootters.rs
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
//! Page–Wootters 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 Wheeler–DeWitt 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
352
crates/emergent-time/src/real_matrix.rs
Normal file
352
crates/emergent-time/src/real_matrix.rs
Normal 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
104
crates/emergent-time/src/state.rs
Normal file
104
crates/emergent-time/src/state.rs
Normal 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 Page–Wootters 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());
|
||||
}
|
||||
}
|
||||
563
crates/emergent-time/src/structural_clock.rs
Normal file
563
crates/emergent-time/src/structural_clock.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
118
crates/emergent-time/src/thermal.rs
Normal file
118
crates/emergent-time/src/thermal.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
//! Connes–Rovelli 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);
|
||||
}
|
||||
}
|
||||
579
crates/emergent-time/src/weight_learning.rs
Normal file
579
crates/emergent-time/src/weight_learning.rs
Normal 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 Box–Muller.
|
||||
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 (Mann–Whitney). 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
214
crates/emergent-time/src/wheeler_dewitt.rs
Normal file
214
crates/emergent-time/src/wheeler_dewitt.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
//! Wheeler–DeWitt 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 Page–Wootters 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 Page–Wootters 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
310
crates/emergent-time/src/witness.rs
Normal file
310
crates/emergent-time/src/witness.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
412
docs/adr/ADR-251-agentic-time.md
Normal file
412
docs/adr/ADR-251-agentic-time.md
Normal 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 (Page–Wootters), 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 (Connes–Rovelli 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 (Page–Hinkley)
|
||||
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 |
|
||||
| Wheeler–DeWitt timeless constraint `Ĥ\|Ψ⟩=0` | `wheeler_dewitt` |
|
||||
| Page–Wootters relational clock (evolution from a static state) | `page_wootters` |
|
||||
| Entropic time `τ_S=(S−S₀)/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** (Page–Hinkley; 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 (Page–Hinkley)
|
||||
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. **Wheeler–DeWitt 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 Page–Wootters 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 `τ = (S−S₀)/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.5–5.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 **Page–Hinkley 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. **Page–Wootters 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 (Page–Wootters 1983; Giovannetti–
|
||||
Lloyd–Maccone 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue