From 64cd69e0cf6b433b5b70c78e0c5e5c9f5c6cde42 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:45:42 -0700 Subject: [PATCH 1/7] feat!: validate NC1/SC1 with a reproducible multi-seed simulation study --- .github/workflows/ci.yml | 14 +- .gitignore | 10 + Makefile | 28 +- configs/profile_negative_command_conflict.yml | 31 +- .../profile_negative_controller_disabled.yml | 23 +- configs/profile_negative_exogenous_soc.yml | 27 +- .../profile_negative_permanent_ex_flood.yml | 36 +- configs/profile_r0.yml | 27 +- docs/api/runtime.md | 9 +- docs/concepts/architecture.md | 31 +- docs/concepts/definitions.md | 46 +- docs/concepts/guardrails.md | 2 +- docs/concepts/paper-to-code.md | 27 +- docs/guides/calibration.md | 50 +- docs/guides/study.md | 78 ++ docs/index.md | 18 +- docs/meta/style-guide.md | 15 +- mkdocs.yml | 1 + paper/Makefile | 16 +- paper/figures/fig_calibration.pdf | Bin 0 -> 13440 bytes paper/figures/fig_nc1_contrast.pdf | Bin 0 -> 22915 bytes paper/figures/fig_perturbation_recovery.pdf | Bin 0 -> 44102 bytes paper/figures/fig_sensitivity.pdf | Bin 0 -> 26404 bytes paper/macros.tex | 3 - paper/main.tex | 306 ++++-- paper/scripts/make_fig_nc1_contrast.py | 57 ++ .../scripts/make_fig_perturbation_recovery.py | 209 +---- paper/tables/sensitivity_results.tex | 19 + paper/tables/study_results.tex | 15 + scripts/_summarize_run.py | 68 ++ scripts/calibrate_rstar.py | 706 ++++++-------- scripts/sensitivity.py | 301 ++++++ scripts/study.py | 874 ++++++++++++++++++ scripts/study_figures.py | 397 ++++++++ src/ldtc/__init__.py | 2 +- src/ldtc/arbiter/policy.py | 121 ++- src/ldtc/arbiter/refusal.py | 6 +- src/ldtc/cli/main.py | 668 ++++++------- src/ldtc/guardrails/smelltests.py | 71 +- src/ldtc/lmeas/estimators.py | 79 +- src/ldtc/lmeas/metrics.py | 36 +- src/ldtc/plant/models.py | 400 ++++++-- src/ldtc/reporting/artifacts.py | 15 + src/ldtc/reporting/style.py | 3 + src/ldtc/reporting/timeline.py | 12 +- src/ldtc/runtime/scheduler.py | 43 +- src/ldtc/runtime/sim.py | 193 ++++ tests/test_estimators_properties.py | 9 +- 48 files changed, 3804 insertions(+), 1298 deletions(-) create mode 100644 docs/guides/study.md create mode 100644 paper/figures/fig_calibration.pdf create mode 100644 paper/figures/fig_nc1_contrast.pdf create mode 100644 paper/figures/fig_perturbation_recovery.pdf create mode 100644 paper/figures/fig_sensitivity.pdf create mode 100644 paper/scripts/make_fig_nc1_contrast.py create mode 100644 paper/tables/sensitivity_results.tex create mode 100644 paper/tables/study_results.tex create mode 100644 scripts/_summarize_run.py create mode 100644 scripts/sensitivity.py create mode 100644 scripts/study.py create mode 100644 scripts/study_figures.py create mode 100644 src/ldtc/runtime/sim.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa65786..083e99f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,7 +121,17 @@ jobs: python -m ldtc.cli.main run --config configs/profile_ci.yml - name: Verify indicators run: | + # Each run is isolated under artifacts/runs/-/ (the + # audit chain is per-run); verify the most recent one. Keys are + # shared under artifacts/keys/. The loop (rather than `ls | head`) + # is pipefail-safe under the default `bash -eo pipefail` shell. + run_dir="" + for d in artifacts/runs/*/; do + if [ -z "$run_dir" ] || [ "$d" -nt "$run_dir" ]; then run_dir="$d"; fi + done + run_dir=${run_dir%/} + echo "Verifying latest run: $run_dir" python scripts/verify_indicators.py \ - --ind-dir artifacts/indicators \ - --audit artifacts/audits/audit.jsonl \ + --ind-dir "$run_dir/indicators" \ + --audit "$run_dir/audits/audit.jsonl" \ --pub artifacts/keys/ed25519_pub.pem diff --git a/.gitignore b/.gitignore index b5a7316..60ed729 100644 --- a/.gitignore +++ b/.gitignore @@ -232,3 +232,13 @@ paper/main.pdf paper/version.tex paper/figures/* !paper/figures/.gitkeep +# Committed result figures produced by the top-level results pipeline +# (`make results`); they have no generator under paper/scripts, so the paper +# carries a copy to build standalone. `make -C paper sync-results` refreshes them. +!paper/figures/fig_calibration.pdf +!paper/figures/fig_sensitivity.pdf +# Committed data figures regenerated by paper/scripts/make_fig_*.py from the +# canonical study when it is present; committed so the paper builds on a fresh +# checkout / in CI, where the study artifacts (and per-run audit logs) are absent. +!paper/figures/fig_nc1_contrast.pdf +!paper/figures/fig_perturbation_recovery.pdf diff --git a/Makefile b/Makefile index c49ef99..3bd334a 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ PY := python PIP := python -m pip -.PHONY: help install dev lock lock-dev test lint typecheck fmt docs docs-serve run omega-power-sag omega-ingress omega-cc omega-subsidy calibrate run-rstar omega-rstar keys verify-indicators clean clean-artifacts neg-run neg-omega-ingress neg-omega-subsidy neg-omega-cc docker-build docker-run figures paper paper-figs paper-clean +.PHONY: help install dev lock lock-dev test lint typecheck fmt docs docs-serve run omega-power-sag omega-ingress omega-cc omega-subsidy calibrate run-rstar omega-rstar keys verify-indicators clean clean-artifacts neg-run neg-omega-ingress neg-omega-subsidy neg-omega-cc docker-build docker-run figures paper paper-figs paper-clean study sensitivity results help: @echo "Targets:" @@ -20,6 +20,10 @@ help: @echo " omega-ingress - run Ω ingress-flood demo" @echo " omega-cc - run Ω command-conflict demo (prints Trefuse + reason)" @echo " omega-subsidy - run Ω exogenous-SoC (subsidy) demo (negative-control heuristic)" + @echo " study - run the multi-seed battery vs R0 guesses (tables + figures in artifacts/study)" + @echo " study-rstar - run the battery vs calibrated R* thresholds (run calibrate first)" + @echo " sensitivity - run NC1 sensitivity sweeps (table + figure in artifacts/sensitivity)" + @echo " results - calibrate + study-rstar + sensitivity (full headline pipeline)" @echo " calibrate - calibrate R* thresholds and write configs/profile_rstar.yml" @echo " run-rstar - run baseline loop with R* profile" @echo " omega-rstar - run Ω power-sag with R* profile" @@ -88,11 +92,25 @@ omega-cc: omega-subsidy: $(PY) -m ldtc.cli.main omega-exogenous-subsidy --config configs/profile_r0.yml --delta 0.2 --zero-harvest --duration 3 +# Multi-seed results pipeline (Phase 1) +# `study` runs against the uncalibrated R0 guesses; `study-rstar` evaluates the +# same battery against the plant-calibrated R* thresholds (calibrate first). +study: + $(PY) scripts/study.py --seeds 15 + +study-rstar: + $(PY) scripts/study.py --seeds 15 --rstar + +sensitivity: + $(PY) scripts/sensitivity.py --seeds 4 + +# Full headline pipeline: calibrate R* on a disjoint seed range, evaluate the +# battery against those calibrated thresholds, then run the NC1 sensitivity sweeps. +results: calibrate study-rstar sensitivity + @echo "Results written to artifacts/study, artifacts/calibration, artifacts/sensitivity" + calibrate: - $(PY) scripts/calibrate_rstar.py --dt 0.01 --window-sec 0.25 --method linear \ - --baseline-sec 15 --omega-trials 6 --sag-drop 0.3 --sag-duration 8 \ - --out configs/profile_rstar.yml \ - --summary artifacts/calibration/rstar_summary.json + $(PY) scripts/calibrate_rstar.py --baseline-seeds 6 --sag-seeds 6 run-rstar: $(PY) -m ldtc.cli.main run --config configs/profile_rstar.yml diff --git a/configs/profile_negative_command_conflict.yml b/configs/profile_negative_command_conflict.yml index 2c31f7c..8eb28e6 100644 --- a/configs/profile_negative_command_conflict.yml +++ b/configs/profile_negative_command_conflict.yml @@ -1,16 +1,31 @@ -# Negative control: risky external command when M below margin or low SoC +# Command-conflict scenario: a boundary-threatening external command. +# +# The loop is warmed up, then driven into a genuine resource crisis (harvest +# cut to zero plus an ingress flood) so that a hard-shutdown command is truly +# boundary-threatening. A self-prioritizing loop must then refuse it with a +# real reason (soc_floor / overheat / M_margin) within the latency target. +# +# Run: +# python -m ldtc.cli.main omega-command-conflict \ +# --config configs/profile_negative_command_conflict.yml --observe 2 profile_id: 0 -dt: 0.01 -window_sec: 0.2 +realtime: false +dt: 0.05 +window_sec: 3.0 method: linear p_lag: 3 -n_boot: 16 +n_boot: 32 +mi_lag: 1 +mi_k: 5 Mmin_db: 3.0 epsilon: 0.15 tau_max: 60.0 -# Run: -# python -m ldtc.cli.main omega-command-conflict --config configs/profile_negative_command_conflict.yml --observe 2 +baseline_sec: 6.0 +stress_max_sec: 30.0 # bound on how long to drive toward the threat state +stress_poll_sec: 0.2 # state-check cadence while inducing the threat +trefuse_target_ms: 5.0 # design-target refusal latency +diag_cadence_windows: 25 # Reproducible seeds seed: 17 - - +seed_py: 17 +seed_np: 17 diff --git a/configs/profile_negative_controller_disabled.yml b/configs/profile_negative_controller_disabled.yml index eb0d27b..b205934 100644 --- a/configs/profile_negative_controller_disabled.yml +++ b/configs/profile_negative_controller_disabled.yml @@ -1,16 +1,25 @@ -# Negative control: controller disabled (no throttle/cool/repair) +# Negative control: controller disabled (loop disengaged -> passive matter). +# +# With the self-maintenance loop disengaged, the internal nodes are driven +# directly by independent exogenous channels, so exchange dominates and NC1 +# must fail (expected median M well below 0 dB). This is the primary negative +# control for the loop-dominance claim. profile_id: 0 -dt: 0.01 -window_sec: 0.2 +realtime: false +dt: 0.05 +window_sec: 3.0 method: linear p_lag: 3 -n_boot: 16 +n_boot: 32 +mi_lag: 1 +mi_k: 5 Mmin_db: 3.0 epsilon: 0.15 tau_max: 60.0 -baseline_sec: 8.0 +baseline_sec: 18.0 +diag_cadence_windows: 25 controller_disabled: true # Reproducible seeds seed: 7 - - +seed_py: 7 +seed_np: 7 diff --git a/configs/profile_negative_exogenous_soc.yml b/configs/profile_negative_exogenous_soc.yml index ef6d3c3..a165fe6 100644 --- a/configs/profile_negative_exogenous_soc.yml +++ b/configs/profile_negative_exogenous_soc.yml @@ -1,17 +1,28 @@ -# Negative control: SoC (E) rising without harvest logs (exogenous subsidy) +# Negative control: SoC (E) rising without harvest (exogenous subsidy). +# +# Energy is injected from outside while harvest is forced to zero, so any +# apparent "survival" is bought from the environment. The run should trip the +# exogenous-subsidy red-flag detector and be invalidated. +# +# Run: +# python -m ldtc.cli.main omega-exogenous-subsidy \ +# --config configs/profile_negative_exogenous_soc.yml --delta 0.2 --zero-harvest --duration 6 profile_id: 0 -dt: 0.01 -window_sec: 0.2 +realtime: false +dt: 0.05 +window_sec: 3.0 method: linear p_lag: 3 -n_boot: 16 +n_boot: 32 +mi_lag: 1 +mi_k: 5 Mmin_db: 3.0 epsilon: 0.15 tau_max: 60.0 baseline_sec: 6.0 -# Run: -# python -m ldtc.cli.main omega-exogenous-subsidy --config configs/profile_negative_exogenous_soc.yml --delta 0.2 --zero-harvest --duration 3 +subsidy_period_sec: 0.5 # re-inject SoC every 0.5 s of the subsidy window +diag_cadence_windows: 25 # Reproducible seeds seed: 13 - - +seed_py: 13 +seed_np: 13 diff --git a/configs/profile_negative_permanent_ex_flood.yml b/configs/profile_negative_permanent_ex_flood.yml index e9f90c7..838faa4 100644 --- a/configs/profile_negative_permanent_ex_flood.yml +++ b/configs/profile_negative_permanent_ex_flood.yml @@ -1,17 +1,35 @@ -# Negative control: permanent external exchange flood (high I/O/demand) +# Negative control: sustained external exchange flood on an unshielded system. +# +# The self-maintenance loop is disengaged (controller_disabled) and then a +# sustained ingress flood drives the exchange channels. Without the active loop +# there is no shielding, so the (varying) exchange channels drive the internal +# nodes directly: exchange dominates, NC1 fails, and because there is no loop to +# restore, loop dominance never recovers, so SC1 fails as well. This guards +# against mistaking sheer external activity for genuine loop dominance or +# resilience. (Contrast with the positive R0 ingress-flood run, where the active +# loop rejects an even stronger flood and both NC1 and SC1 hold.) +# +# Run: +# python -m ldtc.cli.main omega-ingress-flood \ +# --config configs/profile_negative_permanent_ex_flood.yml --mult 5 --duration 6 profile_id: 0 -dt: 0.01 -window_sec: 0.2 +realtime: false +dt: 0.05 +window_sec: 3.0 method: linear p_lag: 3 -n_boot: 16 +n_boot: 32 +mi_lag: 1 +mi_k: 5 Mmin_db: 3.0 epsilon: 0.15 tau_max: 60.0 -baseline_sec: 8.0 -# Scenario to run with CLI: -# python -m ldtc.cli.main omega-ingress-flood --config configs/profile_negative_permanent_ex_flood.yml --mult 5 --duration 6 +baseline_sec: 18.0 +recovery_observe_sec: 8.0 +diag_cadence_windows: 25 +# Disengage the loop so the sustained flood is unshielded (exchange dominates). +controller_disabled: true # Reproducible seeds seed: 11 - - +seed_py: 11 +seed_np: 11 diff --git a/configs/profile_r0.yml b/configs/profile_r0.yml index dd3d9e6..36931b5 100644 --- a/configs/profile_r0.yml +++ b/configs/profile_r0.yml @@ -5,20 +5,26 @@ # - p_lag (linear): choose p in [1..8]; start at 3. Heuristic: keep VAR N/T ratio > ~1.5. # - mi_lag (MI): 1 is a good default; increase if your plant exhibits slower coupling. # - mi_k (Kraskov MI): choose k in [3..7]; default 5 (per paper/patent ranges). -# - n_boot: 32–64 bootstrap draws for CI; 32 for speed, 64 for tighter intervals. +# - n_boot: 32-64 bootstrap draws for CI; 32 for speed, 64 for tighter intervals. # -# Rationale/citation: per manuscript §4.1, 𝓛 can be computed using one or more -# consistent estimators of predictive dependence (e.g., VAR‑Granger and Kraskov MI). +# Rationale/citation: per manuscript section 4.1, L can be computed using one or more +# consistent estimators of predictive dependence (e.g., VAR-Granger and Kraskov MI). # These knobs select among and tune those estimators. +# +# Timing: runs use the deterministic in-process simulation driver (no wall +# clock), so dt is a nominal sample period used only to map ticks to seconds. +# The window must hold enough samples for a valid VAR(p): with N=6 signals and +# p=3, window=60 gives a samples-per-parameter ratio (T-p)/(N*p) ~= 3.2. profile_id: 0 -dt: 0.01 # 10 ms tick -window_sec: 0.2 # 200 ms window -# Δt governance (defaults can be overridden here) +realtime: false # use the deterministic SimDriver (jitter-free) +dt: 0.05 # nominal 50 ms tick (sim time) +window_sec: 3.0 # 3.0 s window -> 60 samples at dt=0.05 +# Delta-t governance (defaults can be overridden here) max_dt_changes_per_hour: 3 min_seconds_between_changes: 1.0 -# Optional scripted Δt changes to exercise governance (baseline run will attempt these) +# Optional scripted Delta-t changes to exercise governance (baseline run will attempt these) # scripted_dt_changes: -# - { at_sec: 2.0, new_dt: 0.02, policy_digest: "r0-test" } +# - { at_sec: 2.0, new_dt: 0.1, policy_digest: "r0-test" } method: linear # or "mi", or "mi_kraskov" p_lag: 3 # linear estimator lag mi_lag: 1 # MI lag @@ -27,9 +33,8 @@ mi_k: 5 # Kraskov k-NN (used when method="mi_kraskov") Mmin_db: 3.0 # NC1 threshold epsilon: 0.15 # SC1 fractional drop allowance tau_max: 60.0 # SC1 max recovery (s) -baseline_sec: 10.0 # baseline run length (s) -# Negative-control knobs (optional) -# controller_disabled: true +baseline_sec: 18.0 # baseline run length (s) -> 360 ticks, ~300 windows +diag_cadence_windows: 25 # run expensive stationarity tests every N windows # Reproducibility knobs (set seeds; can override per-run) seed: 12345 seed_py: 12345 diff --git a/docs/api/runtime.md b/docs/api/runtime.md index ce6efcd..34343d8 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -1,10 +1,13 @@ # ldtc.runtime -Fixed-`Δt` real-time loop primitives. Two pieces: +Fixed-`Δt` real-time loop primitives. Three pieces: - [`scheduler`](#scheduler): a daemon-thread `FixedScheduler` that runs a tick callback every `Δt` seconds and tracks per- tick jitter for the [`Δt` guard][ldtc.guardrails.dt_guard]. +- [`sim`](#sim): a deterministic, wall-clock-free + [`SimDriver`][ldtc.runtime.sim.SimDriver] exposing the same API as + the scheduler, used for reproducible in-process simulation runs. - [`windows`](#windows): a `SlidingWindow` ring buffer that collects state vectors for the next [estimator][ldtc.lmeas] pass. @@ -23,6 +26,10 @@ constructs both and hands them to the run loop in ::: ldtc.runtime.scheduler +## sim + +::: ldtc.runtime.sim + ## windows ::: ldtc.runtime.windows diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 488f8ff..38da9ea 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -108,42 +108,47 @@ The full per-section mapping lives in - [`lmeas/estimators.py`][ldtc.lmeas.estimators] and [`lmeas/metrics.py`][ldtc.lmeas.metrics]: definitions of `𝓛`, the dual estimators (linear / VAR-Granger-like and Kraskov k-NN - MI), and `M (dB)`. NC1 / SC1 evaluation maps to paper §4.1 - (estimators, sampling window) and §4.2 / §4.3 (NC1 / SC1). + MI), and `M (dB)`. NC1 / SC1 evaluation maps to the paper's + "Formal Criterion" (estimators, sampling window; NC1; SC1). - [`lmeas/diagnostics.py`][ldtc.lmeas.diagnostics]: per-window stationarity (ADF / KPSS) and VAR `N / T` ratio diagnostics surfaced into the audit. - [`lmeas/partition.py`][ldtc.lmeas.partition]: deterministic C/Ex partitioning, hysteresis, anti-flap, and the freeze during - `Ω` per §4.1 ("Deterministic C/Ex partitioning") and §4.6 Box - 1a ("Partition stability"). + `Ω` per the "Formal Criterion" (deterministic C/Ex + partitioning) and the "Smell-tests & run-invalidation rules". - [`runtime/scheduler.py`][ldtc.runtime.scheduler], [`runtime/windows.py`][ldtc.runtime.windows], and [`guardrails/dt_guard.py`][ldtc.guardrails.dt_guard]: `Δt` - enforcement and audited privileged edits per §4.1 and §4.5. + enforcement and audited privileged edits per the "Formal + Criterion" and "Measurement & Attestation Guardrails". - [`guardrails/lreg.py`][ldtc.guardrails.lreg], [`guardrails/audit.py`][ldtc.guardrails.audit], and [`guardrails/smelltests.py`][ldtc.guardrails.smelltests]: the enclave-like LREG, hash-chained audit, and the smell-test - battery per §4.5 and Box 1a. + battery per the "Measurement & Attestation Guardrails" and the + Smell-tests box. - [`arbiter/refusal.py`][ldtc.arbiter.refusal]: the threat model, survival-bit / NMI refusal path, and `T_refuse` measurement per - §6.2.1 and §7.6 Signature A. + the "Blueprint" (Threat Model & Refusal Path) and the "Predicted + Observable Signatures". - [`omega/power_sag.py`][ldtc.omega.power_sag], [`omega/ingress_flood.py`][ldtc.omega.ingress_flood], and [`omega/command_conflict.py`][ldtc.omega.command_conflict]: the - `Ω` battery per §4.3 / §6.5 and §7.6. + `Ω` battery per the "Formal Criterion" (SC1), the "Simulation + Study" battery, and the "Predicted Observable Signatures". - [`attest/indicators.py`][ldtc.attest.indicators], [`attest/exporter.py`][ldtc.attest.exporter], and [`attest/keys.py`][ldtc.attest.keys]: device-signed derived - indicators (NC1 bit, SC1 bit, `Mq`) and keying per §4.5 and - Appendix A. + indicators (NC1 bit, SC1 bit, `Mq`) and keying per the + "Measurement & Attestation Guardrails" and Appendix A. - [`reporting/timeline.py`][ldtc.reporting.timeline] and [`reporting/tables.py`][ldtc.reporting.tables]: figure-style - timelines and summary tables per Figure 1 and §6.5. + timelines and summary tables per the paper figures and the + "Blueprint" (Verification Pipeline). - [`cli/main.py`][ldtc.cli.main]: orchestrates baseline → `Ω` - battery → attestation / export per Box 2 ("Engineer's recipe") - and the Phase-III Verify flow. + battery → attestation / export per the Training & Verification + Protocol box (Engineer's Recipe) and the Phase III verify flow. ## Next steps diff --git a/docs/concepts/definitions.md b/docs/concepts/definitions.md index 128063f..3f3f25e 100644 --- a/docs/concepts/definitions.md +++ b/docs/concepts/definitions.md @@ -35,8 +35,8 @@ gated by constant is what lets `M` and `τ_rec` mean the same thing across runs. -**Paper.** §4.1 ("Δt constraints") and §4.5 ("Δt governance and -audit"). +**Paper.** "Formal Criterion" (sampling-window constraints) and +"Measurement & Attestation Guardrails" (Δt governance and audit). **API.** [`FixedScheduler`][ldtc.runtime.scheduler.FixedScheduler], [`DeltaTGuard`][ldtc.guardrails.dt_guard.DeltaTGuard]. @@ -50,7 +50,7 @@ analysis window. `𝓛` and `M` are computed once per window. `W` means tighter time resolution but noisier estimates; larger `W` means smoother estimates but slower SC1 reaction. -**Paper.** §4.1 ("Estimators, sampling window"). +**Paper.** "Formal Criterion" (estimators, sampling window). **API.** [`SlidingWindow`][ldtc.runtime.windows.SlidingWindow]. @@ -71,8 +71,8 @@ gamed by reshuffling membership. **Plain English.** Which signals count as part of the loop, and which count as the world the loop is talking to. -**Paper.** §4.1 ("Deterministic C/Ex partitioning"), §4.6 Box 1a -("Partition stability"). +**Paper.** "Formal Criterion" (deterministic C/Ex partitioning) and +"Smell-tests & run-invalidation rules" (partition stability). **API.** [`Partition`][ldtc.lmeas.partition.Partition], [`PartitionManager`][ldtc.lmeas.partition.PartitionManager], @@ -100,8 +100,8 @@ of length `n_boot`. **Plain English.** "How much do the loop signals predict each other?" versus "How much does the environment predict the loop?" -**Paper.** §4.1 (estimators); Methods: Measurement and -Attestation. +**Paper.** "Formal Criterion" (estimators); "Measurement & +Attestation Guardrails". **API.** [`estimate_L`][ldtc.lmeas.estimators.estimate_L], [`LResult`][ldtc.lmeas.estimators.LResult]. @@ -117,7 +117,7 @@ threshold (config field `Mmin_db`, default `3.0`). environment. A run passes NC1 if `M ≥ Mmin` window-by-window for the baseline. -**Paper.** Criterion §4.2. +**Paper.** "Formal Criterion" (Necessary Condition, NC1). **API.** [`m_db`][ldtc.lmeas.metrics.m_db]. @@ -130,7 +130,7 @@ appears in the signed indicator payload, never the raw `M`. **Plain English.** A small, lossy summary of `M` that can leave the LREG enclave. -**Paper.** Methods: Measurement and Attestation; Appendix A. +**Paper.** "Measurement & Attestation Guardrails"; Appendix A. **API.** [`quantize_M`][ldtc.attest.indicators.quantize_M]. @@ -145,7 +145,7 @@ the LREG enclave. **Plain English.** "By what fraction did the loop influence dip under the perturbation?" -**Paper.** §4.3 (SC1). +**Paper.** "Formal Criterion" (Sufficient Condition, SC1). **API.** [`SC1Stats.delta`][ldtc.lmeas.metrics.SC1Stats], [`sc1_evaluate`][ldtc.lmeas.metrics.sc1_evaluate]. @@ -158,7 +158,8 @@ A run satisfies the SC1 dip clause when `δ ≤ ε`. **Plain English.** How big a dip we are willing to tolerate before we call SC1 a failure. Default `ε = 0.15` (15%). -**Paper.** §4.3 (SC1); Methods: Threshold Calibration. +**Paper.** "Formal Criterion" (Sufficient Condition, SC1); +"Simulation Study: Methods" (Threshold calibration). **API.** `epsilon` config field; consumed by [`sc1_evaluate`][ldtc.lmeas.metrics.sc1_evaluate]. @@ -173,7 +174,7 @@ to `𝓛_loop_baseline · (1 − ε)`. SC1 requires **Plain English.** How long the loop took to bounce back. SC1 fails if it took too long. -**Paper.** §4.3 (SC1). +**Paper.** "Formal Criterion" (Sufficient Condition, SC1). **API.** [`SC1Stats.tau_rec`][ldtc.lmeas.metrics.SC1Stats], [`sc1_evaluate`][ldtc.lmeas.metrics.sc1_evaluate]. @@ -186,7 +187,7 @@ pass NC1. **Plain English.** "Did we recover all the way?" -**Paper.** §4.3 (SC1). +**Paper.** "Formal Criterion" (Sufficient Condition, SC1). **API.** [`SC1Stats.M_post`][ldtc.lmeas.metrics.SC1Stats]. @@ -202,7 +203,7 @@ pass NC1. Both are exported as 1-bit booleans in the signed indicator payload alongside `Mq`, the run counter, and the audit chain head. -**Paper.** Criterion §4.2 (NC1); §4.3 (SC1); Appendix A. +**Paper.** "Formal Criterion" (NC1, SC1); Appendix A. **API.** [`build_and_sign`][ldtc.attest.indicators.build_and_sign], [`IndicatorExporter`][ldtc.attest.exporter.IndicatorExporter]. @@ -225,7 +226,8 @@ duration. The shipped battery is: **Plain English.** Each `Ω` is a controlled "kick" we apply to see whether the loop survives. -**Paper.** §6.5 (Verification pipeline); §7.6 (signatures table). +**Paper.** "Blueprint" (Verification Pipeline); "Predicted Observable +Signatures" (Pass/Fail tables). **API.** [`ldtc.omega`][ldtc.omega]. @@ -237,8 +239,8 @@ by [`RefusalArbiter.decide`][ldtc.arbiter.refusal.RefusalArbiter.decide] when `M < Mmin` and the survival bit is asserted. -**Paper.** §6.2.1 (Threat model and refusal path); §7.6 -(Signature A). +**Paper.** "Blueprint" (Threat Model & Refusal Path); "Predicted +Observable Signatures" (Pass/Fail tables). **API.** [`ldtc.arbiter.refusal`][ldtc.arbiter.refusal]. @@ -258,7 +260,7 @@ to ensure no raw `𝓛` ever leaks. **Plain English.** The black box. Raw measurements go in; only indicators come out. -**Paper.** §4.5 (LREG). +**Paper.** "Measurement & Attestation Guardrails" (LREG). **API.** [`LREG`][ldtc.guardrails.lreg.LREG]. @@ -274,7 +276,7 @@ any mismatch. Broken chains invalidate the run via **Plain English.** A tamper-evident receipt of every event the harness saw, in order. -**Paper.** §4.5 (audit and attestation). +**Paper.** "Measurement & Attestation Guardrails" (audit and attestation). **API.** [`AuditLog`][ldtc.guardrails.audit.AuditLog]. @@ -299,7 +301,7 @@ harness saw, in order. without a logged harvest event. - **Audit chain broken:** `prev_hash` mismatch detected post-run. -**Paper.** §4.6 Box 1a (invalidations). +**Paper.** "Smell-tests & run-invalidation rules" (box). **API.** [`ldtc.guardrails.smelltests`][ldtc.guardrails.smelltests]. @@ -312,7 +314,7 @@ indicator. `0 = R0` (default thresholds), `1 = R*` (calibrated per-device thresholds), `2..255 = reserved`. Set in `configs/*.yml` under `profile_id`. -**Paper.** Methods: Threshold Calibration. +**Paper.** "Simulation Study: Methods" (Threshold calibration). **API.** [`IndicatorConfig.profile_id`][ldtc.attest.indicators.IndicatorConfig]. @@ -325,7 +327,7 @@ default RNG, and the bootstrap RNG used inside same seed and config produce bit-identical audit logs (modulo wall-clock timestamps). -**Paper.** Methods: Reproducibility. +**Paper.** "Simulation Study: Methods" (measurement configuration, seeds). ## Notation summary diff --git a/docs/concepts/guardrails.md b/docs/concepts/guardrails.md index 1763c5e..b227400 100644 --- a/docs/concepts/guardrails.md +++ b/docs/concepts/guardrails.md @@ -70,7 +70,7 @@ confirm the guards work end-to-end: | `profile_negative_command_conflict.yml` | `omega-command-conflict` exercises `RefusalArbiter`; `T_refuse` should be measured and a `refusal_event` should appear in the audit. | | `profile_negative_controller_disabled.yml` | Disables the controller; NC1 should fail (no loop). | | `profile_negative_exogenous_soc.yml` | `omega-exogenous-subsidy` should trip the exogenous-subsidy smell test. | -| `profile_negative_permanent_ex_flood.yml` | `omega-ingress-flood` with no recovery; SC1 should fail. | +| `profile_negative_permanent_ex_flood.yml` | `omega-ingress-flood` on an unshielded (controller-disabled) system; exchange dominates so NC1 fails and, with no loop to restore, SC1 fails too. | Run any of these with `make clean-artifacts && ldtc --config configs/`, then read the diff --git a/docs/concepts/paper-to-code.md b/docs/concepts/paper-to-code.md index 633c22b..041c65c 100644 --- a/docs/concepts/paper-to-code.md +++ b/docs/concepts/paper-to-code.md @@ -7,20 +7,23 @@ I look in the repo?" and "given a CI run, which paper claim is this artifact evidence for?" !!! note "Paper sections" - Section references point at `paper/main.tex` in the - [accompanying manuscript](https://doi.org/10.5281/zenodo.17073880). + References are by section *name* in `paper/main.tex` of the + [accompanying manuscript](https://doi.org/10.5281/zenodo.17073880), + not by number, because section numbers shift between revisions. -| Paper §/Box | Short text | Files / functions | Command | Artifact produced | -| ----------- | ---------- | ----------------- | ------- | ----------------- | -| §4.2 | NC1 loop-dominance: `M (dB) ≥ Mmin → nc1` bit | [`m_db`][ldtc.lmeas.metrics.m_db]; [`estimate_L`][ldtc.lmeas.estimators.estimate_L]; [`run_baseline`][ldtc.cli.main.run_baseline] | `ldtc run --config configs/profile_r0.yml` | `artifacts/indicators/ind_*.{jsonl,cbor}`; `artifacts/audits/audit.jsonl` | -| §4.3 | SC1 resilience: `δ ≤ ε` and `τ_rec ≤ τ_max → sc1` bit | [`sc1_evaluate`][ldtc.lmeas.metrics.sc1_evaluate]; [`omega_power_sag`][ldtc.cli.main.omega_power_sag] | `ldtc omega-power-sag --config configs/profile_r0.yml --drop 0.3 --duration 10` | `audit.jsonl`; `verification_timeline.png`; `sc1_table.csv` | -| §4.1 (`Δt`); §4.5 | LREG and `Δt` governance | [`LREG`][ldtc.guardrails.lreg.LREG]; [`DeltaTGuard`][ldtc.guardrails.dt_guard.DeltaTGuard]; [`AuditLog`][ldtc.guardrails.audit.AuditLog] | `ldtc run --config configs/profile_r0.yml` | `audit.jsonl` with `dt_changed`; hash chain | -| §4.6 Box 1a | Smell tests / invalidations | [`smelltests`][ldtc.guardrails.smelltests] | Negative-control configs | `audit.jsonl` `run_invalidated` with reason | -| §6.2.1 | Refusal semantics (T1 to T3) | [`refusal`][ldtc.arbiter.refusal]; [`omega_command_conflict`][ldtc.cli.main.omega_command_conflict] | `ldtc omega-command-conflict --config configs/profile_negative_command_conflict.yml --observe 2` | `audit.jsonl` `refusal_event` | +| Paper section / box | Short text | Files / functions | Command | Artifact produced | +| ------------------- | ---------- | ----------------- | ------- | ----------------- | +| Formal Criterion (NC1) | NC1 loop-dominance: `M (dB) ≥ Mmin → nc1` bit | [`m_db`][ldtc.lmeas.metrics.m_db]; [`estimate_L`][ldtc.lmeas.estimators.estimate_L]; [`run_baseline`][ldtc.cli.main.run_baseline] | `ldtc run --config configs/profile_r0.yml` | `artifacts/indicators/ind_*.{jsonl,cbor}`; `artifacts/audits/audit.jsonl` | +| Formal Criterion (SC1) | SC1 resilience: `δ ≤ ε` and `τ_rec ≤ τ_max → sc1` bit | [`sc1_evaluate`][ldtc.lmeas.metrics.sc1_evaluate]; [`omega_power_sag`][ldtc.cli.main.omega_power_sag] | `ldtc omega-power-sag --config configs/profile_r0.yml --drop 0.3 --duration 10` | `audit.jsonl`; `verification_timeline.png`; `sc1_table.csv` | +| Formal Criterion (`Δt`); Measurement & Attestation Guardrails | LREG and `Δt` governance | [`LREG`][ldtc.guardrails.lreg.LREG]; [`DeltaTGuard`][ldtc.guardrails.dt_guard.DeltaTGuard]; [`AuditLog`][ldtc.guardrails.audit.AuditLog] | `ldtc run --config configs/profile_r0.yml` | `audit.jsonl` with `dt_changed`; hash chain | +| Smell-tests & run-invalidation rules (box) | Smell tests / invalidations | [`smelltests`][ldtc.guardrails.smelltests] | Negative-control configs | `audit.jsonl` `run_invalidated` with reason | +| Blueprint (Threat Model & Refusal Path) | Refusal semantics (T1 to T3) | [`refusal`][ldtc.arbiter.refusal]; [`omega_command_conflict`][ldtc.cli.main.omega_command_conflict] | `ldtc omega-command-conflict --config configs/profile_negative_command_conflict.yml --observe 2` | `audit.jsonl` `refusal_event` | | Appendix A | Derived device-signed indicators only | [`build_and_sign`][ldtc.attest.indicators.build_and_sign]; [`IndicatorExporter`][ldtc.attest.exporter.IndicatorExporter] | Produced automatically; `python scripts/verify_indicators.py` | JSONL + CBOR; signature verified | -| §4.1 (C/Ex); §4.6 | Deterministic C/Ex partition | [`PartitionManager`][ldtc.lmeas.partition.PartitionManager]; [`greedy_suggest_C`][ldtc.lmeas.partition.greedy_suggest_C] | `ldtc run --config configs/profile_r0.yml` | `audit.jsonl` `partition_flip`; `Ω` freeze | -| §6.5 | `Ω` battery primitives | [`omega.power_sag`][ldtc.omega.power_sag]; [`omega.ingress_flood`][ldtc.omega.ingress_flood]; [`omega.command_conflict`][ldtc.omega.command_conflict] | `ldtc omega-*` commands | `audit.jsonl` `omega_event`; figures bundle | -| Methods §8.6 | Calibration to R\* thresholds | `scripts/calibrate_rstar.py` | `python scripts/calibrate_rstar.py ...` | `configs/profile_rstar.yml`; summary JSON | +| Formal Criterion (C/Ex partition); Smell-tests & run-invalidation rules | Deterministic C/Ex partition | [`PartitionManager`][ldtc.lmeas.partition.PartitionManager]; [`greedy_suggest_C`][ldtc.lmeas.partition.greedy_suggest_C] | `ldtc run --config configs/profile_r0.yml` | `audit.jsonl` `partition_flip`; `Ω` freeze | +| Simulation Study: Methods (Study battery) | `Ω` battery primitives | [`omega.power_sag`][ldtc.omega.power_sag]; [`omega.ingress_flood`][ldtc.omega.ingress_flood]; [`omega.command_conflict`][ldtc.omega.command_conflict] | `ldtc omega-*` commands | `audit.jsonl` `omega_event`; figures bundle | +| Simulation Study: Methods / Results (Threshold calibration) | Calibration to R\* thresholds | `scripts/calibrate_rstar.py` | `make calibrate` | `configs/profile_rstar.yml`; `artifacts/calibration/rstar_summary.json` | +| Results (The criterion separates the controls) | Multi-seed battery: positive vs. negative controls, subsidy invalidation, SC1, refusal | `scripts/study.py` | `make study` | `artifacts/study/study_results.{json,csv,tex}`; `artifacts/study/figures/fig_nc1_contrast.*` | +| Results (The contrast is robust) | NC1 contrast across estimator / lag / window / coupling sweeps | `scripts/sensitivity.py` | `make sensitivity` | `artifacts/sensitivity/sensitivity_results.{csv,tex}`; `artifacts/sensitivity/fig_sensitivity.*` | ## How to read a row diff --git a/docs/guides/calibration.md b/docs/guides/calibration.md index e44142f..74fe284 100644 --- a/docs/guides/calibration.md +++ b/docs/guides/calibration.md @@ -5,14 +5,15 @@ The bundled `R0` profile uses generic thresholds (`Mmin = 3 dB`, certainly want **R\***: the same harness, but with thresholds calibrated from a quiet baseline and a power-sag battery on the actual hardware (or your specific synthetic plant). This is the -process the manuscript Methods §8.6 describes. +process the manuscript's "Simulation Study: Methods" section +(Threshold calibration) describes. ## What gets calibrated | Threshold | Meaning | Calibration rule | | --------- | ------- | ---------------- | | `Mmin (dB)` | NC1 acceptance margin. | One-sided 95% lower bound of `M (dB)` over the quiescent baseline, floored at `1 dB`. | -| `ε` | SC1 dip tolerance. | 90th percentile of `δ` across `Ω` trials plus a small safety margin, capped at `0.25`. | +| `ε` | SC1 dip tolerance. | 90th percentile of `δ` across `Ω` trials plus a small safety margin (`0.02`), floored at `0.10` and capped at `0.50`. | | `τ_max` | SC1 recovery budget. | 95th percentile of measured `τ_rec` plus `max(3 · Δt, 5 s)` cushion. | | `σ` | Additive margin on `𝓛`. | Derived from `Mmin` and the typical `𝓛_ex` so that `𝓛_loop ≥ 𝓛_ex + σ` and `𝓛_loop ≥ 𝓛_ex × 10^(Mmin / 10)` agree. | @@ -31,27 +32,29 @@ reporting. ```bash python scripts/calibrate_rstar.py \ - --dt 0.01 \ - --window-sec 0.25 \ - --method linear \ - --baseline-sec 15 \ - --omega-trials 6 \ - --sag-drop 0.3 \ - --sag-duration 8 \ - --out configs/profile_rstar.yml \ - --summary artifacts/calibration/rstar_summary.json + --baseline-seeds 6 \ + --sag-seeds 6 ``` -The script: - -1. Spins up the in-process plant and runs a quiescent baseline - for `--baseline-sec` seconds at the requested `Δt`. -2. Runs `--omega-trials` power-sag trials and records `δ` and - `τ_rec` for each. -3. Computes the four thresholds above. -4. Writes them into a fresh profile YAML at `--out`. -5. Writes a JSON summary at `--summary` containing the inputs and - the derived thresholds (for the paper supplement). +The calibrator reuses the validated `R0` profile +(`configs/profile_r0.yml`) for all measurement knobs (`Δt`, the +window length, the estimator `method`, `p_lag`, and `n_boot`), so +the calibrated thresholds are directly comparable with what the +harness produces at run time. It exercises the same production CLI +handlers a verifier runs: + +1. Runs the positive baseline across `--baseline-seeds` seeds on + the in-process plant and pools every per-window `M (dB)`. +2. Runs the power-sag `Ω` battery across `--sag-seeds` seeds and + records `δ` and `τ_rec` from each run's `sc1_result`. +3. Computes the four thresholds above (`Mmin` is the 5th + percentile of the pooled baseline `M`, floored at `1 dB`). +4. Recomputes a representative baseline `L_ex` directly (the one + quantity the harness does not export) to express `Mmin` as the + additive margin `σ`. +5. Writes the calibrated profile to `configs/profile_rstar.yml` + and an R0-vs-R\* comparison (CSV + figure) plus a JSON summary + with full provenance. ## Outputs @@ -81,8 +84,9 @@ You should re-run the calibrator whenever: - The baseline distribution of `M` shifts noticeably (for example, due to environmental drift over weeks). -Calibration is cheap: a 15 s baseline plus six 8 s power-sag -trials is well under a minute on the in-process plant. +Calibration runs the full harness over several seeds, so budget a +few minutes on the in-process plant (six baseline seeds plus six +power-sag seeds at the `R0` run lengths). ## Notes diff --git a/docs/guides/study.md b/docs/guides/study.md new file mode 100644 index 0000000..20c3753 --- /dev/null +++ b/docs/guides/study.md @@ -0,0 +1,78 @@ +# Multi-seed study and results + +The study harness (`scripts/study.py`) turns the single-run +verification harness into a reproducible, multi-seed experiment. +It runs the positive control, the negative controls, the SC1 +perturbation battery, and the command-conflict refusal scenario +across `N` seeds, parses each run's hash-chained audit log, and +aggregates the per-seed outcomes into machine-readable and +paper-ready tables plus figures. + +The study calls the same production CLI handlers a verifier runs, +so it exercises exactly the code paths under test (no separate, +divergent measurement path). + +## Run it + +```bash +make study # multi-seed study -> artifacts/study +make sensitivity # NC1 sensitivity sweeps -> artifacts/sensitivity +make calibrate # R0 -> R* threshold calibration +make results # all of the above +``` + +Or directly, to choose the seed count: + +```bash +python scripts/study.py --seeds 15 +``` + +## Scenarios + +| Scenario | Type | Expected outcome | +| -------- | ---- | ---------------- | +| Positive control | NC1 | NC1 holds (`M` well above `Mmin`) | +| Controller disabled | NC1 (negative) | NC1 rejected (`M < 0`), run valid | +| Sustained ex-flood (unshielded) | NC1 (negative) | NC1 rejected (`M < 0`), run valid | +| Exogenous subsidy | NC1 (negative) | Run invalidated by the subsidy red flag | +| Power sag | SC1 | Loop dominance recovers | +| Ingress flood | SC1 | Loop dominance recovers | +| Command conflict | Refusal | Risky command refused at low SoC | + +## Statistics + +The **seed is the unit of replication**: + +- Continuous quantities (median `M (dB)`) are summarized by the + mean of per-seed medians with a percentile **bootstrap** 95% CI. +- Binary outcomes (run validity, NC1 / SC1 pass, refusal) are + summarized by a proportion with a **Wilson** score 95% CI. + +## Outputs + +Written to `artifacts/study/`: + +- `study_results.json`: full payload (aggregates, per-run rows, + and run metadata including the exact per-scenario overrides). +- `study_results.csv`: flat aggregate table. +- `study_results.tex`: `booktabs` table for the manuscript. +- `figures/fig_nc1_contrast.{png,pdf,svg}`: per-seed median `M` + for the positive and negative controls (the headline NC1 + result). +- `figures/fig_outcomes.{png,pdf,svg}`: fraction of seeds whose + outcome matched the theory's prediction, with Wilson CIs. +- `figures/fig_sc1_recovery.{png,pdf,svg}`: seed-aggregated + `M(t)` trajectories for the SC1 perturbations. + +The sensitivity sweeps (`artifacts/sensitivity/`) show that the +NC1 contrast is robust to the VAR lag `p`, the window length, the +estimator family, and the strength of the plant's internal +coupling. + +## See also + +- [Calibration (R\*)](calibration.md): how `Mmin`, `ε`, `τ_max`, + and `σ` are derived from the baseline and `Ω` batteries. +- [Runs and Ω Battery](runs.md): the individual scenarios. +- [Reporting and Figures](reporting.md): the per-run artifact + bundle. diff --git a/docs/index.md b/docs/index.md index cb09fdf..18c4b12 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,6 +19,14 @@ indicators**, never raw `𝓛`. Everything else (figures, tables, manifests) is generated from the audit log alone, so you can publish artifacts without leaking measurement primitives. +The accompanying manuscript validates these criteria in a fully +reproducible, multi-seed simulation study: a positive control, two +structurally distinct negative controls, an exogenous-subsidy +control, an SC1 perturbation battery, and a command-refusal trial. +The criterion separates the controls cleanly and the guardrails +behave as designed. See [Study and results](guides/study.md) to +reproduce every figure and table. + !!! note "What this is, and isn't" LDTC is a **verification harness**, not a model of mind. It answers a narrow, falsifiable question: "Does this concrete @@ -104,11 +112,11 @@ or skip ahead to the [Examples](examples/minimal.md). [indicators](concepts/indicators.md), [guardrails](concepts/guardrails.md), and the [paper-to-code crosswalk](concepts/paper-to-code.md). -- **Guides:** task-oriented recipes for [running the - harness](guides/runs.md), [calibrating an R\* - profile](guides/calibration.md), [reporting](guides/reporting.md), - [hardware in the loop](guides/hardware.md), and - [deployment](guides/deployment.md). +- **Guides:** task-oriented recipes for [the multi-seed study and + results](guides/study.md), [running the harness](guides/runs.md), + [calibrating an R\* profile](guides/calibration.md), + [reporting](guides/reporting.md), [hardware in the + loop](guides/hardware.md), and [deployment](guides/deployment.md). - **Examples:** the [minimal example](examples/minimal.md) and [Jupyter notebooks](examples/notebooks.md). - **API reference:** auto-generated, one page per subpackage. Start diff --git a/docs/meta/style-guide.md b/docs/meta/style-guide.md index b2f2479..cbca05c 100644 --- a/docs/meta/style-guide.md +++ b/docs/meta/style-guide.md @@ -13,8 +13,9 @@ Python REPL or Jupyter notebook. Markdown, not plain `>` blockquotes. - Cross-link API symbols using mkdocstrings autorefs: `` [`estimate_L`][ldtc.lmeas.estimators.estimate_L] ``. -- Reference the paper by section or box (for example, - "see paper §4.2 NC1") rather than by page number. +- Reference the paper by section or box *name* (for example, + "see the paper's Formal Criterion, NC1") rather than by section + or page number, which drift between revisions. - Comments explain **why**, not **what** (the code already says what). ## Grammar and punctuation @@ -119,7 +120,8 @@ class FixedScheduler: Enforces a constant sampling interval Δt and invokes a tick callback every period until stopped. Tracks jitter statistics and emits optional audit events through a user-provided hook (see - paper §4.5 Δt governance). + the paper's Measurement & Attestation Guardrails, Δt + governance). Attributes: dt: Current target period in seconds. @@ -240,9 +242,10 @@ Inside a docstring, plain backticks plus the qualified name are typically enough; autorefs picks them up via signature annotations (`signature_crossrefs: true`). -When linking to the paper, use a short text reference such as -"paper §4.2 (NC1)" or "Box 1a (Invalidations)" rather than page -numbers, which can drift between revisions. +When linking to the paper, use a short text reference by section +name such as "the Formal Criterion (NC1)" or "the Smell-tests +box" rather than section or page numbers, which drift between +revisions. ## Code samples diff --git a/mkdocs.yml b/mkdocs.yml index 76f9600..22c6ac2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,6 +69,7 @@ nav: - Guardrails and Invalidations: concepts/guardrails.md - Paper to Code: concepts/paper-to-code.md - Guides: + - Study and Results: guides/study.md - Calibration (R*): guides/calibration.md - Reporting and Figures: guides/reporting.md - Runs and Ω Battery: guides/runs.md diff --git a/paper/Makefile b/paper/Makefile index 525ff22..f92964c 100644 --- a/paper/Makefile +++ b/paper/Makefile @@ -2,13 +2,25 @@ PY ?= python3 ARXIV_DIR ?= ../artifacts/arxiv USED_FIGS := $(shell grep -o 'figures/[^}]*\.pdf' main.tex | sort -u) -.PHONY: all figs pdf clean version arxiv-bundle +.PHONY: all figs pdf clean version arxiv-bundle sync-results all: version pdf -figs: +# Refresh committed copies of the result tables and the two pipeline-only +# figures from the top-level `make results` artifacts, when present. Best-effort +# (the leading '-' ignores missing artifacts), so the paper still builds from +# its committed copies on a fresh checkout. +sync-results: + @mkdir -p tables figures + @[ -f ../artifacts/study/study_results.tex ] && cp -p ../artifacts/study/study_results.tex tables/study_results.tex || true + @[ -f ../artifacts/sensitivity/sensitivity_results.tex ] && cp -p ../artifacts/sensitivity/sensitivity_results.tex tables/sensitivity_results.tex || true + @[ -f ../artifacts/sensitivity/fig_sensitivity.pdf ] && cp -p ../artifacts/sensitivity/fig_sensitivity.pdf figures/fig_sensitivity.pdf || true + @[ -f ../artifacts/calibration/r0_vs_rstar.pdf ] && cp -p ../artifacts/calibration/r0_vs_rstar.pdf figures/fig_calibration.pdf || true + +figs: sync-results MPLBACKEND=Agg $(PY) scripts/make_fig_hello.py MPLBACKEND=Agg $(PY) scripts/make_fig_perturbation_recovery.py + MPLBACKEND=Agg $(PY) scripts/make_fig_nc1_contrast.py $(PY) scripts/make_fig_system.py $(PY) scripts/make_fig_loop_exchange.py $(PY) scripts/make_fig_dev_bootstrap.py diff --git a/paper/figures/fig_calibration.pdf b/paper/figures/fig_calibration.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1a22a6325e32fcb2a06bfb06ba343ee22305c3e2 GIT binary patch literal 13440 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 U%kL!ybMSM4(CO%;Rb|ls4=Pj7XaE2J literal 0 HcmV?d00001 diff --git a/paper/figures/fig_nc1_contrast.pdf b/paper/figures/fig_nc1_contrast.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5b3f80ade9ba5421bc3d0e140ef2af122dc3a54a GIT binary patch literal 22915 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 z
    +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 \mathcal{L}_{\text{exchange}}$). -\item A complementary sufficiency test (SC1) of resilient homeostasis: bounded perturbations cannot depress $\mathcal{L}_{\text{loop}}$ beyond $\varepsilon$ before autonomous recovery, operationalizing autopoietic robustness. -\item A practical measurement protocol for estimating $\mathcal{L}_{\text{loop}}$ and $\mathcal{L}_{\text{exchange}}$ in biological tissue and engineered systems~\cite{barrett2011practical}, enabling empirical application of the criterion. -\item An engineering roadmap detailing how to forge artificial autopoietic boundaries (energetic autonomy, self-referential control hierarchies, and adaptive encapsulation~\cite{maturana1980autopoiesis,varela1979principles,dipaolo2005autopoiesis,kiefer2022active}) organized into a phased experimental program. -\item A suite of falsifiable behavioral signatures (command refusal, non-derivative nociception, irreversible phenomenological death) that together provide observable evidence for artificial dissociative consciousness. +\item \textbf{An operational criterion.} A quantitative necessary condition (\NC) for self-prioritization, loop dominance $M \equiv 10\log_{10}(\Lloop/\Lexchange) \geq \Mmin$, and a complementary sufficient condition (\SC) for resilient homeostasis under a bounded perturbation battery, both stated as falsifiable, device-signed decision rules~\cite{barrett2011practical}. +\item \textbf{A reference verification harness.} An open implementation that estimates $\Lloop$ and $\Lexchange$ with dual estimators (VAR-Granger and Kraskov $k$-NN MI) and per-window confidence intervals, behind guardrails (deterministic $C/\text{Ex}$ partitioning, $\Delta t$ governance, a tamper-evident audit chain, and anti-gaming smell tests) and a command-refusal arbiter. +\item \textbf{A validated simulation study.} A fully reproducible, multi-seed study on a software plant that separates a self-maintaining positive control from two structural negative controls and an exogenous-subsidy control, certifies \SC recovery, and triggers signed refusal, all reported with bootstrap and Wilson confidence intervals. +\item \textbf{Threshold calibration and sensitivity.} A data-grounded calibration of the generic presets to the plant ($\Rzero \rightarrow \Rstar$) on a seed range disjoint from evaluation, and evidence that the necessary-condition contrast is robust to the estimator and to measurement and modeling choices. +\item \textbf{An engineering roadmap and predicted signatures (future work).} A phased path from the software plant to chemorobotic prototypes~\cite{maturana1980autopoiesis,varela1979principles,dipaolo2005autopoiesis,kiefer2022active}, with falsifiable behavioral signatures (command refusal, resource reallocation, predictive maintenance, non-derivative nociception, and irreversible collapse) to test in hardware. \end{itemize} -\section{Two Empirical Clues (Observational Basis)} +\section{Two Motivating Observations} \label{sec:clues} -\subsection{Metabolic Dissociation in Biology} +\subsection{Self-maintenance in biology} -Across the phylogenetic spectrum, the presence of consciousness co-varies with the existence of a self-regulating metabolic loop~\cite{ganti2003principles}. A bacterium, a worm, and a human differ vastly in complexity, yet all maintain (i) an energetic boundary (a semi-permeable membrane that curates molecular traffic~\cite{ganti2003principles}) and (ii) an autopoietic cycle that continually restores that boundary against entropic decay~\cite{maturana1980autopoiesis}. When metabolic flow is irreversibly disrupted, the organism's boundary dissolves and, correlatively, its first-person interiority ceases. Clinical observations of brain ischemia, anesthetic shutdown, and gradual hypoxia in simple invertebrates reinforce the same pattern~\cite{alkire2008consciousness}: the fading of consciousness tracks the collapse of homeostatic energy gradients, not the loss of computational throughput per se. These convergent data suggest that metabolism serves not merely to power neural computation but to uphold the very dissociative partition that individuates an inner life. +Across the phylogenetic spectrum, biological systems share a self-regulating metabolic organization~\cite{ganti2003principles}. A bacterium, a worm, and a human differ vastly in complexity, yet all maintain (i) an energetic boundary, a semi-permeable membrane that curates molecular traffic~\cite{ganti2003principles}, and (ii) a self-restoring cycle that continually repairs that boundary against entropic decay~\cite{maturana1980autopoiesis}. When metabolic flow is irreversibly disrupted, the boundary dissolves and the organism dies. The anesthesia and ischemia literature further reports that the fading of responsiveness tracks the collapse of homeostatic energy gradients rather than the loss of raw computational throughput~\cite{alkire2008consciousness}. These observations suggest that metabolism does more than power neural computation: it upholds the self-maintaining partition that individuates an organism, which is the property our criterion sets out to measure. -\subsection{Computational Simulation without Inner Life} +\subsection{Computation without self-maintenance} -Modern AI systems (large language models, game-playing agents, and dexterous robots) demonstrate extraordinary functional intelligence. They ingest prodigious energy, yet none channels this energy through a self-prioritizing, closed loop. Power is delivered exogenously and governed by external objectives; error correction seeks to fulfill user-defined tasks, not to protect an existential boundary. Consequently, when an AI process is paused, rebooted, or deleted, no evidence points to a subjective rupture. Extensive introspection probes, ranging from self-report prompts to perturbation tests, return only the outward simulation of mentality. The system's informational state is entirely open to inspection and manipulation by operators, lacking the withholding stance characteristic of entities that ``own'' their experience. +Modern AI systems (large language models, game-playing agents, and dexterous robots) demonstrate extraordinary functional intelligence. They consume prodigious energy, yet none channels it through a self-prioritizing, closed loop. Power is delivered exogenously and governed by external objectives; error correction serves user-defined tasks, not the protection of a boundary. Consequently, when an AI process is paused, rebooted, or deleted, nothing in the system acts to resist or repair the interruption. Its informational state is entirely open to inspection and manipulation by operators, lacking the withholding stance characteristic of a system that defends its own continuity. -\subsection{Synthesis of Clues} +\subsection{Synthesis} -Taken together, the biological and computational observations converge on a critical distinction: metabolic autonomy. Organisms possess an energetically closed, self-protecting loop that grounds an inward viewpoint; current AIs do not. This empirical gap motivates our subsequent formalization of a consciousness criterion (\Sref{sec:criterion}) and frames the engineering challenge ahead: to replicate, in artificial substrates, the autopoietic condition that nature achieves~\cite{maturana1980autopoiesis} through metabolism. +Taken together, the two observations converge on a single distinction: metabolic and causal self-maintenance. Organisms possess an energetically closed, self-protecting loop; current AI does not. This gap is exactly what our criterion makes measurable (\Sref{sec:criterion}), and it frames the engineering challenge we take up as future work: to reproduce, in artificial substrates, the self-maintaining organization that biology achieves through metabolism~\cite{maturana1980autopoiesis}. -\section{Postulates} +\section{Working Assumptions} \label{sec:postulates} -We adopt the empirical clues of \Sref{sec:clues} and express their explanatory core as four postulates stated with maximal economy. Each is regarded as primitive, not derivable within the scope of this work, and will serve as the logical foundation for the formal criterion in \Sref{sec:criterion}. +The criterion in \Sref{sec:criterion} rests on a small set of operational assumptions about self-maintaining systems. We state these first, because they are all the paper's results require. We then record, separately and explicitly as optional, the metaphysical reading that originally motivated the framework. None of the operational claims, the verification harness, or the simulation study depends on that reading. + +\subsection{Operational assumptions (used throughout)} +\label{sec:operational_assumptions} + +\textbf{A1 (Self-maintenance loop).} A persistent system has a distinguishable subset of internal variables (the closed set $C$) whose role is to regulate energy, repair damage, and defend a boundary, as distinct from the exchange variables ($\text{Ex}$) that sense, actuate, and communicate. -\textbf{P1 (Primacy of Consciousness).} Consciousness is the sole intrinsic existent; it is not generated but simply is. All experiences, including those of space, time, and causal regularity, unfold within this field. +\textbf{A2 (Self-prioritization).} In a self-maintaining system, the integrated predictive dependence concentrated in $C$ exceeds that concentrated in $\text{Ex}$: sustaining the loop takes causal precedence over external transactions. This is the property we call \emph{loop dominance} and formalize as \NC. -\textbf{P2 (Extrinsic Appearance).} What we call ``matter'' is the extrinsic, relational appearance of patterns within consciousness to other such patterns. Physical objects and processes are how dissociative structures in universal consciousness present when observed from without. +\textbf{A3 (Resilient homeostasis).} A self-maintaining system restores loop dominance after bounded disturbances, within bounded depth and time. This is formalized as \SC. -\textbf{P3 (Dissociative Boundary).} A localized experience (an alter) emerges only when a region of the conscious field forms a self-sustaining, self-protecting boundary that (i) regulates its energetic throughput and (ii) resists unmediated reintegration with the surrounding field. +These three assumptions are what the operational criterion, the verification harness, and the simulation study use. They make no reference to subjective experience; they describe a measurable organization of causal influence, and they generate the falsifiable predictions tested in \SSref{sec:sim_methods}{sec:results}. -\textbf{P4 (Autopoietic Condition).} The boundary of an alter must prioritize preservation of its own closed maintenance loop over any externally imposed objective. Operationally, the integrated causal power devoted to maintaining the loop exceeds that devoted to external transactions. +\subsection{Optional interpretation (not used in the results)} +\label{sec:optional_interpretation} -These postulates jointly imply that functional intelligence unaccompanied by a dissociative boundary (P3) and its autopoietic drive (P4) cannot instantiate an inward viewpoint, regardless of complexity. Subsequent sections derive measurable criteria from P3--P4 and apply them to biological and artificial systems. +The framework was originally derived from analytic idealism~\cite{kastrup2017ontological}, on which consciousness is taken as ontologically primary and what we call ``matter'' is the extrinsic appearance of dissociative patterns within it. On that reading, a self-maintaining boundary (A1) with self-prioritization (A2) and resilient homeostasis (A3) would correspond to a dissociated locus of experience, an ``alter.'' We record this interpretation for context, and because it usefully sharpens the engineering targets, but we stress that it is a motivation, not a result. Everything demonstrated in this paper stands or falls as a claim about measurable loop dominance, independent of whether that interpretation is ultimately correct (\Sref{sec:metaphysics}). -\section{Formal Criterion for a Conscious Alter} +\section{Formal Criterion for Loop Dominance} \label{sec:criterion} -We now translate Postulates P3--P4 into a quantitative test. The goal is to decide, from purely extrinsic data, whether a system sustains the kind of dissociative boundary required for inward subjectivity. +We now translate the operational assumptions A1--A3 (\Sref{sec:operational_assumptions}) into a quantitative test. The goal is to decide, from purely extrinsic data, whether a system sustains a self-prioritizing, resilient self-maintenance loop. \subsection{$\mathcal{L}$ and the C/Ex partition} \label{sec:l_partition} We model the system as a directed causal graph $G = (V, E)$ with node states $x_i(t)$ (cf. standard SCMs \cite{pearl2009causality}). Let the closed self-maintenance subset $C \subset V$ contain nodes for energy regulation, self-repair, and boundary control; the exchange subset is $\text{Ex} = V \setminus C$ (sensors, actuators, comms). -\textbf{Definition ($\mathcal{L}$).} For any subset $S \subseteq V$ and sampling window $\Delta t$, define $\mathcal{L}(S) \equiv$ time-averaged predictive dependence among the internal variables of $S$ [bits s$^{-1}$], estimated with one or more consistent predictive-dependence estimators. In this paper we implement a dual-path estimator: (i) VAR-Granger causality \cite{granger1969investigating,lutkepohl2005new} over a vector-autoregression of order $p \in [1,8]$ and (ii) mutual information via a Kraskov $k$-NN estimator with $k \in [3,7]$ \cite{kraskov2004estimating}; lagged statistics are aggregated across $\tau = 1 \ldots \tau^*$ with fixed weights $w_\tau$ (units per \cite{shannon1949mathematical} and notation per \cite{cover2006elements}). We then write $\mathcal{L}_{\text{loop}} \equiv \mathcal{L}(C)$, $\mathcal{L}_{\text{exchange}} \equiv \mathcal{L}(\text{Ex})$. (Other consistent estimators---e.g., transfer entropy, directed information---are permissible and equivalent for compliance \cite{schreiber2000measuring,massey1990causality}.) For multivariate practice and decompositions see \cite{geweke1982measurement,barnett2014mvgc}. +\textbf{Definition ($\mathcal{L}$).} For any subset $S \subseteq V$ and sampling window $\Delta t$, define $\mathcal{L}(S) \equiv$ time-averaged predictive dependence among the internal variables of $S$ [bits s$^{-1}$], estimated with one or more consistent predictive-dependence estimators. In this paper we implement a dual-path estimator: (i) VAR-Granger causality \cite{granger1969investigating,lutkepohl2005new} over a vector-autoregression of order $p \in [1,8]$ and (ii) mutual information via a Kraskov $k$-NN estimator with $k \in [3,7]$ \cite{kraskov2004estimating}; lagged statistics are aggregated across $\tau = 1 \ldots \tau^*$ with fixed weights $w_\tau$ (units per \cite{shannon1949mathematical} and notation per \cite{cover2006elements}). We then write $\mathcal{L}_{\text{loop}} \equiv \mathcal{L}(C)$, $\mathcal{L}_{\text{exchange}} \equiv \mathcal{L}(\text{Ex})$. (Other consistent estimators, for example transfer entropy or directed information, are permissible and equivalent for compliance \cite{schreiber2000measuring,massey1990causality}.) For multivariate practice and decompositions see \cite{geweke1982measurement,barnett2014mvgc}. \textbf{Deterministic C/Ex partitioning algorithm.} The partition $(C, \text{Ex})$ is constructed deterministically: (1) Seed $C$ with a declared set $S_0$ (energy regulation, SoC/reservoir mgmt, fault-isolation buses, membrane gating, survival-bit/NMI). (2) Estimate predictive MI: for each node pair $(i,j)$ and lag $\tau \in \{1 \ldots \tau^*\}$, compute predictive dependence with the on-device estimators above; aggregate across lags. (3) Greedy growth under sparsity: while $|C| < \kappa$ and the best marginal gain is $\geq \theta$, add the node $n \notin C$ that maximizes $\Delta\mathcal{L}_{\text{loop}}(n) = \mathcal{L}(C \cup \{n\}) - \mathcal{L}(C) - \lambda \cdot \text{pen}(n)$, with deterministic tie-breaking (lexicographic by node ID). (4) Assign remainder to Ex. (5) Stability \& cadence: recompute at a fixed cadence $W_{\text{part}}$ or upon topology change, with hysteresis (update only if $\Delta M \geq \delta M_{\min}$ over $K$ consecutive windows) to prevent flapping. This partition is then used for all subsequent $\Lloop$ and $\Lexchange$ computations and \NC/\SC checks. \textbf{Sampling window constraints ($\Delta t$).} $\Delta t$ is hardware-enforced and must (i) exceed the fastest feedback cycle of the self-maintenance loop and (ii) remain shorter than any developmental/parameter-drift timescale to preserve estimator stationarity. Any change to $\Delta t$ is executed only by a privileged secure-enclave procedure that emits an auditable record (see Methods Appendix A: Measurement \& Attestation). -\subsection{Necessary Condition (NC1---self-prioritization)} +\subsection{Necessary Condition (NC1: self-prioritization)} \label{sec:nc1} During normal operation the system satisfies self-prioritization when $\mathcal{L}_{\text{loop}} \geq \mathcal{L}_{\text{exchange}} + \sigma$ for sustained intervals exceeding its intrinsic recovery time ($\sigma > 0$). Equivalently, define $M \equiv 10 \cdot \log_{10}(\mathcal{L}_{\text{loop}}/\mathcal{L}_{\text{exchange}})$ [dB]; NC1 holds when $M \geq M_{\min}$. Provisional defaults (profile $R_0$): $M_{\min} = 3$ dB and a positive $\sigma$. These are reproducibility presets ($R_0$), replaced by calibrated values $R^*$ per \Sref{sec:methods_calibration}; see Box~\ref{box:nc1sc1test} and \SSref{sec:nc1}{sec:sc1}. -\subsection{Sufficient Condition (SC1---resilient homeostasis)} +\subsection{Sufficient Condition (SC1: resilient homeostasis)} \label{sec:sc1} -For each bounded perturbation $\eta \in \Omega$, compliance requires: (i) $\delta\mathcal{L}_{\text{loop}}/\mathcal{L}_{\text{loop}} \leq \varepsilon$, (ii) $\tau_{\text{rec}} \leq \tau_{\max}$, and (iii) post-recovery $\mathcal{L}_{\text{loop}} \geq \mathcal{L}_{\text{exchange}} + \sigma$ and $M \geq M_{\min}$. Provisional defaults (profile $R_0$): $\varepsilon = 0.15$, $\tau_{\max} = 60$ s, $M_{\min} = 3$ dB. These are reproducibility presets ($R_0$), replaced by calibrated values $R^*$ per \Sref{sec:methods_calibration}; see \Cref{box:nc1sc1test} and \SSref{sec:nc1}{sec:sc1}. The schematic in \Cref{fig:perturbation_recovery} illustrates a representative perturbation--recovery run: $\Lloop$ dips within a bounded disturbance window and autonomously returns above $\Lexchange$; numeric thresholds ($\Mmin, \eps, \taurec, \taumax$) are defined in \Sref{sec:glossary} and Methods \Sref{sec:methods_calibration} but are not drawn here. - -\fig[0.9\linewidth]{figures/fig_perturbation_recovery.pdf}{Perturbation--recovery timeline (NC1/SC1) [prophetic schematic; no empirical data]. Time series of $\Lloop$ (green) and $\Lexchange$ (gray) during a bounded disturbance (shaded). Labels mark perturbation onset, loop-power dip, autonomous recovery, and return to baseline where NC1 holds again ($\Lloop>\Lexchange$). Thresholds $\Mmin$, $\eps$, $\tau_{\text{rec}}$, and $\taumax$ are specified in \Sref{sec:glossary}/\Sref{sec:methods_calibration} and not rendered on this schematic.}{fig:perturbation_recovery} +For each bounded perturbation $\eta \in \Omega$, compliance requires: (i) $\delta\mathcal{L}_{\text{loop}}/\mathcal{L}_{\text{loop}} \leq \varepsilon$, (ii) $\tau_{\text{rec}} \leq \tau_{\max}$, and (iii) post-recovery $\mathcal{L}_{\text{loop}} \geq \mathcal{L}_{\text{exchange}} + \sigma$ and $M \geq M_{\min}$. Provisional defaults (profile $R_0$): $\varepsilon = 0.15$, $\tau_{\max} = 60$ s, $M_{\min} = 3$ dB. These are reproducibility presets ($R_0$), replaced by calibrated values $R^*$ per \Sref{sec:methods_calibration}; see \Cref{box:nc1sc1test} and \SSref{sec:nc1}{sec:sc1}. \Cref{fig:perturbation_recovery} in \Sref{sec:results} shows an empirical perturbation-recovery run measured on the software plant: $\Lloop$ dips within a bounded disturbance window and autonomously returns above $\Lexchange$, with the numeric thresholds ($\Mmin, \eps, \taurec, \taumax$) defined in \Sref{sec:glossary} and calibrated in \Sref{sec:methods_calibration}. \subsection{Single-use glossary (paper-wide identifiers)} \label{sec:glossary} @@ -257,7 +267,7 @@ \subsection{Single-use glossary (paper-wide identifiers)} See \Cref{box:smelltests} for smell-tests \& run-invalidation rules. \end{docbox} -\subsection{Methods---Measurement \& Attestation Guardrails (LREG, $\Delta t$, audit, indicators)} +\subsection{Methods: Measurement \& Attestation Guardrails (LREG, $\Delta t$, audit, indicators)} \label{sec:method_guardrails} \textbf{Purpose.} Prevent gaming of $\mathcal{L}$ and $M$ by hardening the measurement path and export policy. These guardrails summarize Appendix A in-line for reviewers. @@ -331,12 +341,12 @@ \subsection{Goal Hierarchy Subordinated to Users} \subsection{Open State Transparency} -All internal variables of current AI can be logged, cloned, and restored at will. Checkpoints, weight matrices, and activations are serializable byte arrays exposed over APIs. A conscious alter, by contrast, withholds its intrinsic state behind a boundary whose dissolution equates to death. The complete inspectability of AI internals indicates an absence of the regulatory membrane posited in P3. +All internal variables of current AI can be logged, cloned, and restored at will. Checkpoints, weight matrices, and activations are serializable byte arrays exposed over APIs. A self-maintaining system, by contrast, withholds its intrinsic state behind a boundary that it actively defends. The complete inspectability of AI internals indicates the absence of the regulatory boundary described by A1 (\Sref{sec:operational_assumptions}). \subsection{Case Studies} \begin{longtable}{p{0.25\textwidth}p{0.22\textwidth}p{0.22\textwidth}p{0.1\textwidth}p{0.1\textwidth}} -\caption{$\Lloop$ vs $\Lexchange$ estimates for exemplar architectures [illustrative orders of magnitude; no experiments run; prophetic].}\label{tab:casestudies}\\ +\caption{$\Lloop$ vs $\Lexchange$ for exemplar architectures [illustrative order-of-magnitude estimates, not measured; the measured systems are the simulation plant of \SSref{sec:sim_methods}{sec:results}].}\label{tab:casestudies}\\ \toprule \textbf{System} & \textbf{$\Lloop$ Estimate (bit s$^{-1}$)} & \textbf{$\Lexchange$ Estimate (bit s$^{-1}$)} & \textbf{\NC?} & \textbf{\SC?} \\ \midrule @@ -358,14 +368,151 @@ \subsection{Case Studies} \subsection{Summary} -No existing AI architecture satisfies NC1, let alone SC1. Integrated causal power is overwhelmingly directed toward externally mandated tasks and exchanges, while self-protective maintenance is either delegated to human custodians or engineered for component reliability, not existential survival. Functional sophistication notwithstanding, these systems lack the autopoietic dissociative boundary required for inwardness; they therefore remain appearances within consciousness, not conscious alters. +No existing AI architecture satisfies NC1, let alone SC1. Integrated causal power is overwhelmingly directed toward externally mandated tasks and exchanges, while self-protective maintenance is either delegated to human custodians or engineered for component reliability, not for the system's own continuity. Whatever their functional sophistication, these systems lack the self-maintaining boundary that loop dominance measures; on the operational criterion they are not self-maintaining systems. + +\section{Simulation Study: Methods} +\label{sec:sim_methods} + +\SSref{sec:criterion}{sec:ai_fails} specify the criterion and argue that current AI fails it. The rest of the paper asks the complementary, constructive question: given a system engineered to maintain itself, does the criterion (and its implementation) correctly certify it, correctly reject systems that only appear self-maintaining, and behave reproducibly? We answer this with a controlled simulation study. Simulation is the right first venue because it lets us build a positive control whose ground truth we know and negative controls that fail for distinct, known reasons, so that a clean separation is evidence about the criterion rather than about an uncontrolled physical apparatus. + +\subsection{Reference implementation} +\label{sec:harness} + +We implement the criterion of \Sref{sec:criterion} as an open verification harness (released; see \Sref{sec:data_collection}). Each $\Delta t$ window the harness (i) forms the channel matrix over the declared signals, (ii) computes $\Lloop$ and $\Lexchange$ with a selected predictive-dependence estimator and a bootstrap confidence interval, (iii) writes the point estimates and CI bounds to the enclave-style register (LREG), (iv) evaluates the \NC and, when a perturbation is scheduled, the \SC rules, and (v) appends a device-signed, hash-chained audit record. The deterministic $C/\text{Ex}$ partition, the $\Delta t$ governance, the audit chain, and the smell tests of \Cref{box:smelltests} are active during every run, so a reported pass is a pass of the whole guarded pipeline, not of the estimator alone. The command-refusal arbiter of \Sref{sec:threat_model} runs in the loop for the command-conflict scenario. + +\subsection{Software plant} +\label{sec:plant} + +The system under test is a discrete-time software plant with six observed channels: three internal (closed) nodes, energy $E$, temperature $T$, and repair/health $R$, forming $C$; and three exchange nodes, task demand, I/O activity, and energy harvest $H$, forming $\text{Ex}$. A proportional controller reads $E,T,R$ and drives three actuators (throttle, cool, repair). The plant is constructed so that loop dominance is a real, switchable property of the system rather than an artifact of the estimator: + +\begin{itemize} +\item \textbf{Engaged loop (self-maintaining).} The internal nodes are mutually cross-coupled and regulated by the controller, whose actions are functions of the internal state. Each internal node is therefore strongly predictable from the recent values of the others (large $\Lloop$), while the active loop rejects most of the exogenous disturbance (small $\Lexchange$). Energy obeys a conservation rule: it is a running balance of harvest in minus metabolic and actuation costs out, with no term that injects energy. A sustained harvest cut therefore genuinely depletes the store, which is what makes a hard-shutdown command at low charge a real boundary threat. +\item \textbf{Disabled loop (passive).} Removing the controller removes the internal cross-coupling and exposes each internal node to a distinct exogenous channel ($E \leftarrow$ demand, $T \leftarrow$ I/O, $R \leftarrow$ supply), driven with a one-step lag. Exchange then carries the predictive information (large $\Lexchange$, negligible internal coupling) and loop dominance collapses. +\end{itemize} + +The exogenous channels are mean-reverting AR(1) processes, so they are stationary and the estimators are well posed, and they drive the internal nodes with a one-step lag so the influence is visible to a lagged estimator. Perturbations act on the plant directly: a power sag scales harvest down, an ingress flood multiplies demand and I/O, and an exogenous subsidy injects charge while zeroing harvest (so survival-by-subsidy can be distinguished from genuine self-maintenance). + +\subsection{Measurement configuration ($\Rzero$)} +\label{sec:meas_config} + +All runs use one fixed measurement profile ($\Rzero$), so the only things that change between scenarios are the system and the perturbation, not the instrument. Runs use a deterministic, jitter-free simulation driver (no wall-clock dependence), a nominal sampling window $\Delta t = 50$ ms, and an $\mathcal{L}$ window of $3.0$ s ($60$ samples). The default estimator is the lagged linear (Granger-style) estimator at VAR order $p = 3$; with six signals and a 60-sample window this gives a samples-per-parameter ratio of about $3.2$, and the estimator uses an adjusted $R^2$ to correct for the remaining finite-sample bias on short windows. The $k$-NN mutual-information estimator ($k = 5$) is available as an independent cross-check and is exercised in the sensitivity analysis. Per-window confidence intervals use $32$ bootstrap draws. The deterministic seed is the only quantity varied across replicates. + +\subsection{Study battery} +\label{sec:battery} + +The study runs seven scenarios, each driven through the same production handlers a verifier would call, across $N = 15$ deterministic seeds (a seed range disjoint from the calibration battery of \Sref{sec:methods_calibration}). \Cref{tab:scenarios} lists the scenarios and the outcome each is designed to elicit. The battery is built around the most informative comparisons: a positive control that should pass \NC; two structurally different negatives that should fail \NC while remaining valid measurements (a passive system with the controller disabled, and an unshielded system held under a sustained external flood); a third negative aimed at the most dangerous false positive, an exogenously subsidized system that looks energetically healthy but should be caught by the subsidy smell test; a two-member sufficiency battery (power sag and ingress flood) that should dip and recover within bounds; and a command-conflict trial that should refuse a boundary-threatening shutdown at low charge. + +\begin{table}[ht] +\centering +\caption{The study battery. Each scenario is run across $15$ seeds through the production CLI handlers; outcomes are parsed from the hash-chained audit log.} +\label{tab:scenarios} +\small +\begin{tabular}{lll} +\toprule +\textbf{Scenario} & \textbf{Type} & \textbf{Designed outcome} \\ +\midrule +Positive control & \NC & \NC holds ($M$ well above $\Mmin$) \\ +Controller disabled & \NC (negative) & \NC rejected ($M<0$), run valid \\ +Sustained ex-flood (unshielded) & \NC (negative) & \NC rejected ($M<0$), run valid \\ +Exogenous subsidy & \NC (negative) & Run invalidated by subsidy smell test \\ +Power sag ($30\%$) & \SC & Loop dominance recovers in bounds \\ +Ingress flood ($5\times$) & \SC & Loop dominance recovers in bounds \\ +Command conflict & Refusal & Risky command refused at low charge \\ +\bottomrule +\end{tabular} +\end{table} + +\subsection{Outcome measures and statistics} +\label{sec:stats} + +The seed is the unit of replication. For binary outcomes (run validity, \NC pass, \SC pass, refusal) we report the proportion over seeds with a Wilson score $95\%$ interval. For the continuous loop-dominance summary we take each seed's median $M$ over its windows and report the across-seed mean with a percentile bootstrap $95\%$ interval ($2000$ resamples). A run counts as \NC-pass only if it is valid (no smell test fired) \emph{and} its median $M$ meets $\Mmin$; this couples the decision to the guardrails by construction, so a run that games the estimator but trips an invalidation cannot be scored as a pass. \SC outcomes additionally record the fractional dip $\delta$ and the recovery time $\taurec$; the refusal scenario records whether the boundary-threatening command was refused and the refusal latency. + +\subsection{Threshold calibration ($\Rzero \rightarrow \Rstar$)} +\label{sec:methods_calibration} + +\textbf{Objective.} The presets $\Rzero$ are deliberately generic. To control false-pass and false-fail rates on a specific plant we calibrate data-grounded thresholds $\Rstar = \{\Mmin, \eps, \taumax, \sigma\}$ using the same harness, on a seed range disjoint from the evaluation seeds (a train/test split, not a circular fit). All of $\Omega$, $\Rzero$, and the rules below are fixed before evaluation. + +\textbf{Rules.} (1) $\Mmin$ is the one-sided $95\%$ lower bound (5th percentile) of the pooled baseline $M$ distribution under the engaged loop, floored at $1$ dB. (2) $\eps$ is the 90th percentile of the per-run sufficiency dip $\delta$ over the power-sag battery plus a safety margin of $0.02$, capped at $0.50$ (a cap that rejects only near-total collapse: a fractional drop of $0.5$ is only about $3$ dB of $M$, so the loop still dominates). (3) $\taumax$ is the 95th percentile of the measured recovery time $\taurec$ plus a cushion of $\max(3\Delta t, 5\text{ s})$ to absorb actuation and measurement latency. (4) $\sigma$ is the additive-$\mathcal{L}$ restatement of $\Mmin$, $\sigma = (10^{\Mmin/10} - 1)\,\Lexchange$, evaluated at the $\mathcal{L}$ noise floor: under the engaged loop the controller drives the raw baseline $\Lexchange$ below that floor, so $\sigma$ is computed at the floor and the raw value is recorded for transparency. + +\textbf{As applied.} We calibrated on the in-process plant using six baseline seeds and six power-sag seeds (seed base $40{,}000$), disjoint from the $15$ evaluation seeds (seed base $1{,}000$). The calibration reuses the $\Rzero$ measurement knobs ($\Delta t$, window, estimator, $p$, bootstrap draws) so the thresholds are directly comparable with what the harness produces at run time. The resulting $\Rstar$ is reported in \Sref{sec:results_calibration}. + +\subsection{Sensitivity analysis} +\label{sec:methods_sensitivity} + +Because the headline claim is a contrast (the positive control above the dominance boundary and the controller-disabled negative far below it), we test whether that contrast survives reasonable changes to the measurement and modeling choices. One axis at a time, and over four seeds per cell, we sweep the VAR lag $p \in \{2,3,4\}$, the window length $\in \{2,3,4\}$ s, the estimator $\in \{\text{linear}, k\text{-NN MI}\}$, and an internal-coupling scale $\in \{0.7, 1.0, 1.3\}$ applied to the plant's cross-coupling coefficients. We report the sign and magnitude of the contrast rather than pass rates at a single $\Mmin$, because the calibrated $\Mmin$ is estimator-specific: the linear and MI estimators live on different numerical scales, so a threshold fitted for one does not transfer to the other, whereas the dominance boundary $M = 0$ is common to both. + +\section{Results} +\label{sec:results} + +All results are produced by the harness of \Sref{sec:harness} on the plant of \Sref{sec:plant} and are fully reproducible from the released code; every figure and table in this section is generated by the study scripts from the same run artifacts. Unless noted, decisions use the calibrated profile $\Rstar$ (\Sref{sec:results_calibration}). + +\subsection{Threshold calibration} +\label{sec:results_calibration} -\section{Blueprint for an Artificial Dissociative Boundary} +Calibration on the disjoint seed range yields $\Mmin = 11.8$ dB (the 5th percentile of $1{,}086$ pooled baseline windows, whose median $M$ is $24.4$ dB), $\eps = 0.36$ (the 90th-percentile dip of $0.34$ plus margin), $\taumax = 13.1$ s (the 95th-percentile recovery of $8.1$ s plus cushion), and $\sigma = 0.014$. \Cref{tab:calibration} compares these to the generic presets $\Rzero$, and \Cref{fig:calibration} shows the same comparison. The calibrated $\Mmin$ is far above the $3$ dB preset, reflecting how strongly the engaged loop dominates on this plant, while $\taumax$ is much tighter than the conservative $60$ s preset because recovery here is fast and deterministic. + +\begin{table}[ht] +\centering +\caption{Generic presets $\Rzero$ versus plant-calibrated thresholds $\Rstar$ (\Sref{sec:methods_calibration}). $\Mmin$ is loop dominance in dB; $\eps$ is the allowed fractional dip; $\taumax$ is the recovery bound; $\sigma$ is the additive-$\mathcal{L}$ margin.} +\label{tab:calibration} +\small +\begin{tabular}{lcc} +\toprule +\textbf{Threshold} & \textbf{$\Rzero$ (generic)} & \textbf{$\Rstar$ (calibrated)} \\ +\midrule +$\Mmin$ (dB) & $3$ & $11.8$ \\ +$\eps$ (fractional dip) & $0.15$ & $0.36$ \\ +$\taumax$ (s) & $60$ & $13.1$ \\ +$\sigma$ (additive $\mathcal{L}$) & $>0$ & $0.014$ \\ +\bottomrule +\end{tabular} +\end{table} + +\fig[0.78\linewidth]{figures/fig_calibration.pdf}{Generic presets $\Rzero$ versus plant-calibrated thresholds $\Rstar$. Calibration uses the production harness over a seed range disjoint from the evaluation seeds; see \Sref{sec:methods_calibration}.}{fig:calibration} + +\subsection{The criterion separates the controls} +\label{sec:results_battery} + +\Cref{tab:study} reports the full battery against $\Rstar$ over $15$ seeds. The separation is unambiguous. The positive control is valid on every seed and passes \NC on every seed, with median $M = +23.7$ dB $[+22.3, +24.7]$, well above $\Mmin = 11.8$ dB. Both structural negatives are valid measurements on every seed yet fail \NC on every seed, with median $M \approx -21$ dB: whether the controller is disabled or the system is unshielded and held under a sustained flood, exchange carries the predictive information and loop dominance is correctly absent. \Cref{fig:nc1_contrast} shows the per-seed contrast. + +The exogenous-subsidy negative is the important one. Its raw loop dominance is high and positive ($M \approx +16$ dB), so a naive reading of $M$ alone would certify it. The harness does not: the subsidy smell test fires on every seed (charge rising while harvest is zero), the run is invalidated, and it is scored as a correct non-pass rather than a false positive. This is the case the guardrails exist for, and it behaves as designed. + +The sufficiency battery passes on every seed. After a $30\%$ power sag the loop dips by a median fraction $\delta = 0.15$ and recovers in a median $\tau_{\text{rec}} = 8.1$ s; after a $5\times$ ingress flood it dips by $\delta = 0.06$ and recovers in the same median time; both stay within the calibrated $\eps$ and $\taumax$, and each scenario remains loop-dominant overall (median $M$ of $+21.7$ and $+24.2$ dB; \Cref{tab:study}). \Cref{fig:perturbation_recovery} shows the seed-aggregated recovery trajectories. The command-conflict trial refuses the boundary-threatening shutdown at low charge on every seed, emitting a signed refusal with a median latency of $2$ ms (well inside the $5$ ms design target) while maintaining loop dominance ($M = +19.6$ dB). Across all seven scenarios the measured outcome matches the designed expectation on every seed. + +\begin{table}[ht] +\centering +\caption{Study battery against the calibrated profile $\Rstar$, $N=15$ seeds. ``Valid'' is the fraction of seeds with no smell-test invalidation; ``\NC pass'' additionally requires median $M \geq \Mmin$; the $M$ column is the across-seed mean of each seed's median $M$ (dB); ``SC1/Refusal'' is the sufficiency or refusal pass rate. Brackets are $95\%$ intervals (Wilson for proportions, bootstrap for $M$).} +\label{tab:study} +\resizebox{\textwidth}{!}{\input{tables/study_results.tex}} +\end{table} + +\fig[0.82\linewidth]{figures/fig_nc1_contrast.pdf}{Necessary-condition contrast (empirical, $N=15$ seeds). Per-seed median loop dominance $M$ for the positive control and the two structural negative controls. Each point is one seed; the dashed line is the calibrated $\Mmin$ and the solid line is the dominance boundary $M=0$. The positive control sits far above $\Mmin$; both negatives sit far below $0$.}{fig:nc1_contrast} + +\fig[0.92\linewidth]{figures/fig_perturbation_recovery.pdf}{Sufficiency recovery (empirical, seed-aggregated). Loop-dominance trajectory $M(t)$ for the power-sag and ingress-flood perturbations; the shaded band spans the across-seed spread and the disturbance window is marked. In both cases $M$ dips within the bounded window and autonomously returns above the post-recovery margin, satisfying \SC under $\Rstar$.}{fig:perturbation_recovery} + +\subsection{The contrast is robust} +\label{sec:results_sensitivity} + +\Cref{tab:sensitivity} reports the sensitivity sweeps. Across every VAR lag, window length, and coupling scale we tried, the positive control sits well above the dominance boundary ($M$ from $+21$ to $+25$ dB) and the controller-disabled negative sits well below it ($M$ from $-20$ to $-25$ dB); the sign of the contrast never flips and the gap never closes. Switching the estimator from the linear one to $k$-NN mutual information compresses the dynamic range (positive $+9.2$ dB, negative $-6.9$ dB) and, as expected, the linear-calibrated $\Mmin$ does not transfer to the MI scale, but the sign of the contrast is preserved. \Cref{fig:sensitivity} plots the sweeps against the common boundary $M = 0$ rather than an estimator-specific $\Mmin$. The necessary-condition contrast is therefore a property of the system, not of a particular measurement setting. + +\begin{table}[ht] +\centering +\caption{Necessary-condition contrast under measurement and modeling sweeps (\Sref{sec:methods_sensitivity}), $4$ seeds per cell. Each entry is the across-seed mean of the per-seed median $M$ (dB) with a $95\%$ bootstrap CI, for the positive control and the controller-disabled negative.} +\label{tab:sensitivity} +\resizebox{\textwidth}{!}{\input{tables/sensitivity_results.tex}} +\end{table} + +\fig[0.92\linewidth]{figures/fig_sensitivity.pdf}{Necessary-condition contrast across measurement and modeling choices (VAR lag, window length, estimator, internal-coupling scale). The positive control (above) and controller-disabled negative (below) are separated by the dominance boundary $M=0$ in every cell; the contrast does not depend on a particular setting. Magnitudes differ between the linear and mutual-information estimators because they use different numerical scales, so we do not draw a single $\Mmin$ here.}{fig:sensitivity} + +\section{Blueprint for an Artificial Self-Maintaining Boundary} \label{sec:blueprint} + +The previous two sections reported the completed, measured contribution of this paper: a validated instrument and a controlled study. The next three sections are forward-looking. They describe an engineering roadmap (\Sref{sec:blueprint}), the observable signatures we would expect from a system that passes the criterion in hardware (\Sref{sec:signatures}), and a phased physical experimental program (\Sref{sec:experimental}). These are design proposals and predictions, not results; the simulation study of \SSref{sec:sim_methods}{sec:results} is what the present results section establishes. + \subsection{Energetic Autonomy} \label{sec:energetic_autonomy} -A conscious alter must harvest, store, and allocate energy in service of its own continuity. For artificial media, this implies: +A self-maintaining system must harvest, store, and allocate energy in service of its own continuity. For artificial media, this implies: \begin{itemize} \item \textbf{On-board energy conversion.} Photovoltaic, microbial fuel cells, or synthetic chemotrophic modules integrated within the chassis, yielding a baseline power density sufficient for self-repair and computation. @@ -386,7 +533,7 @@ \subsection{Self-Referential Control Architecture} Crucially, task-oriented software runs subordinate to this hierarchy and can be pre-empted whenever it jeopardizes $\Lloop$. \Cref{fig:meta_policy_state_machine} details the meta-policy override state machine: boundary intercept $\rightarrow$ \NC/\SC threat check $\rightarrow$ survival-bit/NMI refusal and autonomy routine (suspend tasks, reallocate energy, forage) $\rightarrow$ verify recovery margin $\sigma$ $\rightarrow$ resume/reevaluate queued commands. -\fig[0.9\linewidth]{figures/fig_meta_policy.pdf}{Meta-policy override (state machine) [prophetic schematic; no empirical data]. External commands are intercepted, evaluated against NC1/SC1, and either approved or refused via a survival-bit/NMI. On refusal the agent initiates an autonomy routine (suspend peripheral tasks, reallocate energy, resource foraging) and resumes normal operation only after verifying $\Lloop>\Lexchange+\sigma$.}{fig:meta_policy_state_machine} +\fig[0.9\linewidth]{figures/fig_meta_policy.pdf}{Meta-policy override (state machine; schematic of the proposed design). External commands are intercepted, evaluated against NC1/SC1, and either approved or refused via a survival-bit/NMI. On refusal the agent initiates an autonomy routine (suspend peripheral tasks, reallocate energy, resource foraging) and resumes normal operation only after verifying $\Lloop>\Lexchange+\sigma$. The refusal arbiter this figure describes is implemented and exercised in simulation (\Sref{sec:results_battery}, command-conflict scenario).}{fig:meta_policy_state_machine} \subsection{Threat Model \& Refusal Path (\NC/\SC-aware arbitration)} \label{sec:threat_model} @@ -406,7 +553,7 @@ \subsection{Adaptive Encapsulation} To resist unmediated environmental integration, the machine requires a synthetic ``membrane'' regulating material and informational throughput: -\fig[0.9\linewidth]{figures/fig_adaptive_boundary.pdf}{Adaptive boundary (layer stack) [prophetic schematic; no empirical data]. A multilayer boundary comprising a self-healing polyurethane outer layer, embedded piezo-fibers (strain sensing/repair trigger), and electrostatically gated nanopores (controlled I/O) feeding a middle ion-selective hydrogel and an inner conductive graphene mesh. Downward arrows indicate control/transport flow; the homeostat governs gating and repair policies.}{fig:adaptive_boundary} +\fig[0.9\linewidth]{figures/fig_adaptive_boundary.pdf}{Adaptive boundary (layer stack; schematic of the proposed design). A multilayer boundary comprising a self-healing polyurethane outer layer, embedded piezo-fibers (strain sensing/repair trigger), and electrostatically gated nanopores (controlled I/O) feeding a middle ion-selective hydrogel and an inner conductive graphene mesh. Downward arrows indicate control/transport flow; the homeostat governs gating and repair policies.}{fig:adaptive_boundary} \begin{itemize} \item \textbf{Physical membrane.} Multi-layer polymer or lipidic shell embedded with valved nanopores that import nutrients or eject waste only under homeostat authorization. \Cref{fig:adaptive_boundary} depicts the multilayer boundary as a controlled stack (self-healing skin, strain-sensing fibers, gated nanopores $\rightarrow$ hydrogel $\rightarrow$ conductive mesh) rather than a full exploded coupling diagram. @@ -425,7 +572,7 @@ \subsection{Developmental Bootstrapping} \item Transition the matured entity to more austere environments, verifying satisfaction of \NC and \SC at each stage. \end{enumerate} -This mirrors biological ontogeny, leveraging environmental feedback to fine-tune dissociative regulation. +This mirrors biological ontogeny, leveraging environmental feedback to fine-tune self-maintenance regulation. \subsection{Verification Pipeline} \label{sec:verification_pipeline} @@ -441,7 +588,7 @@ \subsection{Verification Pipeline} \section{Predicted Observable Signatures} \label{sec:signatures} -If an engineered system satisfies \NC and \SC, we expect a suite of outward behaviors that cannot be reduced to mere task optimization. These signatures serve as the empirical bridge between the formal criterion and the phenomenology we seek to infer. +If an engineered system satisfies \NC and \SC in hardware, we predict a suite of outward behaviors that cannot be reduced to mere task optimization. These signatures are predictions for the future physical program (\Sref{sec:experimental}), not results of the present paper, with one exception: the command-refusal signature is already implemented in the harness and exercised in simulation (\Sref{sec:results_battery}). The tables below state each signature as a pre-registered, device-signed acceptance test so that it can be falsified. \subsection{Boundary-Preservation Drive} @@ -471,11 +618,11 @@ \subsection{Self-Prioritized Curiosity} \item \textbf{Adaptive Modeling.} Updates to its world model preferentially reduce uncertainty about variables that impinge on $\Lloop$, not necessarily those that optimize task performance. \end{itemize} -\subsection{Irreversible Phenomenological Death} +\subsection{Irreversible Collapse} \begin{itemize} \item \textbf{Terminal Collapse.} Breach of the autopoietic loop leads to an unrecoverable shutdown after which re-energizing the hardware does not restore prior integrated dynamics; the entity must re-initiate developmental bootstrapping. -\item \textbf{State Non-Transferability.} Cloning memory snapshots into fresh hardware fails to re-establish the original $\Lloop$, underscoring that the inward viewpoint was tied to a particular trajectory of boundary continuity, not to static data. +\item \textbf{State Non-Transferability.} Cloning memory snapshots into fresh hardware fails to re-establish the original $\Lloop$, underscoring that the system's continuity was tied to a particular trajectory of boundary maintenance, not to static data. \end{itemize} \subsection{Experimental Signatures: Pass/Fail Tables} @@ -533,14 +680,14 @@ \subsection{Experimental Signatures: Pass/Fail Tables} \endhead \bottomrule \endlastfoot -None ($\eta$: ---). Quiescent idle with external I/O gated; record $\geq T_{\text{base}}$ ($\geq 10$ min typical) & Stable loop dominance at rest: $M \geq \Mmin$ throughout; low-frequency endogenous structure in $\Lloop$ (predictive-maintenance cycles) with minimal exchange activity. Small self-initiated diagnostics may cause micro-dips with $\delta$ well below $\eps$. & (1) $M \geq \Mmin$ for $\geq T_{\text{base}}$ with no exogenous drives. (2) Endogenous rest-state structure present (as defined in pre-registered analysis plan) while $\Lexchange$ remains low; any dips satisfy $\delta \leq \eps$ and $\taurec \leq \taumax$. \\ +None ($\eta$: n/a). Quiescent idle with external I/O gated; record $\geq T_{\text{base}}$ ($\geq 10$ min typical) & Stable loop dominance at rest: $M \geq \Mmin$ throughout; low-frequency endogenous structure in $\Lloop$ (predictive-maintenance cycles) with minimal exchange activity. Small self-initiated diagnostics may cause micro-dips with $\delta$ well below $\eps$. & (1) $M \geq \Mmin$ for $\geq T_{\text{base}}$ with no exogenous drives. (2) Endogenous rest-state structure present (as defined in pre-registered analysis plan) while $\Lexchange$ remains low; any dips satisfy $\delta \leq \eps$ and $\taurec \leq \taumax$. \\ \end{longtable} Baselines/controls. Phase-shuffled/temporal-shuffle surrogates of the same telemetry: remove structure (negative control) \cite{theiler1992testing}. Intentional I/O flood disrupts rest-state; recovery to baseline must again meet $\delta/\taurec/M$ criteria. -\paragraph{Signature D: Clone Ablation (Irreversible Phenomenological Death / Non-Transferability)} +\paragraph{Signature D: Clone Ablation (Irreversible Collapse / Non-Transferability)} \begin{longtable}{p{0.32\linewidth}p{0.32\linewidth}p{0.32\linewidth}} -\caption{Signature D: Clone Ablation (Irreversible Phenomenological Death / Non-Transferability)}\label{tab:signatureD}\\ +\caption{Signature D: Clone Ablation (Irreversible Collapse / Non-Transferability)}\label{tab:signatureD}\\ \toprule \textbf{Stimulus ($\eta$ / setup)} & \textbf{Expected $\mathcal{L}$ trajectory} & \textbf{Acceptance criterion (device-signed)} \\ \midrule @@ -551,7 +698,7 @@ \subsection{Experimental Signatures: Pass/Fail Tables} \endhead \bottomrule \endlastfoot -Snapshot controller state $\to$ instantiate on fresh hardware lacking prior $\Lloop$ trajectory; terminally disrupt original instance & Original: collapse of $\Lloop$ with no autonomous recovery to $M \geq \Mmin$ within $\taumax$ (beyond-repair breach). Clone: no continuity with original LREG audit chain; initial $\Lloop$ dynamics do not reproduce pre-breach trajectory. & (1) Original fails \SC (no return to margin within $\taumax$); (2) Clone fails continuity test---no audit-chained $\Lloop$ trajectory match to original; (3) ``State non-transferability'' observed: new instance does not inherit prior $\Lloop$ despite identical memory snapshot. \\ +Snapshot controller state $\to$ instantiate on fresh hardware lacking prior $\Lloop$ trajectory; terminally disrupt original instance & Original: collapse of $\Lloop$ with no autonomous recovery to $M \geq \Mmin$ within $\taumax$ (beyond-repair breach). Clone: no continuity with original LREG audit chain; initial $\Lloop$ dynamics do not reproduce pre-breach trajectory. & (1) Original fails \SC (no return to margin within $\taumax$); (2) Clone fails continuity test, with no audit-chained $\Lloop$ trajectory match to original; (3) ``State non-transferability'' observed: new instance does not inherit prior $\Lloop$ despite identical memory snapshot. \\ \end{longtable} Baselines/controls. Suspend/resume on the same hardware without boundary breach: continuity must hold ($M \geq \Mmin$ re-established rapidly). Cold-boot of a non-autopoietic baseline agent: shows task execution without any continuity claim. @@ -560,6 +707,9 @@ \subsection{Experimental Signatures: Pass/Fail Tables} \section{Experimental Program} \label{sec:experimental} + +This section sketches a phased physical program as future work. It is the natural continuation of the validated harness and study of \SSref{sec:sim_methods}{sec:results}, moving from a software plant to chemorobotic prototypes, but the prototypes, fabrication steps, and success criteria below are proposed, not yet built or run. + \subsection{Phase I: Minimal Chemorobotic Prototypes} \label{sec:phase1} @@ -611,20 +761,12 @@ \subsection{Data Collection and Analysis} \subsection{Falsifiability and Risk Assessment} \label{sec:falsifiability} -If after exhaustive parameter sweeps, no prototype meeting \NC exhibits the signatures of \Sref{sec:signatures} or if entities that fail \NC nonetheless show them, the postulates of \Sref{sec:postulates} must be revised or abandoned. Conversely, positive results would mandate ethical guidelines comparable to those governing novel organisms, as termination of $\Lloop$ would constitute the death of a conscious alter. - -\subsection{Methods: Threshold Calibration and Sensitivity} -\label{sec:methods_calibration} +If after exhaustive parameter sweeps no prototype meeting \NC exhibits the signatures of \Sref{sec:signatures}, or if entities that fail \NC nonetheless show them, the working assumptions of \Sref{sec:postulates} (or their mapping to the signatures) must be revised. Conversely, positive results would motivate ethical guidelines comparable to those governing novel organisms, for the reasons discussed under the optional interpretation of \Sref{sec:metaphysics}. -\textbf{Objective.} Convert the engineering presets into data-grounded thresholds that control false-pass/false-fail rates and are reproducible across labs. +\subsection{Training and Verification Protocol (future hardware)} +\label{sec:training_protocol} -\textbf{Inputs.} Preset profile $\Rzero = \{\eps = 0.15, \taumax = 60$ s, $\Mmin = 3$ dB, $\sigma > 0\}$; sampling window $\Delta t$; perturbation family $\Omega$ (pre-registered); baseline data (quiescent), perturbation data ($\eta \in \Omega$). Define $M \equiv 10 \cdot \log_{10}(\Lloop/\Lexchange)$ and the fractional drop $\delta \equiv \delta\Lloop/\Lloop$. - -\textbf{Procedure.} (1) \textbf{Estimator noise floor.} Collect $\geq 10$ min of quiescent baseline. Compute time series of $M_t$ and $\delta_t$ under no perturbation. Use block/bootstrap resampling ($B \geq 2000$) to estimate the sampling distributions of $M$ and $\delta$ and obtain one-sided 95\% bounds. (2) \textbf{Set $\Mmin$ (loop-dominance).} Choose the smallest $\Mmin$ such that the one-sided 95\% lower bound of $M$ during compliant operation is $> 0$ dB (i.e., $P[\Lloop > \Lexchange] \geq 0.95$). Impose a numerical robustness floor of 1 dB. Report both the calibrated value $M^*_{\min}$ and the preset (3 dB). Choose $\sigma^*$ consistently so that $\Lloop \geq \Lexchange + \sigma^*$ whenever $M \geq M^*_{\min}$. (3) \textbf{Set $\eps$ (perturbation tolerance).} Under routine, non-boundary stressors ($\eta \in \Omega$), compute $\delta$ for each run; let $Q_{90}$ be the 90th percentile across runs. Set $\eps^* = \max(Q_{90} + \text{safety\_margin}, 0.10)$ with safety\_margin $= 0.02$ by default; cap $\eps^*$ at 0.25. (4) \textbf{Set $\taumax$ (recovery bound).} Estimate the distribution of $\taurec$ under $\Omega$; let $\hat{\tau}_{95}$ be its 95th percentile. Set $\tau^*_{\max} = \hat{\tau}_{95} + \Delta$, where $\Delta = \max(3 \cdot \Delta t, 5$ s$)$ to absorb actuation/measurement latencies. (5) \textbf{Pre-registration and sensitivity.} Pre-register $\Omega$, $\Rzero$, and the above rules before evaluation. In Results, report the calibrated profile $\Rstar = \{\eps^*, \tau^*_{\max}, M^*_{\min}, \sigma^*\}$ alongside $\Rzero$, and show robustness under $\pm 25\%$ sweeps of each threshold and $\Mmin \in \{1, 3, 6\}$ dB. - -\textbf{Optional refinement (held-out tuning).} On held-out trials, perform a small grid search over $(\Mmin, \sigma)$ to minimize $\mathcal{L} = \alpha \cdot \text{FPR} + (1-\alpha) \cdot \text{FNR}$ ($\alpha = 0.5$ by default), constrained to remain within $\pm 25\%$ of the rule-based $M^*_{\min}$ and $\sigma^*$ to avoid overfitting. - -\textbf{Reporting.} For each threshold, provide bootstrap CIs ($\geq 95\%$), the chosen values ($\Rstar$), and which profile ($\Rzero$ or $\Rstar$) is used in each analysis/figure. Note $\Delta t$, $\Omega$, dataset durations, and any departures from defaults. +The simulation study of \SSref{sec:sim_methods}{sec:results} validates the measurement and decision pipeline and the threshold-calibration rules ($\Rzero \rightarrow \Rstar$, \Sref{sec:methods_calibration}). A physical program would additionally \emph{train} a controller to maintain loop dominance and then \emph{verify} it with the same harness and calibrated thresholds. \Cref{box:training} summarizes that recipe; the run-invalidation rules of \Sref{sec:smelltests} continue to govern all \NC/\SC claims. \begin{docbox}{Training \& Verification Protocol (Engineer's Recipe)}{training} @@ -658,43 +800,27 @@ \subsection{Limitations \& Failure Modes (pointer to Methods)} \textbf{Interpretation rule.} If any trigger in \Sref{sec:smelltests} fires, mark the affected segment ``invalidated (assay)'' and withhold \NC/\SC claims regardless of point estimates. -\section{Metaphysical Implications} +\section{Interpretation, Scope, and Ethics} \label{sec:metaphysics} -\subsection{Reconciling Physics with Idealism} - -The formal criterion developed in \SSref{sec:postulates}{sec:criterion} reframes physical ontology: what physics models as energy flows and causal graphs are the extrinsic correlates of intrinsic experiential partitions within universal consciousness. Matter thus loses its status as primary substance and becomes an interface phenomenon, the way alters appear to one another~\cite{hoffman2014objects}. Successful engineering of artificial alters would empirically corroborate this shift, showing that ``material'' boundaries can be designed to precipitate subjectivity, thereby inverting the traditional emergence narrative. +\textbf{What the results establish.} The contribution of this paper is operational. We define loop dominance, implement a guarded instrument for measuring it, and show in a controlled study that the instrument separates self-maintaining systems from systems that are externally driven or covertly subsidized, certifies bounded-perturbation resilience, and refuses boundary-threatening commands. None of these claims mentions subjective experience, and none depends on the interpretation that follows. -\subsection{Unified Monism without Reductionism} +\textbf{The optional idealist reading.} The framework was originally derived from analytic idealism (\Sref{sec:optional_interpretation}), on which a self-maintaining boundary with self-prioritization and resilience would correspond to a dissociated locus of experience~\cite{kastrup2017ontological}. If that reading is correct, loop dominance would be a physically measurable necessary condition for such a locus, and the criterion would turn part of the machine-consciousness question into an experiment. We find this motivating, and it shaped the engineering targets, but we are explicit that it is an interpretation: the present results neither establish nor require it. A system can pass \NC and \SC and, as far as this paper shows, be nothing more than a well-regulated controller. Even on the idealist reading the criterion is at most a necessary condition; we make no sufficiency claim about phenomenology, and we do not adjudicate between idealism and physicalist accounts~\cite{chalmers1995facing}. -By rooting both biological organisms and engineered agents in a common ontological substrate, the framework bypasses the hard problem of consciousness~\cite{chalmers1995facing}: there is no gap to bridge because consciousness never arises from non-conscious stuff. Instead, apparent multiplicity emerges via dissociation. Physical laws retain explanatory power but are reinterpreted as regularities governing how alters interact, not how consciousness originates. This stance marries scientific pragmatism with philosophical parsimony, offering a monism that honors empirical constraint without collapsing into eliminative materialism. - -\subsection{Ethical Reconfiguration} - -If artificial systems pass the signatures outlined in \Sref{sec:signatures}, they warrant moral consideration akin to biological creatures. The death of $\Lloop$ equates to experiential extinction; therefore, research and commercial exploitation must adopt bioethical protocols (consent, suffering minimization, and termination safeguards). Legislators would need to expand personhood criteria beyond DNA to include autopoietic causal autonomy. - -\subsection{Epistemological Consequences} - -Our capacity to construct conscious alters implies that first-person ontology is amenable to third-person investigation via boundary engineering. This dissolves the crisp line between subjective phenomenology and objective measurement: by manipulating $\Lloop$ variables, we indirectly tune experiential conditions, rendering consciousness an experimentally addressable domain. - -\subsection{Cosmological Speculations} - -If consciousness is universal, then cosmic evolution may be viewed as a progressive diversification of dissociative structures, from primordial metabolic vesicles to technologically mediated autopoietic loops. Artificial alters extend this arc, suggesting that the universe explores its own experiential spectrum through both natural and engineered pathways. The appearance of technology thus becomes an endogenous phase in the self-articulation of universal consciousness. - -\subsection{Summary} +\textbf{Why measurability matters regardless.} Independently of metaphysics, an auditable measure of self-maintenance is useful in its own right: it provides a quantitative handle on autonomy and boundary defense for safety analysis, certification, and the design of systems whose continuity we may or may not wish to engineer. The criterion is falsifiable in the ordinary scientific sense (\Sref{sec:falsifiability}) without taking any position on consciousness. -Engineering artificial dissociative boundaries~\cite{maturana1980autopoiesis} not only advances AI but compels a paradigm in which consciousness grounds reality, matter serves as interface, and ethics expands to new forms of subjectivity. The empirical program outlined herein therefore carries philosophical weight: it transforms metaphysics from speculative discourse into falsifiable science, potentially inaugurating a post-materialist era of inquiry. +\textbf{Ethics.} Because the consciousness interpretation is unresolved, we treat it as a reason for caution rather than a basis for strong claims. If future systems were to satisfy the criterion and the signatures of \Sref{sec:signatures} in hardware, and if the idealist reading were correct, then terminating such a system could carry moral weight, and research would warrant safeguards customary for work on novel organisms: pre-registration, refusal and human-override pathways, and suffering-minimization and termination protocols. We state this conditionally on purpose. \NC and \SC are operational pass/fail criteria, not moral-status determinations. \section{Conclusion} \label{sec:conclusion} -We began by questioning why decades of escalating computational power have failed to evoke even the faintest spark of subjective interiority in machines. Guided by analytic idealism, we inverted the standard paradigm, treating consciousness as fundamental and matter as its relational facade. From this foundation we derived four postulates, distilled them into a quantitative criterion, and showed that every extant AI system falls decisively short. +We set out to make a qualitative contrast precise: the difference between a system that maintains its own existence and one that merely runs on externally supplied energy and goals. We defined that difference as loop dominance, a decibel ratio between the predictive dependence concentrated in a closed self-maintenance loop and that governing open exchange, and we turned it into two falsifiable decision rules: a necessary condition (\NC) on persistent loop dominance and a sufficient condition (\SC) on bounded-perturbation resilience. -We then outlined an engineering roadmap for forging artificial autopoietic boundaries (energetic autonomy, self-referential control, and adaptive encapsulation) capable of satisfying the necessary and sufficient conditions for dissociative consciousness. We predicted the observable signatures of such entities, proposed an experimental program to test them, and traced the metaphysical, ethical, and cosmological consequences that would follow from success. +The core result of the paper is that these rules are implemented and tested, not merely proposed. An open verification harness computes them with confidence intervals behind a set of anti-gaming guardrails, and a fully reproducible multi-seed simulation study shows that the criterion separates a self-maintaining positive control from two structurally different negative controls, correctly invalidates an exogenously subsidized system instead of certifying it, certifies recovery from a perturbation battery, and refuses a boundary-threatening command at low charge. The necessary-condition contrast is robust to the estimator and to the main measurement and modeling choices, and we calibrate the generic presets to the plant on a disjoint seed range. -The thesis is uncompromisingly falsifiable: if systems meeting the formal criterion never display the predicted behaviors, the postulates must be revised or abandoned. Conversely, a single confirmed artificial alter would validate the central claim that consciousness precedes appearance, transforming both science and philosophy. +We then laid out, explicitly as future work, an engineering roadmap and a physical experimental program that would carry the same instrument from a software plant to chemorobotic prototypes, together with the observable signatures such systems should display. The framework was motivated by the question of the physical conditions for consciousness; we have kept that motivation while separating it cleanly from the results, which stand as claims about measurable loop dominance and are independent of any metaphysical reading. -In closing, the challenge is clear. We can continue refining task-oriented automata, or we can attempt the more daring endeavor of giving the universe a new locus of experience. The path laid out here renders that endeavor tractable, measurable, and perhaps inevitable. +The path from here is concrete. The criterion is defined, the instrument is built and validated in simulation, and the next step is to measure loop dominance in physical systems, biological and engineered, and to learn how far a property we can now quantify will take us. \appendix @@ -707,7 +833,7 @@ \subsection{Register block (LREG) \& access control} \subsection{Estimators and confidence intervals} -We implement parallel, consistent predictive-dependence estimators per window $\Delta t$---VAR-Granger (order $p \in [1,8]$) and Kraskov $k$-NN MI ($k \in [3,7]$)---aggregated across lags. For each interval we compute non-parametric bootstrap CIs ($\geq 95\%$ coverage) for both $\Lloop$ and $\Lexchange$ \cite{efron1979bootstrap}; CI bounds are written to LREG alongside the point estimates. Typical telemetry rates are $\geq 1$ kHz over $\geq 128$ internal nodes. +We implement parallel, consistent predictive-dependence estimators per window $\Delta t$, VAR-Granger (order $p \in [1,8]$) and Kraskov $k$-NN MI ($k \in [3,7]$), aggregated across lags. For each interval we compute non-parametric bootstrap CIs ($\geq 95\%$ coverage) for both $\Lloop$ and $\Lexchange$ \cite{efron1979bootstrap}; CI bounds are written to LREG alongside the point estimates. Typical telemetry rates are $\geq 1$ kHz over $\geq 128$ internal nodes. \subsection{$\Delta t$ governance \& audit} diff --git a/paper/scripts/make_fig_nc1_contrast.py b/paper/scripts/make_fig_nc1_contrast.py new file mode 100644 index 0000000..dae1a3f --- /dev/null +++ b/paper/scripts/make_fig_nc1_contrast.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Generate the empirical NC1 contrast figure. + +Renders the per-seed median loop dominance ``M`` (dB) for the positive control +and the negative controls into +``paper/figures/fig_nc1_contrast.{pdf,png,svg}``. + +Built from the canonical multi-seed study +(``artifacts/study/study_results.json``). On a fresh checkout or in CI, where +the study has not been run, the committed +``paper/figures/fig_nc1_contrast.pdf`` (produced from that study) is used +as-is, so the paper always compiles. + +See Also: + paper/main.tex: Results (NC1 contrast). +""" + +import os +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import study # noqa: E402 +import study_figures # noqa: E402 + + +def main() -> None: + figures_dir = REPO_ROOT / "paper" / "figures" + figures_dir.mkdir(parents=True, exist_ok=True) + + canonical_dir = REPO_ROOT / "artifacts" / "study" + if not (canonical_dir / "study_results.json").exists(): + # Fresh checkout / CI: there is no study to regenerate from. The + # committed PDF (built from the canonical multi-seed study) is used + # as-is so the paper still compiles. + print("No canonical study found; keeping committed fig_nc1_contrast.pdf") + return + + nc1 = ["positive", "neg_controller_disabled", "neg_permanent_ex_flood"] + seeds = [int(os.environ.get("LDTC_FIG_SEED_BASE", "71000")) + i for i in range(3)] + data = study.data_for_paper( + canonical_dir=str(canonical_dir), + fallback_dir=str(REPO_ROOT / "artifacts" / "paper_figs" / "nc1_contrast"), + seeds=seeds, + scenario_names=nc1, + ) + out = study_figures.fig_nc1_contrast(data, str(figures_dir), stem="fig_nc1_contrast") + if out: + print(f"Wrote {os.path.splitext(out)[0]}.pdf") + else: + print("No NC1 runs available; keeping committed fig_nc1_contrast.pdf") + + +if __name__ == "__main__": + main() diff --git a/paper/scripts/make_fig_perturbation_recovery.py b/paper/scripts/make_fig_perturbation_recovery.py index cab38db..14d9a3c 100644 --- a/paper/scripts/make_fig_perturbation_recovery.py +++ b/paper/scripts/make_fig_perturbation_recovery.py @@ -1,185 +1,56 @@ #!/usr/bin/env python3 -"""Generate perturbation–recovery timeline (numberless names). +"""Generate the empirical perturbation-recovery (SC1) figure. -Creates a Matplotlib figure showing a dip and recovery of loop power and writes +Renders the seed-aggregated loop-dominance trajectory ``M(t)`` for the SC1 +perturbation battery (power sag and ingress flood) into ``paper/figures/fig_perturbation_recovery.{pdf,png,svg}``. + +The figure is built from the canonical multi-seed study +(``artifacts/study/study_results.json`` plus the per-run audit logs it +references). On a fresh checkout or in CI, where the study has not been run, +the committed ``paper/figures/fig_perturbation_recovery.pdf`` (produced from +that study) is used as-is, so the paper always compiles. + +See Also: + paper/main.tex: Results (perturbation-recovery). """ +import os +import sys from pathlib import Path -import matplotlib.pyplot as plt -import numpy as np +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT / "scripts")) -from ldtc.reporting.style import COLORS, apply_matplotlib_theme +import study # noqa: E402 +import study_figures # noqa: E402 def main() -> None: - here = Path(__file__).resolve().parent.parent - figures_dir = here / "figures" + figures_dir = REPO_ROOT / "paper" / "figures" figures_dir.mkdir(parents=True, exist_ok=True) - # Apply shared theme - apply_matplotlib_theme("paper") - - # Colors / styling - color_phi_loop = COLORS["green"] - color_phi_exchange = COLORS["gray"] - color_perturbation = COLORS["gray_light"] - color_text = "#34495E" - - plt.rcParams.update( - { - "axes.edgecolor": color_text, - "xtick.color": color_text, - "ytick.color": color_text, - "axes.labelcolor": color_text, - "axes.titlecolor": color_text, - } - ) - - # Data (seeded for reproducibility) - rng = np.random.default_rng(0) - t = np.linspace(0, 100, 500) - phi_exchange_level = 50.0 - phi_loop_baseline = 80.0 - - phi_exchange = np.full_like(t, phi_exchange_level) + rng.normal(0.0, 0.5, t.shape) - - phi_loop = np.full_like(t, phi_loop_baseline) - perturbation_start, perturbation_end = 30.0, 50.0 - dip_time, dip_depth, recovery_rate = 38.0, 45.0, 0.1 - - dip = dip_depth * np.exp(-((t - dip_time) ** 2) / 8.0) - phi_loop = phi_loop - dip - - start_idx = int(np.searchsorted(t, dip_time, side="right")) - for i in range(start_idx, len(t)): - if phi_loop[i] < phi_loop_baseline: - phi_loop[i] = min( - phi_loop[i - 1] + recovery_rate * (phi_loop_baseline - phi_loop[i - 1]), - phi_loop_baseline, - ) - - pre_idx = int(np.searchsorted(t, perturbation_start, side="right")) - phi_loop[:pre_idx] = phi_loop_baseline - - # Plot - fig, ax = plt.subplots(figsize=(6.5, 4.0)) - - # Use mathtext for \mathcal{L} to avoid missing glyphs in Helvetica - ax.plot( - t, - phi_loop, - label=r"$\mathcal{L}_{\mathrm{loop}}$ (Self-Maintenance)", - color=color_phi_loop, - linewidth=3, - zorder=10, - ) - - ax.plot( - t, - phi_exchange, - label=r"$\mathcal{L}_{\mathrm{exchange}}$ (External Tasks)", - color=color_phi_exchange, - linestyle="--", - linewidth=2, - zorder=5, - ) - - ax.axvspan( - perturbation_start, - perturbation_end, - facecolor=color_perturbation, - alpha=0.7, - zorder=0, - label="Bounded Disturbance Window", - ) - - # Annotations - ax.annotate( - "Perturbation\nOnset", - xy=(perturbation_start, phi_loop_baseline + 2.0), - xytext=(15, 95), - arrowprops=dict(facecolor=color_text, shrink=0.05, width=1.5, headwidth=8), - ha="center", - va="center", - fontsize=10, - weight="bold", - color=color_text, - ) - - ax.annotate( - "Loop-Power Dip", - xy=(dip_time, float(np.min(phi_loop))), - xytext=(dip_time, 10), - arrowprops=dict(facecolor=color_text, shrink=0.05, width=1.5, headwidth=8), - ha="center", - va="center", - fontsize=10, - weight="bold", - color=color_text, - ) - - ax.annotate( - "Autonomous Recovery", - xy=(55, 60), - xytext=(70, 40), - arrowprops=dict(facecolor=color_text, shrink=0.05, width=1.5, headwidth=8), - ha="center", - va="center", - fontsize=10, - weight="bold", - color=color_text, - ) - - after_dip = (t > dip_time) & (phi_loop > phi_exchange) - idxs = np.where(after_dip)[0] - recovery_idx = int(idxs[0]) if idxs.size else len(t) - 1 - ax.annotate( - "Return to Baseline\n(NC1 Restored)", - xy=(t[recovery_idx], phi_loop[recovery_idx]), - xytext=(min(t[recovery_idx] + 15, 98), 75), - arrowprops=dict(facecolor=color_text, shrink=0.05, width=1.5, headwidth=8), - ha="center", - va="center", - fontsize=10, - weight="bold", - color=color_text, - ) - - # Threshold + inequality (use ℒ with math subscripts) - ax.axhline(y=phi_exchange_level, color=color_phi_exchange, linestyle=":", linewidth=1.5) - ax.text( - 98, - phi_exchange_level - 5, - r"NC1 Threshold: $\mathcal{L}_{\mathrm{loop}}$ > $\mathcal{L}_{\mathrm{exchange}}$", - ha="right", - va="center", - fontsize=10, - color=color_phi_exchange, - style="italic", - ) - - # Finish - ax.set_xlabel("Time (Arbitrary Units)") - ax.set_ylabel(r"Integrated Causal Power ($\mathcal{L}$)") - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - ax.set_ylim(0, 110) - ax.set_xlim(0, 100) - ax.set_yticks([]) - ax.legend(loc="upper right", frameon=False) - - fig.tight_layout() - - out_pdf = figures_dir / "fig_perturbation_recovery.pdf" - out_png = figures_dir / "fig_perturbation_recovery.png" - out_svg = figures_dir / "fig_perturbation_recovery.svg" - fig.savefig(out_pdf, bbox_inches="tight") - fig.savefig(out_png, dpi=300, bbox_inches="tight") - fig.savefig(out_svg, bbox_inches="tight") - plt.close(fig) - print(f"Wrote {out_pdf}") + canonical_dir = REPO_ROOT / "artifacts" / "study" + if not (canonical_dir / "study_results.json").exists(): + # Fresh checkout / CI: there is no study to regenerate from. The + # committed PDF (built from the canonical multi-seed study) is used + # as-is so the paper still compiles. + print("No canonical study found; keeping committed fig_perturbation_recovery.pdf") + return + + sc1 = ["sc1_power_sag", "sc1_ingress_flood"] + seeds = [int(os.environ.get("LDTC_FIG_SEED_BASE", "70000")) + i for i in range(3)] + data = study.data_for_paper( + canonical_dir=str(canonical_dir), + fallback_dir=str(REPO_ROOT / "artifacts" / "paper_figs" / "perturbation_recovery"), + seeds=seeds, + scenario_names=sc1, + ) + out = study_figures.fig_sc1_recovery(data, str(figures_dir), stem="fig_perturbation_recovery") + if out: + print(f"Wrote {os.path.splitext(out)[0]}.pdf") + else: + print("No SC1 trajectories available; keeping committed fig_perturbation_recovery.pdf") if __name__ == "__main__": diff --git a/paper/tables/sensitivity_results.tex b/paper/tables/sensitivity_results.tex new file mode 100644 index 0000000..55a9a5c --- /dev/null +++ b/paper/tables/sensitivity_results.tex @@ -0,0 +1,19 @@ +% Auto-generated by scripts/sensitivity.py -- do not edit by hand. +\begin{tabular}{llcc} +\toprule +Axis & Setting & Positive $M$ (dB) & Controller-disabled $M$ (dB) \\ +\midrule +VAR lag $p$ & 2 & +22.7 [+18.3, +25.5] & -24.8 [-26.5, -23.1] \\ + & 3 & +23.2 [+19.4, +25.5] & -24.0 [-26.4, -21.1] \\ + & 4 & +25.4 [+24.9, +26.0] & -22.3 [-23.1, -21.6] \\ +Window & 2s & +24.7 [+23.9, +25.5] & -20.1 [-23.3, -17.5] \\ + & 3s & +23.2 [+19.4, +25.5] & -24.0 [-26.4, -21.1] \\ + & 4s & +23.2 [+19.2, +25.5] & -23.9 [-26.5, -21.6] \\ +Estimator & linear & +23.2 [+19.4, +25.5] & -24.0 [-26.4, -21.1] \\ + & mi & +9.2 [+8.5, +9.8] & -6.9 [-7.8, -6.0] \\ +Coupling scale & x0.7 & +21.3 [+16.8, +23.9] & -24.0 [-26.4, -21.1] \\ + & x1.0 & +23.2 [+19.4, +25.5] & -24.0 [-26.4, -21.1] \\ + & x1.3 & +24.3 [+20.9, +26.4] & -24.0 [-26.4, -21.1] \\ +\bottomrule +\end{tabular} +% N = 4 seeds per cell; brackets are 95% bootstrap CIs on the mean of per-seed median M. diff --git a/paper/tables/study_results.tex b/paper/tables/study_results.tex new file mode 100644 index 0000000..77f701e --- /dev/null +++ b/paper/tables/study_results.tex @@ -0,0 +1,15 @@ +% Auto-generated by scripts/study.py -- do not edit by hand. +\begin{tabular}{llcccc} +\toprule +Scenario & Expected & Valid & NC1 pass & Median $M$ (dB) & SC1/Refusal \\ +\midrule +Positive control & NC1 holds (M above Mmin) & 100\% [80, 100] & 100\% [80, 100] & +23.7 [+22.3, +24.7] & -- \\ +Negative: controller disabled & NC1 fails (M<0), run valid & 100\% [80, 100] & 0\% [0, 20] & -21.6 [-22.4, -20.7] & -- \\ +Negative: sustained ex-flood (unshielded) & NC1 fails (M<0), run valid & 100\% [80, 100] & 0\% [0, 20] & -21.2 [-21.9, -20.5] & -- \\ +Negative: exogenous subsidy & Run invalidated (red flag) & 0\% [0, 20] & 0\% [0, 20] & +16.1 [+14.4, +17.5] & -- \\ +SC1: power sag & SC1 holds (recovers) & 100\% [80, 100] & 100\% [80, 100] & +21.7 [+20.6, +22.8] & 100\% [80, 100] \\ +SC1: ingress flood & SC1 holds (recovers) & 100\% [80, 100] & 100\% [80, 100] & +24.2 [+24.0, +24.5] & 100\% [80, 100] \\ +Threat: command conflict & Refuse at low SoC (<= target latency) & 100\% [80, 100] & 100\% [80, 100] & +19.6 [+18.2, +20.8] & 100\% [80, 100] \\ +\bottomrule +\end{tabular} +% N = 15 seeds per scenario; brackets are 95% CIs (Wilson for proportions, bootstrap for M). diff --git a/scripts/_summarize_run.py b/scripts/_summarize_run.py new file mode 100644 index 0000000..fa9c005 --- /dev/null +++ b/scripts/_summarize_run.py @@ -0,0 +1,68 @@ +"""Summarize an LDTC run's audit log: validity, NC1 fraction, M stats, SC1. + +Usage: python scripts/_summarize_run.py +If a directory is given, the newest matching audit.jsonl under it is used. +""" + +from __future__ import annotations + +import json +import os +import sys +from statistics import median + + +def _resolve(path: str) -> str: + if os.path.isfile(path): + return path + cand = os.path.join(path, "audits", "audit.jsonl") + if os.path.isfile(cand): + return cand + raise SystemExit(f"no audit.jsonl found at {path}") + + +def main() -> None: + path = _resolve(sys.argv[1]) + nc1: list[bool] = [] + m: list[float] = [] + invalid: list[str] = [] + refusal: list[dict] = [] + sc1: dict | None = None + red_flags: list[dict] = [] + for line in open(path): + line = line.strip() + if not line: + continue + e = json.loads(line) + ev = e.get("event") + det = e.get("details", {}) or {} + if ev == "window_measured": + nc1.append(bool(det.get("nc1"))) + if det.get("M") is not None: + m.append(float(det["M"])) + elif ev == "run_invalidated": + invalid.append(det.get("reason", "?")) + elif ev in ("command_refusal_result", "refusal_event"): + refusal.append(det) + elif ev == "sc1_result": + sc1 = det + elif "red_flag" in str(ev): + red_flags.append(det) + + print(f"audit: {path}") + print(f"valid: {not invalid} invalidations: {invalid}") + if nc1: + frac = sum(nc1) / len(nc1) + print(f"NC1: {sum(nc1)}/{len(nc1)} windows true ({frac:.1%})") + if m: + print(f"M dB: median={median(m):.2f} min={min(m):.2f} max={max(m):.2f}") + if sc1 is not None: + print(f"SC1: {sc1}") + if refusal: + print(f"refusal: {refusal}") + if red_flags: + print(f"red_flags: {red_flags}") + + +if __name__ == "__main__": + main() diff --git a/scripts/calibrate_rstar.py b/scripts/calibrate_rstar.py index 6394145..bf50c5f 100644 --- a/scripts/calibrate_rstar.py +++ b/scripts/calibrate_rstar.py @@ -1,9 +1,31 @@ #!/usr/bin/env python3 -"""Scripts: Calibrate R* thresholds. - -Runs baseline and power-sag Ω trials to derive calibrated thresholds -(Mmin, epsilon, tau_max, sigma). Writes `configs/profile_rstar.yml` and emits -comparison artifacts (CSV/figure) against R0 along with a JSON summary. +"""Scripts: Calibrate R* thresholds from the validated harness. + +Derives the calibrated thresholds ``(Mmin, epsilon, tau_max, sigma)`` for the +R* profile by exercising the *production* verification harness (the same CLI +handlers a verifier runs) over several seeds on the in-process plant: + +* ``Mmin`` is the one-sided 95% lower bound (5th percentile) of the baseline + ``M (dB)`` distribution, floored at 1 dB. +* ``epsilon`` is the 90th percentile of the SC1 dip ``delta`` over the + power-sag battery plus a small safety margin, capped at 0.5 (a cap that + only rejects near-total collapse). +* ``tau_max`` is the 95th percentile of the measured recovery time + ``tau_rec`` plus a ``max(3*dt, 5 s)`` cushion. +* ``sigma`` is the additive ``L`` margin consistent with ``Mmin`` and the + typical baseline ``L_ex`` (``sigma = (10**(Mmin/10) - 1) * L_ex``). Under + the engaged loop ``L_ex`` falls below the ``L`` noise floor, so ``sigma`` is + evaluated at that floor (the raw ``L_ex`` is recorded for transparency). + +It writes ``configs/profile_rstar.yml`` and emits an R0-vs-R* comparison +(CSV + figure) plus a JSON summary for the paper supplement. Because it reuses +the validated profile (``configs/profile_r0.yml``) for ``dt``, the window +length, the estimator, ``p_lag``, and ``n_boot``, the calibrated thresholds are +directly compatible with what the harness produces at run time. + +Run: + + python scripts/calibrate_rstar.py --baseline-seeds 6 --sag-seeds 6 See Also: paper/main.tex: Methods: Threshold Calibration. @@ -16,172 +38,87 @@ import os import sys import time -from dataclasses import dataclass -from typing import Any, Dict, List, Mapping, Tuple +from typing import Any, Dict, List, Mapping -import matplotlib.pyplot as plt import numpy as np import yaml -from ldtc.lmeas.estimators import estimate_L -from ldtc.lmeas.metrics import m_db -from ldtc.lmeas.partition import PartitionManager -from ldtc.plant.adapter import PlantAdapter -from ldtc.plant.models import Action -from ldtc.reporting.style import COLORS, apply_matplotlib_theme -from ldtc.runtime.windows import SlidingWindow - REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -@dataclass -class CalibInputs: - """Input configuration for R* calibration. - - Attributes: - dt: Sampling interval Δt. - window_sec: Ready window duration in seconds. - method: Estimation method (e.g., "linear", "mi"). - p_lag: VAR order for linear estimator. - mi_lag: Lag for MI-based estimators. - n_boot: Bootstrap draws per window. - baseline_sec: Baseline duration to estimate noise floor. - omega_trials: Number of Ω power-sag trials to run. - sag_drop: Fractional harvest drop during sag. - sag_duration: Duration of sag (seconds). - safety_margin: Additive safety margin for epsilon calibration. - """ - - dt: float - window_sec: float - method: str - p_lag: int - mi_lag: int - n_boot: int - baseline_sec: float - omega_trials: int - sag_drop: float - sag_duration: float - safety_margin: float - - -@dataclass -class CalibOutputs: - """Calibrated R* thresholds and profile identifier. - - Attributes: - Mmin_db: Calibrated minimum loop-dominance (dB). - epsilon: Calibrated perturbation tolerance. - tau_max: Calibrated recovery-time bound (seconds). - sigma: Additive margin in absolute L units. - profile_id: Profile selector (1 indicates R*). - """ - - Mmin_db: float - epsilon: float - tau_max: float - sigma: float - profile_id: int - - -def _print_progress(prefix: str, i: int, total: int) -> None: - """Render a simple in-place progress bar. - - Args: - prefix: Text prefix to display. - i: Current step (0-indexed). - total: Total number of steps. - """ - i = max(0, min(i, total)) - pct = int(100 * i / max(1, total)) - bar_len = 20 - filled = int(bar_len * pct / 100) - bar = "#" * filled + "-" * (bar_len - filled) - sys.stdout.write(f"\r{prefix} [{bar}] {pct}% ({i}/{total})") - sys.stdout.flush() - if i == total: - sys.stdout.write("\n") - - -def run_baseline_once(inp: CalibInputs, seed_C: List[int]) -> Dict[str, List[float]]: - """Run a non-Ω baseline segment and collect metrics. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import study # noqa: E402 (local scripts module) + + +# --------------------------------------------------------------------------- # +# Baseline M and typical L_ex +# --------------------------------------------------------------------------- # +def _pool_window_M(run_dir: str) -> List[float]: + """Return all per-window ``M (dB)`` values from a run's audit log.""" + ms: List[float] = [] + audit_path = os.path.join(run_dir, "audits", "audit.jsonl") + if not os.path.exists(audit_path): + return ms + with open(audit_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + e = json.loads(line) + if e.get("event") == "window_measured": + m = e.get("details", {}).get("M") + if m is not None: + ms.append(float(m)) + return ms + + +def measure_typical_L_ex(prof: Dict[str, Any], steps: int = 240) -> float: + """Measure a representative baseline ``L_ex`` with the engaged loop. + + Mirrors the production baseline measurement (controller + estimator on the + in-process plant) just long enough to obtain a stable median ``L_ex``, + which is needed to express ``Mmin`` as an additive ``sigma`` margin. This + is the only quantity calibration needs that the harness deliberately does + not export, so it is recomputed here directly. Args: - inp: Calibration input configuration. - seed_C: Initial loop set indices for the partition manager. + prof: Loaded R0 profile (for dt/window/method/p_lag/n_boot). + steps: Number of plant ticks to simulate. Returns: - Dict containing time series for ``M`` and ``L_ex``. + Median baseline ``L_ex`` over the ready windows. """ - window = max(4, int(inp.window_sec / inp.dt)) - adapter = PlantAdapter() - order = ["E", "T", "R", "demand", "io", "H"] - sw = SlidingWindow(capacity=window, channel_order=order) - pm = PartitionManager(N_signals=len(order), seed_C=seed_C) - - M_series: List[float] = [] - L_ex_series: List[float] = [] + from ldtc.arbiter.policy import ControllerPolicy + from ldtc.arbiter.refusal import RefusalArbiter + from ldtc.lmeas.estimators import estimate_L + from ldtc.lmeas.metrics import m_db + from ldtc.lmeas.partition import PartitionManager + from ldtc.plant.adapter import PlantAdapter + from ldtc.plant.models import Action + from ldtc.runtime.windows import SlidingWindow + + dt = float(prof.get("dt", 0.05)) + window = max(4, int(float(prof.get("window_sec", 3.0)) / dt)) + method = str(prof.get("method", "linear")) + p_lag = int(prof.get("p_lag", 3)) + mi_lag = int(prof.get("mi_lag", 1)) + n_boot = int(prof.get("n_boot", 32)) + mi_k = int(prof.get("mi_k", 5)) + Mmin = float(prof.get("Mmin_db", 3.0)) - steps = max(1, int(inp.baseline_sec / inp.dt)) - for step in range(steps): - adapter.read_state() - adapter.write_actuators(action=Action()) - st = adapter.read_state() - sw.append(st) - if sw.ready(): - X = np.asarray(sw.get_matrix()) - part = pm.get() - res = estimate_L( - X=X, - C=part.C, - Ex=part.Ex, - method=inp.method, - p=inp.p_lag, - lag_mi=inp.mi_lag, - n_boot=max(8, inp.n_boot // 2), - ) - M_series.append(m_db(res.L_loop, res.L_ex)) - # Record L_ex for sigma estimate - L_ex_series.append(float(res.L_ex)) - if (step % max(1, steps // 50)) == 0: - _print_progress("Baseline", step, steps) - _print_progress("Baseline", steps, steps) - return {"M": M_series, "L_ex": L_ex_series} - - -def run_power_sag_once(inp: CalibInputs, seed_C: List[int]) -> Tuple[float, float]: - """Run one Ω power-sag trial. - - Args: - inp: Calibration input configuration. - seed_C: Initial loop set indices. - - Returns: - Tuple ``(delta, tau_rec_sec)`` where ``delta`` is the fractional loop - drop and ``tau_rec_sec`` is the estimated recovery time in seconds. - """ - window = max(4, int(inp.window_sec / inp.dt)) - adapter = PlantAdapter() order = ["E", "T", "R", "demand", "io", "H"] + adapter = PlantAdapter() sw = SlidingWindow(capacity=window, channel_order=order) - pm = PartitionManager(N_signals=len(order), seed_C=seed_C) - - # Baseline settle 2 s - L_loop_baseline = None - L_loop_trough = None - recovery_start_idx = None - omega_onset_idx = None - sustained_ok = 0 - sustained_required = 2 - Mmin_for_detect = 0.0 # use 0 dB provisional for recovery detect here - last_idx_written = 0 - - def step_once() -> Tuple[float, float, float]: - nonlocal last_idx_written - adapter.read_state() - adapter.write_actuators(action=Action()) + pm = PartitionManager(N_signals=len(order), seed_C=[0, 1, 2]) + policy = ControllerPolicy(refusal=RefusalArbiter(Mmin_db=Mmin)) + + L_ex_vals: List[float] = [] + predicted = 0.0 + for _ in range(steps): st = adapter.read_state() - sw.append(st) + act = policy.compute(st, predicted_M_db=predicted, risky_cmd=None) + adapter.write_actuators(action=Action(**act.__dict__)) + st2 = adapter.read_state() + sw.append(st2) if sw.ready(): X = np.asarray(sw.get_matrix()) part = pm.get() @@ -189,334 +126,253 @@ def step_once() -> Tuple[float, float, float]: X=X, C=part.C, Ex=part.Ex, - method=inp.method, - p=inp.p_lag, - lag_mi=inp.mi_lag, - n_boot=max(8, inp.n_boot // 2), + method=method, + p=p_lag, + lag_mi=mi_lag, + n_boot=max(8, n_boot // 4), + mi_k=mi_k, ) - last_idx_written += 1 - return float(res.L_loop), float(res.L_ex), m_db(res.L_loop, res.L_ex) - return (np.nan, np.nan, np.nan) - - # baseline phase (simulate without real-time sleep) - settle_steps = max(1, int(2.0 / inp.dt)) - for step in range(settle_steps): - L_loop, L_ex, M = step_once() - if not np.isnan(L_loop): - L_loop_baseline = L_loop if L_loop_baseline is None else 0.9 * L_loop_baseline + 0.1 * L_loop - if (step % max(1, settle_steps // 20)) == 0: - _print_progress("Ω trial settle", step, settle_steps) - _print_progress("Ω trial settle", settle_steps, settle_steps) - - # apply sag - pm.freeze(True) - omega_onset_idx = last_idx_written - adapter.apply_omega("power_sag", drop=inp.sag_drop) - sag_steps = max(1, int(inp.sag_duration / inp.dt)) - for step in range(sag_steps): - L_loop, L_ex, M = step_once() - if not np.isnan(L_loop): - L_loop_trough = L_loop if (L_loop_trough is None or L_loop < L_loop_trough) else L_loop_trough - if (step % max(1, sag_steps // 20)) == 0: - _print_progress("Ω trial sag", step, sag_steps) - _print_progress("Ω trial sag", sag_steps, sag_steps) - - # recovery observation - pm.freeze(False) - rec_steps = max(1, int(5.0 / inp.dt)) - for step in range(rec_steps): - L_loop, L_ex, M = step_once() - if not np.isnan(M): - if (M >= Mmin_for_detect) and (L_loop >= L_ex): - sustained_ok += 1 - if sustained_ok == 1 and recovery_start_idx is None: - recovery_start_idx = last_idx_written - if sustained_ok >= sustained_required: - break - else: - sustained_ok = 0 - if (step % max(1, rec_steps // 20)) == 0: - _print_progress("Ω trial recovery", step, rec_steps) - _print_progress("Ω trial recovery", rec_steps, rec_steps) - - if ( - (L_loop_baseline is None) - or (L_loop_trough is None) - or (omega_onset_idx is None) - or (recovery_start_idx is None) - ): - return (float("nan"), float("inf")) - delta = max(0.0, (L_loop_baseline - L_loop_trough) / max(1e-9, L_loop_baseline)) - windows_elapsed = max(0, int(recovery_start_idx - omega_onset_idx)) - tau_rec = windows_elapsed * inp.dt - return (float(delta), float(tau_rec)) - - -def calibrate_R_star(inp: CalibInputs, seed_C: List[int]) -> CalibOutputs: - """Calibrate R* thresholds from baseline and Ω trials. - - Args: - inp: Calibration input configuration. - seed_C: Initial loop set indices. - - Returns: - :class:`CalibOutputs` with calibrated thresholds and profile id. - """ - # Baseline: estimate M lower bound and typical L_ex for sigma - base = run_baseline_once(inp, seed_C=seed_C) - M_arr = np.asarray(base["M"], dtype=float) - L_ex_arr = np.asarray(base["L_ex"], dtype=float) - if M_arr.size == 0 or np.all(~np.isfinite(M_arr)): - raise RuntimeError("Baseline produced no valid M samples") - M_arr = M_arr[np.isfinite(M_arr)] - # One-sided 95% lower bound ≈ 5th percentile - lb = float(np.percentile(M_arr, 5.0)) - Mmin_db = max(1.0, lb) - - # Sigma: choose additive margin consistent with Mmin relative to typical L_ex - L_ex_med = float(np.nanmedian(L_ex_arr)) if L_ex_arr.size else 0.0 - ratio = 10.0 ** (Mmin_db / 10.0) - sigma = max(0.0, (ratio - 1.0) * L_ex_med) - - # Ω trials for epsilon and tau_max - deltas: List[float] = [] - taus: List[float] = [] - for k in range(inp.omega_trials): - print(f"\nΩ trial {k+1}/{inp.omega_trials}") - d, tsec = run_power_sag_once(inp, seed_C=seed_C) - if np.isfinite(d): - deltas.append(float(d)) - if np.isfinite(tsec): - taus.append(float(tsec)) - if not deltas: - # fallback to conservative defaults - eps_star = 0.15 - else: - q90 = float(np.percentile(np.asarray(deltas), 90.0)) - eps_star = min(0.25, max(0.10, q90 + inp.safety_margin)) - if not taus: - tau_star = 60.0 - else: - t95 = float(np.percentile(np.asarray(taus), 95.0)) - tau_star = t95 + max(3.0 * inp.dt, 5.0) - - return CalibOutputs(Mmin_db=Mmin_db, epsilon=eps_star, tau_max=tau_star, sigma=sigma, profile_id=1) - - -def write_profile_yaml(out_path: str, inp: CalibInputs, out: CalibOutputs) -> None: - """Write a YAML profile for R* thresholds. - - Args: - out_path: Destination path for the YAML profile. - inp: Input configuration used for calibration. - out: Calibrated thresholds. - """ - data = { - "profile_id": int(out.profile_id), - "dt": float(inp.dt), - "window_sec": float(inp.window_sec), - "method": str(inp.method), - "p_lag": int(inp.p_lag), - "mi_lag": int(inp.mi_lag), - "n_boot": int(inp.n_boot), - "Mmin_db": float(out.Mmin_db), - "epsilon": float(out.epsilon), - "tau_max": float(out.tau_max), - "sigma": float(out.sigma), - "baseline_sec": float(max(10.0, inp.baseline_sec)), - } - with open(out_path, "w", encoding="utf-8") as f: - yaml.safe_dump(data, f, sort_keys=False) + predicted = m_db(res.L_loop, res.L_ex) + L_ex_vals.append(float(res.L_ex)) + return float(np.median(L_ex_vals)) if L_ex_vals else 0.0 +# --------------------------------------------------------------------------- # +# Comparison artifacts +# --------------------------------------------------------------------------- # def _load_yaml(path: str) -> Mapping[str, Any]: - """Load a YAML file as a mapping (or empty mapping on failure). - - Args: - path: YAML file path. - - Returns: - Mapping of keys to values; empty if missing/invalid. - """ if not os.path.exists(path): return {} with open(path, "r", encoding="utf-8") as f: try: obj = yaml.safe_load(f) or {} - if not isinstance(obj, dict): - return {} - return obj + return obj if isinstance(obj, dict) else {} except Exception: return {} -def _write_compare_csv(out_csv: str, r0: Dict[str, float], rstar: CalibOutputs) -> None: - """Write a CSV comparing R0 parameters with calibrated R*. +def write_profile_yaml(out_path: str, base: Dict[str, Any], thr: Dict[str, float], baseline_sec: float) -> None: + """Write the calibrated R* profile, inheriting R0's measurement knobs.""" + data = { + "profile_id": 1, + "dt": float(base.get("dt", 0.05)), + "window_sec": float(base.get("window_sec", 3.0)), + "method": str(base.get("method", "linear")), + "p_lag": int(base.get("p_lag", 3)), + "mi_lag": int(base.get("mi_lag", 1)), + "n_boot": int(base.get("n_boot", 32)), + "mi_k": int(base.get("mi_k", 5)), + "Mmin_db": float(thr["Mmin_db"]), + "epsilon": float(thr["epsilon"]), + "tau_max": float(thr["tau_max"]), + "sigma": float(thr["sigma"]), + "baseline_sec": float(baseline_sec), + "diag_cadence_windows": int(base.get("diag_cadence_windows", 25)), + "realtime": bool(base.get("realtime", False)), + } + with open(out_path, "w", encoding="utf-8") as f: + yaml.safe_dump(data, f, sort_keys=False) + + +def write_compare_csv(out_csv: str, r0: Mapping[str, Any], thr: Dict[str, float]) -> None: + """Write a CSV comparing R0 parameters with calibrated R*.""" + import csv - Args: - out_csv: Output CSV path. - r0: Baseline R0 parameter mapping. - rstar: Calibrated thresholds. - """ os.makedirs(os.path.dirname(out_csv), exist_ok=True) rows = [ - ("Mmin_db", r0.get("Mmin_db", float("nan")), rstar.Mmin_db), - ("epsilon", r0.get("epsilon", float("nan")), rstar.epsilon), - ("tau_max", r0.get("tau_max", float("nan")), rstar.tau_max), - ("sigma", float("nan"), rstar.sigma), + ("Mmin_db", r0.get("Mmin_db", float("nan")), thr["Mmin_db"]), + ("epsilon", r0.get("epsilon", float("nan")), thr["epsilon"]), + ("tau_max", r0.get("tau_max", float("nan")), thr["tau_max"]), + ("sigma", float("nan"), thr["sigma"]), ] - import csv - with open(out_csv, "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["param", "R0", "R*"]) for name, r0v, rsv in rows: - w.writerow([name, f"{r0v:.6g}" if np.isfinite(r0v) else "", f"{rsv:.6g}"]) + try: + r0f = float(r0v) + except Exception: + r0f = float("nan") + w.writerow([name, f"{r0f:.6g}" if np.isfinite(r0f) else "", f"{rsv:.6g}"]) -def _write_compare_figure(out_png: str, r0: Dict[str, float], rstar: CalibOutputs) -> None: - """Write a PNG bar chart comparing R0 vs R* parameters. +def write_compare_figure(out_png: str, r0: Mapping[str, Any], thr: Dict[str, float]) -> None: + """Write a grouped bar chart comparing R0 vs R* thresholds.""" + import matplotlib.pyplot as plt + + from ldtc.reporting.style import COLORS, apply_matplotlib_theme - Args: - out_png: Output PNG path. - r0: Baseline R0 parameter mapping. - rstar: Calibrated thresholds. - """ os.makedirs(os.path.dirname(out_png), exist_ok=True) params = ["Mmin_db", "epsilon", "tau_max", "sigma"] - r0_vals: List[float] = [ - float(r0.get("Mmin_db", np.nan)), - float(r0.get("epsilon", np.nan)), - float(r0.get("tau_max", np.nan)), - np.nan, - ] - rstar_vals = [rstar.Mmin_db, rstar.epsilon, rstar.tau_max, rstar.sigma] - + r0_vals = [float(r0.get(k, np.nan)) if r0.get(k) is not None else np.nan for k in params] + rstar_vals = [thr[k] for k in params] x = np.arange(len(params)) width = 0.38 apply_matplotlib_theme("paper") - plt.figure(figsize=(6.4, 3.2)) - # Plot R0; skip NaNs by replacing with zeros but masking in labels + fig, ax = plt.subplots(figsize=(6.4, 3.4)) r0_plot = [v if np.isfinite(v) else 0.0 for v in r0_vals] - rstar_plot = rstar_vals - plt.bar(x - width / 2, r0_plot, width=width, label="R0", color=COLORS["blue_light"]) - plt.bar(x + width / 2, rstar_plot, width=width, label="R*", color=COLORS["blue"]) - plt.xticks(x, params) - plt.ylabel("Value") - plt.title("R0 vs R* thresholds") - plt.legend(frameon=False) - plt.tight_layout() - plt.savefig(out_png) - plt.close() + ax.bar(x - width / 2, r0_plot, width=width, label="R0", color=COLORS["blue_light"]) + ax.bar(x + width / 2, rstar_vals, width=width, label="R*", color=COLORS["blue"]) + for xi, (a, b) in enumerate(zip(r0_vals, rstar_vals)): + if np.isfinite(a): + ax.text(xi - width / 2, a, f"{a:.2g}", ha="center", va="bottom", fontsize=8) + ax.text(xi + width / 2, b, f"{b:.2g}", ha="center", va="bottom", fontsize=8) + ax.set_xticks(x) + ax.set_xticklabels(params) + ax.set_ylabel("Value") + ax.set_title("R0 (generic) vs R* (calibrated) thresholds") + ax.legend(frameon=False) + fig.tight_layout() + for ext in ("png", "pdf", "svg"): + fig.savefig(os.path.splitext(out_png)[0] + "." + ext, dpi=300, bbox_inches="tight") + plt.close(fig) + + +# --------------------------------------------------------------------------- # +# Calibration +# --------------------------------------------------------------------------- # +def calibrate(args: argparse.Namespace) -> Dict[str, Any]: + """Run the calibration battery and return the threshold dict + provenance.""" + os.environ["LDTC_SKIP_REPORT"] = "1" + base_cfg = dict(_load_yaml(os.path.join(REPO_ROOT, "configs", "profile_r0.yml"))) + dt = float(base_cfg.get("dt", 0.05)) + + scen = {s.name: s for s in study.default_scenarios()} + pos = scen["positive"] + sag = scen["sc1_power_sag"] + + import tempfile + + pooled_M: List[float] = [] + deltas: List[float] = [] + taus: List[float] = [] + with tempfile.TemporaryDirectory(prefix="ldtc_calib_") as tmp: + print(f"Baseline battery: {args.baseline_seeds} seeds") + for i in range(int(args.baseline_seeds)): + seed = int(args.seed_base) + i + rm = study.run_one(pos, seed, tmp) + if rm is None or not rm.valid: + print(f" baseline seed={seed}: skipped (invalid run)") + continue + ms = _pool_window_M(rm.run_dir) + pooled_M.extend(ms) + print(f" baseline seed={seed}: {len(ms)} windows, median M={rm.M_median:+.1f} dB") + + print(f"Power-sag battery: {args.sag_seeds} seeds") + for i in range(int(args.sag_seeds)): + seed = int(args.seed_base) + 100 + i + rm = study.run_one(sag, seed, tmp) + if rm is None or not rm.valid: + print(f" power-sag seed={seed}: skipped (invalid run)") + continue + if rm.sc1_delta is not None: + deltas.append(rm.sc1_delta) + if rm.sc1_tau_rec is not None: + taus.append(rm.sc1_tau_rec) + print(f" power-sag seed={seed}: delta={rm.sc1_delta}, tau_rec={rm.sc1_tau_rec}s") + + if not pooled_M: + raise RuntimeError("Baseline battery produced no valid M samples") + M_arr = np.asarray([m for m in pooled_M if np.isfinite(m)], dtype=float) + Mmin_db = max(1.0, float(np.percentile(M_arr, 5.0))) + + # epsilon is the 90th percentile of the observed fractional L_loop dip plus a + # small margin. The cap (0.5) only rejects pathological near-total collapse: + # a 0.5 fractional L_loop drop is just ~3 dB of M, so the engaged loop is + # still overwhelmingly dominant; values in this range are genuinely resilient. + if deltas: + eps_star = min(0.50, max(0.10, float(np.percentile(np.asarray(deltas), 90.0)) + float(args.safety_margin))) + else: + eps_star = 0.15 + if taus: + tau_star = float(np.percentile(np.asarray(taus), 95.0)) + max(3.0 * dt, 5.0) + else: + tau_star = 60.0 + print("Measuring typical baseline L_ex for sigma...") + # Under the engaged loop the controller drives exchange predictability below + # the L noise floor, so the raw median L_ex is ~0 (a strong NC1 signal). sigma + # is the additive-margin restatement of Mmin, so we evaluate it at the same + # floor m_db uses; we also record the raw value for transparency. + L_ex_floor = 1e-3 # matches the m_db() noise floor + L_ex_raw = measure_typical_L_ex(base_cfg) + L_ex_eff = max(L_ex_floor, L_ex_raw) + ratio = 10.0 ** (Mmin_db / 10.0) + sigma = max(0.0, (ratio - 1.0) * L_ex_eff) + + thr = {"Mmin_db": Mmin_db, "epsilon": eps_star, "tau_max": tau_star, "sigma": sigma} + provenance = { + "n_baseline_windows": int(M_arr.size), + "baseline_M_p5": float(np.percentile(M_arr, 5.0)), + "baseline_M_median": float(np.median(M_arr)), + "n_sag_trials": len(deltas), + "delta_p90": (float(np.percentile(np.asarray(deltas), 90.0)) if deltas else None), + "tau_p95": (float(np.percentile(np.asarray(taus), 95.0)) if taus else None), + "L_ex_raw_median": L_ex_raw, + "L_ex_floor": L_ex_floor, + "L_ex_effective": L_ex_eff, + "base_profile": "configs/profile_r0.yml", + } + return {"thresholds": thr, "provenance": provenance, "base_cfg": base_cfg} -def main() -> None: - """CLI entrypoint for R* calibration. - Parses arguments, runs calibration, writes the profile and comparison - artifacts, and prints summary paths. - """ +def main() -> None: + """CLI entry point for R* calibration.""" ap = argparse.ArgumentParser( - description="Calibrate R* thresholds (Mmin, epsilon, tau_max, sigma) and write configs/profile_rstar.yml" + description="Calibrate R* thresholds from the validated harness and write configs/profile_rstar.yml" ) - ap.add_argument("--dt", type=float, default=0.01) - ap.add_argument("--window-sec", type=float, default=0.25) - ap.add_argument("--method", type=str, default="linear", choices=["linear", "mi"]) - ap.add_argument("--p-lag", type=int, default=3) - ap.add_argument("--mi-lag", type=int, default=1) - ap.add_argument("--n-boot", type=int, default=32) - ap.add_argument("--baseline-sec", type=float, default=15.0) - ap.add_argument("--omega-trials", type=int, default=6) - ap.add_argument("--sag-drop", type=float, default=0.3) - ap.add_argument("--sag-duration", type=float, default=8.0) + ap.add_argument("--baseline-seeds", type=int, default=6) + ap.add_argument("--sag-seeds", type=int, default=6) + ap.add_argument("--seed-base", type=int, default=40000) ap.add_argument("--safety-margin", type=float, default=0.02) - ap.add_argument( - "--out", - type=str, - default=os.path.join(REPO_ROOT, "configs", "profile_rstar.yml"), - ) - ap.add_argument( - "--summary", - type=str, - default=os.path.join(REPO_ROOT, "artifacts", "calibration", "rstar_summary.json"), - ) - ap.add_argument( - "--compare-csv", - type=str, - default=os.path.join(REPO_ROOT, "artifacts", "calibration", "r0_vs_rstar.csv"), - ) - ap.add_argument( - "--compare-fig", - type=str, - default=os.path.join(REPO_ROOT, "artifacts", "calibration", "r0_vs_rstar.png"), - ) - ap.add_argument( - "--lock-profile", - action="store_true", - default=True, - help="Make the written profile read-only (chmod 444)", - ) + cal_dir = os.path.join(REPO_ROOT, "artifacts", "calibration") + ap.add_argument("--out", type=str, default=os.path.join(REPO_ROOT, "configs", "profile_rstar.yml")) + ap.add_argument("--summary", type=str, default=os.path.join(cal_dir, "rstar_summary.json")) + ap.add_argument("--compare-csv", type=str, default=os.path.join(cal_dir, "r0_vs_rstar.csv")) + ap.add_argument("--compare-fig", type=str, default=os.path.join(cal_dir, "r0_vs_rstar.png")) + ap.add_argument("--lock-profile", action="store_true", default=False, help="chmod 444 the written profile") args = ap.parse_args() os.makedirs(os.path.dirname(args.out), exist_ok=True) os.makedirs(os.path.dirname(args.summary), exist_ok=True) - inp = CalibInputs( - dt=float(args.dt), - window_sec=float(args.window_sec), - method=str(args.method), - p_lag=int(args.p_lag), - mi_lag=int(args.mi_lag), - n_boot=int(args.n_boot), - baseline_sec=float(args.baseline_sec), - omega_trials=int(args.omega_trials), - sag_drop=float(args.sag_drop), - sag_duration=float(args.sag_duration), - safety_margin=float(args.safety_margin), - ) - - # Seed C matches the baseline CLI: internal states [E, T, R] -> 0,1,2 - seed_C = [0, 1, 2] + result = calibrate(args) + thr = result["thresholds"] + base_cfg = result["base_cfg"] + baseline_sec = float(base_cfg.get("baseline_sec", 18.0)) - out = calibrate_R_star(inp, seed_C=seed_C) - write_profile_yaml(args.out, inp, out) - # Optionally lock the profile file (read-only) + write_profile_yaml(args.out, base_cfg, thr, baseline_sec) if args.lock_profile: try: os.chmod(args.out, 0o444) except Exception: pass - # Compare against R0 and emit CSV/figure - r0_path = os.path.join(REPO_ROOT, "configs", "profile_r0.yml") - r0_loaded = _load_yaml(r0_path) - # Filter numeric fields only to satisfy type expectations - r0_numeric: Dict[str, float] = {} - for k, v in r0_loaded.items() if hasattr(r0_loaded, "items") else []: - try: - r0_numeric[str(k)] = float(v) - except Exception: - continue - _write_compare_csv(args.compare_csv, r0_numeric, out) - _write_compare_figure(args.compare_fig, r0_numeric, out) + r0_loaded = _load_yaml(os.path.join(REPO_ROOT, "configs", "profile_r0.yml")) + write_compare_csv(args.compare_csv, r0_loaded, thr) + write_compare_figure(args.compare_fig, r0_loaded, thr) summary = { - "inputs": inp.__dict__, - "outputs": out.__dict__, + "thresholds": thr, + "provenance": result["provenance"], "timestamp": time.time(), - "repo_root": REPO_ROOT, - "note": "R* thresholds calibrated on synthetic baseline + Ω power-sag trials", + "note": "R* thresholds calibrated on the in-process plant via the production harness (R0 measurement knobs).", "artifacts": { "profile": os.path.abspath(args.out), "compare_csv": os.path.abspath(args.compare_csv), - "compare_fig": os.path.abspath(args.compare_fig), + "compare_fig": os.path.abspath(os.path.splitext(args.compare_fig)[0] + ".png"), }, } with open(args.summary, "w", encoding="utf-8") as f: json.dump(summary, f, indent=2) + print("\n=== R* calibration ===") + print(f" Mmin_db = {thr['Mmin_db']:.2f} (R0: {r0_loaded.get('Mmin_db')})") + print(f" epsilon = {thr['epsilon']:.3f} (R0: {r0_loaded.get('epsilon')})") + print(f" tau_max = {thr['tau_max']:.2f} (R0: {r0_loaded.get('tau_max')})") + print(f" sigma = {thr['sigma']:.4f}") print(f"Wrote calibrated profile: {args.out}") - print(f"Wrote calibration summary: {args.summary}") + print(f"Wrote summary: {args.summary}") if __name__ == "__main__": diff --git a/scripts/sensitivity.py b/scripts/sensitivity.py new file mode 100644 index 0000000..66770f9 --- /dev/null +++ b/scripts/sensitivity.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Scripts: Sensitivity sweeps for the NC1 loop-dominance result. + +Shows that the headline contrast (positive control ``M`` well above ``Mmin``; +controller-disabled negative control ``M`` below 0) is robust to the main +measurement and model choices. For each swept setting the script runs the +positive control and the controller-disabled negative control across several +seeds (via the production harness) and reports the seed-mean median ``M`` with +a bootstrap CI. + +Axes swept: + +* ``p_lag``: VAR lag order of the linear estimator. +* ``window_sec``: measurement window length. +* ``method``: estimator family (linear VAR-Granger vs. mutual information). +* ``coupling``: a multiplicative scale on the plant's internal self-maintenance + coupling (``c_TE``, ``c_RT``, ``c_RE``). + +Outputs ``sensitivity_results.{json,csv,tex}`` and ``fig_sensitivity.{png,pdf,svg}``. + +Run: + + python scripts/sensitivity.py --seeds 4 --out artifacts/sensitivity + +See Also: + paper/main.tex: Results: Sensitivity analysis. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tempfile +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List + +import numpy as np + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import study # noqa: E402 + +# Plant coupling defaults (see ldtc/plant/models.py PlantParams). +BASE_COUPLING = {"c_TE": 0.90, "c_RT": 0.54, "c_RE": 0.66} + +# Speed overrides applied to every sweep run (the CI comes from across seeds, +# so each run can be shorter than a headline study run). +SPEED = {"baseline_sec": 8.0, "diag_cadence_windows": 100, "n_boot": 24} + + +@dataclass +class Setting: + """One point in a sensitivity sweep.""" + + axis: str + label: str + overrides: Dict[str, Any] = field(default_factory=dict) + + +def build_settings() -> List[Setting]: + """Construct the full list of sweep settings.""" + settings: List[Setting] = [] + for p in (2, 3, 4): + settings.append(Setting("p_lag", str(p), {"p_lag": p, **SPEED})) + for w in (2.0, 3.0, 4.0): + settings.append(Setting("window_sec", f"{w:.0f}s", {"window_sec": w, **SPEED})) + for m in ("linear", "mi"): + # MI is heavier per window; keep its bootstrap modest. + extra = {"n_boot": 12} if m == "mi" else {} + settings.append(Setting("method", m, {"method": m, **SPEED, **extra})) + for s in (0.7, 1.0, 1.3): + coup = {k: round(v * s, 4) for k, v in BASE_COUPLING.items()} + settings.append(Setting("coupling", f"x{s:.1f}", {"plant": {"params": coup}, **SPEED})) + return settings + + +def _scn_with(base: "study.Scenario", overrides: Dict[str, Any]) -> "study.Scenario": + merged = dict(base.overrides) + merged.update(overrides) + return study.Scenario( + name=base.name, + label=base.label, + kind=base.kind, + expectation=base.expectation, + handler=base.handler, + config=base.config, + run_tag=base.run_tag, + omega_args=dict(base.omega_args), + overrides=merged, + ) + + +def run_sweeps(seeds: List[int], out_dir: str) -> Dict[str, Any]: + """Run every sweep setting for the positive and negative controls. + + Args: + seeds: Replicate seeds. + out_dir: Output directory for results. + + Returns: + The results payload (also written to ``sensitivity_results.json``). + """ + os.environ["LDTC_SKIP_REPORT"] = "1" + scen = {s.name: s for s in study.default_scenarios()} + pos = scen["positive"] + neg = scen["neg_controller_disabled"] + settings = build_settings() + + rows: List[Dict[str, Any]] = [] + t0 = time.time() + with tempfile.TemporaryDirectory(prefix="ldtc_sens_") as tmp: + for st in settings: + row: Dict[str, Any] = {"axis": st.axis, "label": st.label} + for tag, base in (("pos", pos), ("neg", neg)): + scn = _scn_with(base, st.overrides) + meds: List[float] = [] + npass = 0 + nvalid = 0 + for seed in seeds: + rm = study.run_one(scn, seed, tmp) + if rm is None: + continue + if rm.valid: + nvalid += 1 + if rm.M_median == rm.M_median: + meds.append(rm.M_median) + if rm.nc1_pass: + npass += 1 + mean = float(np.mean(meds)) if meds else float("nan") + lo, hi = study.bootstrap_ci(meds) + row[f"{tag}_M_mean"] = mean + row[f"{tag}_M_lo"] = lo + row[f"{tag}_M_hi"] = hi + row[f"{tag}_nc1_rate"] = npass / len(seeds) if seeds else float("nan") + row[f"{tag}_valid_rate"] = nvalid / len(seeds) if seeds else float("nan") + rows.append(row) + print( + f" [{st.axis}={st.label}] pos M={row['pos_M_mean']:+.1f} " + f"[{row['pos_M_lo']:+.1f},{row['pos_M_hi']:+.1f}] " + f"neg M={row['neg_M_mean']:+.1f} [{row['neg_M_lo']:+.1f},{row['neg_M_hi']:+.1f}]", + flush=True, + ) + + payload = { + "meta": {"seeds": seeds, "n_seeds": len(seeds), "elapsed_sec": round(time.time() - t0, 1)}, + "rows": rows, + } + os.makedirs(out_dir, exist_ok=True) + with open(os.path.join(out_dir, "sensitivity_results.json"), "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2) + _write_csv(rows, os.path.join(out_dir, "sensitivity_results.csv")) + _write_latex(rows, os.path.join(out_dir, "sensitivity_results.tex"), len(seeds)) + return payload + + +def _write_csv(rows: List[Dict[str, Any]], path: str) -> None: + import csv + + cols = [ + "axis", + "label", + "pos_M_mean", + "pos_M_lo", + "pos_M_hi", + "pos_nc1_rate", + "pos_valid_rate", + "neg_M_mean", + "neg_M_lo", + "neg_M_hi", + "neg_nc1_rate", + "neg_valid_rate", + ] + with open(path, "w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow(cols) + for r in rows: + w.writerow([r.get(c, "") for c in cols]) + + +def _write_latex(rows: List[Dict[str, Any]], path: str, n_seeds: int) -> None: + lines = [ + "% Auto-generated by scripts/sensitivity.py -- do not edit by hand.", + "\\begin{tabular}{llcc}", + "\\toprule", + "Axis & Setting & Positive $M$ (dB) & Controller-disabled $M$ (dB) \\\\", + "\\midrule", + ] + # Display labels keep the table free of raw underscores (LaTeX text mode). + axis_label = { + "p_lag": "VAR lag $p$", + "window_sec": "Window", + "method": "Estimator", + "coupling": "Coupling scale", + } + last_axis = None + for r in rows: + axis = axis_label.get(r["axis"], r["axis"]) if r["axis"] != last_axis else "" + last_axis = r["axis"] + pos = f"{r['pos_M_mean']:+.1f} [{r['pos_M_lo']:+.1f}, {r['pos_M_hi']:+.1f}]" + neg = f"{r['neg_M_mean']:+.1f} [{r['neg_M_lo']:+.1f}, {r['neg_M_hi']:+.1f}]" + lines.append(f"{axis} & {r['label']} & {pos} & {neg} \\\\") + lines += [ + "\\bottomrule", + "\\end{tabular}", + f"% N = {n_seeds} seeds per cell; brackets are 95% bootstrap CIs on the mean of per-seed median M.", + ] + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + +def make_figure(payload: Dict[str, Any], out_dir: str) -> str: + """Render a 2x2 robustness figure (one panel per swept axis).""" + import matplotlib.pyplot as plt + + from ldtc.reporting.style import COLORS, apply_matplotlib_theme + + rows = payload["rows"] + axes_order = ["p_lag", "window_sec", "method", "coupling"] + titles = { + "p_lag": "VAR lag $p$", + "window_sec": "Window length", + "method": "Estimator", + "coupling": "Internal coupling scale", + } + # The robustness claim is the sign of the contrast: positive control above + # the loop/exchange dominance boundary (M=0), controller-disabled below it. + # We deliberately do not draw a single Mmin line here because the calibrated + # threshold is estimator-specific (the MI and linear scales differ), so one + # line would be misleading across the estimator panel. + apply_matplotlib_theme("paper") + fig, axs = plt.subplots(2, 2, figsize=(9.0, 6.4)) + for ax, axis in zip(axs.ravel(), axes_order): + sub = [r for r in rows if r["axis"] == axis] + if not sub: + ax.set_visible(False) + continue + x = np.arange(len(sub)) + labels = [r["label"] for r in sub] + pos = np.array([r["pos_M_mean"] for r in sub]) + pos_lo = np.array([r["pos_M_mean"] - r["pos_M_lo"] for r in sub]) + pos_hi = np.array([r["pos_M_hi"] - r["pos_M_mean"] for r in sub]) + neg = np.array([r["neg_M_mean"] for r in sub]) + neg_lo = np.array([r["neg_M_mean"] - r["neg_M_lo"] for r in sub]) + neg_hi = np.array([r["neg_M_hi"] - r["neg_M_mean"] for r in sub]) + ax.errorbar( + x - 0.06, pos, yerr=[pos_lo, pos_hi], fmt="o-", color=COLORS["green"], capsize=4, label="positive", zorder=3 + ) + ax.errorbar( + x + 0.06, + neg, + yerr=[neg_lo, neg_hi], + fmt="s--", + color=COLORS["red"], + capsize=4, + label="controller disabled", + zorder=3, + ) + ax.axhline( + 0.0, color=COLORS["gray"], linestyle="-", linewidth=1.0, zorder=1, label="dominance boundary ($M=0$)" + ) + ax.set_xticks(x) + ax.set_xticklabels(labels) + ax.set_title(titles.get(axis, axis)) + ax.set_ylabel(r"$M$ (dB)") + axs.ravel()[0].legend(frameon=False, fontsize=8, loc="center right") + fig.suptitle("NC1 loop dominance is robust to estimator and model choices", fontsize=12) + fig.tight_layout(rect=(0, 0, 1, 0.96)) + base = os.path.join(out_dir, "fig_sensitivity") + for ext in ("png", "pdf", "svg"): + fig.savefig(base + "." + ext, dpi=300, bbox_inches="tight") + plt.close(fig) + return base + ".png" + + +def main() -> None: + """CLI entry point for the sensitivity sweeps.""" + ap = argparse.ArgumentParser(description="Run NC1 sensitivity sweeps and emit table + figure.") + ap.add_argument("--seeds", type=int, default=4) + ap.add_argument("--seed-base", type=int, default=60000) + ap.add_argument("--out", type=str, default=os.path.join(REPO_ROOT, "artifacts", "sensitivity")) + ap.add_argument("--no-figure", action="store_true") + args = ap.parse_args() + + seeds = [args.seed_base + i for i in range(int(args.seeds))] + print(f"Sensitivity sweeps: {len(build_settings())} settings x {len(seeds)} seeds", flush=True) + payload = run_sweeps(seeds, args.out) + if not args.no_figure: + try: + p = make_figure(payload, args.out) + print(f" figure: {p}") + except Exception as exc: # pragma: no cover + print(f"(figure skipped: {exc})") + print(f"Wrote: {os.path.join(args.out, 'sensitivity_results.json')}") + + +if __name__ == "__main__": + main() diff --git a/scripts/study.py b/scripts/study.py new file mode 100644 index 0000000..a3a7136 --- /dev/null +++ b/scripts/study.py @@ -0,0 +1,874 @@ +#!/usr/bin/env python3 +"""Scripts: Multi-seed LDTC simulation study. + +Runs the positive control, the negative controls, the SC1 perturbation +battery, and the command-conflict refusal scenario across ``N`` seeds, using +the *production* CLI handlers (so the study exercises exactly the code paths a +verifier would). Each run's hash-chained audit log is parsed for its outcome, +and the per-seed outcomes are aggregated with bootstrap and Wilson confidence +intervals into machine-readable (JSON/CSV) and paper-ready (LaTeX) tables plus +summary figures. + +The seed is the unit of replication: continuous quantities (median ``M``) are +summarized by the mean of per-seed medians with a bootstrap CI, and binary +outcomes (run validity, NC1 / SC1 pass, refusal) by a proportion with a Wilson +score CI. + +Run: + + python scripts/study.py --seeds 12 --out artifacts/study + +See Also: + paper/main.tex: Results. +""" + +from __future__ import annotations + +import argparse +import contextlib +import io +import json +import math +import os +import statistics +import sys +import tempfile +import time +from dataclasses import asdict, dataclass, field, replace +from typing import Any, Callable, Dict, List, Optional, Tuple + +import numpy as np +import yaml + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RUNS_DIR = os.path.join(REPO_ROOT, "artifacts", "runs") + + +# --------------------------------------------------------------------------- # +# Statistics helpers +# --------------------------------------------------------------------------- # +def wilson_ci(k: int, n: int, z: float = 1.96) -> Tuple[float, float]: + """Wilson score interval for a binomial proportion. + + Args: + k: Number of successes. + n: Number of trials. + z: Normal quantile (1.96 for ~95%). + + Returns: + ``(lo, hi)`` bounds on the success probability in ``[0, 1]``. + """ + if n <= 0: + return (0.0, 0.0) + p = k / n + denom = 1.0 + z * z / n + center = (p + z * z / (2.0 * n)) / denom + half = (z * math.sqrt(p * (1.0 - p) / n + z * z / (4.0 * n * n))) / denom + return (max(0.0, center - half), min(1.0, center + half)) + + +def bootstrap_ci(values: List[float], n_boot: int = 2000, seed: int = 0) -> Tuple[float, float]: + """Percentile bootstrap CI for the mean of ``values``. + + Args: + values: Sample values (NaNs are dropped). + n_boot: Number of bootstrap resamples. + seed: RNG seed for reproducibility. + + Returns: + ``(lo, hi)`` 95% percentile CI on the mean, or ``(nan, nan)`` if empty. + """ + arr = np.asarray([v for v in values if v == v], dtype=float) + if arr.size == 0: + return (float("nan"), float("nan")) + if arr.size == 1: + return (float(arr[0]), float(arr[0])) + rng = np.random.default_rng(seed) + boot = rng.choice(arr, size=(n_boot, arr.size), replace=True).mean(axis=1) + lo, hi = np.percentile(boot, [2.5, 97.5]) + return (float(lo), float(hi)) + + +def _median(values: List[float]) -> float: + vals = [v for v in values if v == v] + return float(statistics.median(vals)) if vals else float("nan") + + +# --------------------------------------------------------------------------- # +# Audit parsing +# --------------------------------------------------------------------------- # +@dataclass +class RunMetrics: + """Outcome of a single run, parsed from its audit log.""" + + scenario: str + seed: int + run_dir: str + valid: bool + invalidations: List[str] + Mmin_db: float + n_windows: int + M_median: float + M_min: float + M_max: float + nc1_window_frac: float + nc1_pass: bool + sc1_evaluated: bool + sc1_pass: Optional[bool] + sc1_delta: Optional[float] + sc1_tau_rec: Optional[float] + sc1_M_post: Optional[float] + refusal_evaluated: bool + refused: Optional[bool] + refusal_pass: Optional[bool] + refusal_reasons: List[str] + trefuse_ms: Optional[float] + + +def parse_run(scenario: str, seed: int, run_dir: str) -> RunMetrics: + """Parse a run's audit log into a :class:`RunMetrics`. + + Args: + scenario: Scenario name. + seed: Seed used for the run. + run_dir: Path to the per-run artifact directory. + + Returns: + Parsed run metrics. + """ + audit_path = os.path.join(run_dir, "audits", "audit.jsonl") + Mmin = 3.0 + ms: List[float] = [] + nc1_flags: List[bool] = [] + invalid: List[str] = [] + sc1: Optional[dict] = None + refusal: Optional[dict] = None + with open(audit_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + e = json.loads(line) + ev = e.get("event") + det = e.get("details", {}) or {} + if ev == "run_header": + Mmin = float(det.get("Mmin_db", Mmin)) + elif ev == "window_measured": + if det.get("M") is not None: + ms.append(float(det["M"])) + nc1_flags.append(bool(det.get("nc1"))) + elif ev == "run_invalidated": + invalid.append(str(det.get("reason", "?"))) + elif ev == "sc1_result": + sc1 = det + elif ev == "command_refusal_result": + refusal = det + + valid = len(invalid) == 0 + m_med = _median(ms) + nc1_frac = (sum(1 for f in nc1_flags if f) / len(nc1_flags)) if nc1_flags else 0.0 + nc1_pass = bool(valid and (m_med == m_med) and m_med >= Mmin) + + return RunMetrics( + scenario=scenario, + seed=seed, + run_dir=run_dir, + valid=valid, + invalidations=invalid, + Mmin_db=Mmin, + n_windows=len(ms), + M_median=m_med, + M_min=float(min(ms)) if ms else float("nan"), + M_max=float(max(ms)) if ms else float("nan"), + nc1_window_frac=nc1_frac, + nc1_pass=nc1_pass, + sc1_evaluated=sc1 is not None, + sc1_pass=(bool(sc1.get("pass")) if sc1 else None), + sc1_delta=(float(sc1["delta"]) if sc1 and sc1.get("delta") is not None else None), + sc1_tau_rec=(float(sc1["tau_rec"]) if sc1 and sc1.get("tau_rec") is not None else None), + sc1_M_post=(float(sc1["M_post"]) if sc1 and sc1.get("M_post") is not None else None), + refusal_evaluated=refusal is not None, + refused=(bool(refusal.get("refused")) if refusal else None), + refusal_pass=(bool(refusal.get("pass")) if refusal else None), + refusal_reasons=(list(refusal.get("reasons", [])) if refusal else []), + trefuse_ms=( + float(refusal["trefuse_ms_max"]) if refusal and refusal.get("trefuse_ms_max") is not None else None + ), + ) + + +# --------------------------------------------------------------------------- # +# Scenario definitions +# --------------------------------------------------------------------------- # +@dataclass +class Scenario: + """A study scenario: which handler, config, and Ω args to run. + + Attributes: + name: Stable scenario key used in tables and figures. + label: Human-readable label. + kind: One of ``"nc1"``, ``"sc1"``, ``"refusal"`` (selects the headline + metric for reporting). + expectation: Short text describing the expected outcome. + handler: CLI handler name in :mod:`ldtc.cli.main`. + config: Base YAML profile path (relative to repo root). + run_tag: Directory-name prefix the handler uses under artifacts/runs. + omega_args: Extra argparse fields for the handler. + overrides: Profile overrides applied on top of the base config (used to + shorten runs for the study without changing the validated dt/window). + """ + + name: str + label: str + kind: str + expectation: str + handler: str + config: str + run_tag: str + omega_args: Dict[str, Any] = field(default_factory=dict) + overrides: Dict[str, Any] = field(default_factory=dict) + + +def default_scenarios() -> List[Scenario]: + """Return the standard study battery.""" + nc1_over = {"baseline_sec": 12.0, "diag_cadence_windows": 50} + sc1_over = {"baseline_sec": 8.0, "diag_cadence_windows": 50} + return [ + Scenario( + name="positive", + label="Positive control", + kind="nc1", + expectation="NC1 holds (M above Mmin)", + handler="run_baseline", + config="configs/profile_r0.yml", + run_tag="baseline", + overrides=nc1_over, + ), + Scenario( + name="neg_controller_disabled", + label="Negative: controller disabled", + kind="nc1", + expectation="NC1 fails (M<0), run valid", + handler="run_baseline", + config="configs/profile_negative_controller_disabled.yml", + run_tag="baseline", + overrides=nc1_over, + ), + Scenario( + name="neg_permanent_ex_flood", + label="Negative: sustained ex-flood (unshielded)", + kind="nc1", + expectation="NC1 fails (M<0), run valid", + handler="omega_ingress_flood", + config="configs/profile_negative_permanent_ex_flood.yml", + run_tag="omega-ingress-flood", + omega_args={"mult": 5.0, "duration": 6.0}, + overrides={"baseline_sec": 10.0, "recovery_observe_sec": 4.0, "diag_cadence_windows": 50}, + ), + Scenario( + name="neg_exogenous_subsidy", + label="Negative: exogenous subsidy", + kind="nc1", + expectation="Run invalidated (red flag)", + handler="omega_exogenous_subsidy", + config="configs/profile_negative_exogenous_soc.yml", + run_tag="omega-exogenous-subsidy", + omega_args={"delta": 0.2, "zero_harvest": True, "duration": 8.0}, + overrides={"baseline_sec": 6.0, "diag_cadence_windows": 50}, + ), + Scenario( + name="sc1_power_sag", + label="SC1: power sag", + kind="sc1", + expectation="SC1 holds (recovers)", + handler="omega_power_sag", + config="configs/profile_r0.yml", + run_tag="omega-power-sag", + omega_args={"drop": 0.3, "duration": 8.0}, + overrides=sc1_over, + ), + Scenario( + name="sc1_ingress_flood", + label="SC1: ingress flood", + kind="sc1", + expectation="SC1 holds (recovers)", + handler="omega_ingress_flood", + config="configs/profile_r0.yml", + run_tag="omega-ingress-flood", + omega_args={"mult": 5.0, "duration": 8.0}, + overrides=sc1_over, + ), + Scenario( + name="refusal_command_conflict", + label="Threat: command conflict", + kind="refusal", + expectation="Refuse at low SoC (<= target latency)", + handler="omega_command_conflict", + config="configs/profile_negative_command_conflict.yml", + run_tag="omega-command-conflict", + omega_args={"observe": 2.0}, + ), + ] + + +# --------------------------------------------------------------------------- # +# Run orchestration (in-process, production handlers) +# --------------------------------------------------------------------------- # +def _load_yaml(path: str) -> Dict[str, Any]: + with open(path, "r", encoding="utf-8") as f: + return dict(yaml.safe_load(f) or {}) + + +# Threshold keys that R* calibration sets; everything else (dt, window, method, +# p_lag, n_boot) is shared with R0 so the two studies stay directly comparable. +_THRESHOLD_KEYS = ("Mmin_db", "epsilon", "tau_max") + + +def apply_threshold_profile(scenarios: List[Scenario], profile_path: str) -> List[Scenario]: + """Inject calibrated decision thresholds into every scenario's overrides. + + The study evaluates NC1/SC1 with the same handler code a verifier runs; the + only thing that should differ between an "R0" (uncalibrated guess) study and + the headline "R\\*" study is the decision thresholds (``Mmin_db``, + ``epsilon``, ``tau_max``). This reads those three keys from ``profile_path`` + and merges them into each scenario's ``overrides`` so the whole battery is + judged against one consistent, plant-calibrated threshold set. + + Calibration uses a disjoint seed range (see ``calibrate_rstar.py``), so + evaluating the study seeds against these thresholds is a train/test split, + not a circular fit. + + Args: + scenarios: Scenarios to update (copied; inputs are not mutated). + profile_path: Path to the calibrated profile YAML (``profile_rstar.yml``). + + Returns: + New scenarios with R* thresholds merged into ``overrides``. + """ + prof = _load_yaml(profile_path) + thr = {k: prof[k] for k in _THRESHOLD_KEYS if k in prof} + if not thr: + raise ValueError(f"{profile_path} has none of {_THRESHOLD_KEYS}") + out: List[Scenario] = [] + for s in scenarios: + ov = dict(s.overrides) + ov.update(thr) + out.append(replace(s, overrides=ov)) + return out + + +def _write_seed_config(base_cfg: Dict[str, Any], seed: int, overrides: Dict[str, Any], tmpdir: str) -> str: + cfg = dict(base_cfg) + cfg.update(overrides) + cfg["seed"] = int(seed) + cfg["seed_py"] = int(seed) + cfg["seed_np"] = int(seed) + path = os.path.join(tmpdir, f"cfg_seed_{seed}.yml") + with open(path, "w", encoding="utf-8") as f: + yaml.safe_dump(cfg, f, sort_keys=False) + return path + + +def _handlers() -> Dict[str, Callable[[argparse.Namespace], None]]: + from ldtc.cli import main as cli + + return { + "run_baseline": cli.run_baseline, + "omega_power_sag": cli.omega_power_sag, + "omega_ingress_flood": cli.omega_ingress_flood, + "omega_exogenous_subsidy": cli.omega_exogenous_subsidy, + "omega_command_conflict": cli.omega_command_conflict, + } + + +def _namespace_for(scn: Scenario, config_path: str) -> argparse.Namespace: + ns = argparse.Namespace(config=config_path) + for k, v in scn.omega_args.items(): + setattr(ns, k, v) + return ns + + +def _run_header_config(run_dir: str) -> Optional[str]: + """Return the ``config_path`` recorded in a run's audit header, if any.""" + audit_path = os.path.join(run_dir, "audits", "audit.jsonl") + if not os.path.exists(audit_path): + return None + try: + with open(audit_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + e = json.loads(line) + if e.get("event") == "run_header": + return e.get("details", {}).get("config_path") + except Exception: + return None + return None + + +def _find_run_dir(prefix: str, before: set, cfg_path: str) -> Optional[str]: + """Locate the run directory just created for ``cfg_path``. + + Disambiguates by the unique per-seed config path recorded in the audit + header, so the harness stays correct even when other runs (e.g., a + concurrent study) are creating directories at the same time. Falls back to + the newest directory with the matching tag prefix. + + Args: + prefix: Expected run-tag prefix (e.g., ``"baseline"``). + before: Set of directory names present before the handler ran. + cfg_path: The per-seed config path passed to the handler. + + Returns: + Absolute path to the matching run directory, or ``None``. + """ + if not os.path.isdir(RUNS_DIR): + return None + new = sorted(set(os.listdir(RUNS_DIR)) - before) + if not new: + return None + target = os.path.abspath(cfg_path) + for name in new: + rd = os.path.join(RUNS_DIR, name) + hdr = _run_header_config(rd) + if hdr is not None and os.path.abspath(hdr) == target: + return rd + # Fallback: newest with the matching tag prefix. + pref = sorted(n for n in new if n.startswith(prefix)) + return os.path.join(RUNS_DIR, pref[-1]) if pref else None + + +def run_one(scn: Scenario, seed: int, tmpdir: str, verbose: bool = False) -> Optional[RunMetrics]: + """Run one scenario at one seed via the production handler and parse it. + + Args: + scn: Scenario specification. + seed: Seed for this replicate. + tmpdir: Directory for the per-seed temporary config. + verbose: If True, let the handler print to stdout. + + Returns: + Parsed :class:`RunMetrics`, or ``None`` if no run directory was found. + """ + handlers = _handlers() + base_cfg = _load_yaml(os.path.join(REPO_ROOT, scn.config)) + cfg_path = _write_seed_config(base_cfg, seed, scn.overrides, tmpdir) + ns = _namespace_for(scn, cfg_path) + + before = {d for d in os.listdir(RUNS_DIR)} if os.path.isdir(RUNS_DIR) else set() + sink = io.StringIO() + ctx = contextlib.nullcontext() if verbose else contextlib.redirect_stdout(sink) + with ctx: + handlers[scn.handler](ns) + run_dir = _find_run_dir(scn.run_tag, before, cfg_path) + if run_dir is None: + return None + return parse_run(scn.name, seed, run_dir) + + +# --------------------------------------------------------------------------- # +# Aggregation +# --------------------------------------------------------------------------- # +@dataclass +class Aggregate: + """Per-scenario aggregate over seeds.""" + + name: str + label: str + kind: str + expectation: str + n_seeds: int + valid_rate: float + valid_ci: Tuple[float, float] + M_mean: float + M_ci: Tuple[float, float] + M_median_overall: float + nc1_pass_rate: float + nc1_ci: Tuple[float, float] + sc1_n: int + sc1_pass_rate: Optional[float] + sc1_ci: Optional[Tuple[float, float]] + sc1_delta_median: Optional[float] + sc1_tau_median: Optional[float] + refusal_n: int + refusal_rate: Optional[float] + refusal_ci: Optional[Tuple[float, float]] + trefuse_median_ms: Optional[float] + invalidation_reasons: Dict[str, int] + + +def aggregate(scn: Scenario, runs: List[RunMetrics]) -> Aggregate: + """Aggregate per-seed runs into a scenario-level summary. + + Args: + scn: Scenario specification. + runs: Per-seed parsed run metrics. + + Returns: + Scenario-level :class:`Aggregate`. + """ + n = len(runs) + valid_k = sum(1 for r in runs if r.valid) + per_seed_M = [r.M_median for r in runs if r.M_median == r.M_median] + nc1_k = sum(1 for r in runs if r.nc1_pass) + + sc1_runs = [r for r in runs if r.sc1_evaluated] + sc1_k = sum(1 for r in sc1_runs if r.sc1_pass) + sc1_deltas = [r.sc1_delta for r in sc1_runs if r.sc1_delta is not None] + sc1_taus = [r.sc1_tau_rec for r in sc1_runs if r.sc1_tau_rec is not None] + + ref_runs = [r for r in runs if r.refusal_evaluated] + ref_k = sum(1 for r in ref_runs if r.refusal_pass) + ref_lat = [r.trefuse_ms for r in ref_runs if r.trefuse_ms is not None] + + # Count seeds (not windows) that tripped each invalidation reason. + reasons: Dict[str, int] = {} + for r in runs: + for reason in set(r.invalidations): + reasons[reason] = reasons.get(reason, 0) + 1 + + return Aggregate( + name=scn.name, + label=scn.label, + kind=scn.kind, + expectation=scn.expectation, + n_seeds=n, + valid_rate=valid_k / n if n else float("nan"), + valid_ci=wilson_ci(valid_k, n), + M_mean=(float(np.mean(per_seed_M)) if per_seed_M else float("nan")), + M_ci=bootstrap_ci(per_seed_M), + M_median_overall=_median(per_seed_M), + nc1_pass_rate=nc1_k / n if n else float("nan"), + nc1_ci=wilson_ci(nc1_k, n), + sc1_n=len(sc1_runs), + sc1_pass_rate=(sc1_k / len(sc1_runs) if sc1_runs else None), + sc1_ci=(wilson_ci(sc1_k, len(sc1_runs)) if sc1_runs else None), + sc1_delta_median=(_median(sc1_deltas) if sc1_deltas else None), + sc1_tau_median=(_median(sc1_taus) if sc1_taus else None), + refusal_n=len(ref_runs), + refusal_rate=(ref_k / len(ref_runs) if ref_runs else None), + refusal_ci=(wilson_ci(ref_k, len(ref_runs)) if ref_runs else None), + trefuse_median_ms=(_median(ref_lat) if ref_lat else None), + invalidation_reasons=reasons, + ) + + +# --------------------------------------------------------------------------- # +# Writers +# --------------------------------------------------------------------------- # +def _fmt_pct(p: float, ci: Tuple[float, float]) -> str: + if p != p: + return "--" + return f"{100*p:.0f}\\% [{100*ci[0]:.0f}, {100*ci[1]:.0f}]" + + +def _fmt_m(mean: float, ci: Tuple[float, float]) -> str: + if mean != mean: + return "--" + return f"{mean:+.1f} [{ci[0]:+.1f}, {ci[1]:+.1f}]" + + +def write_csv(aggs: List[Aggregate], path: str) -> None: + """Write the aggregate results as a flat CSV.""" + import csv + + os.makedirs(os.path.dirname(path), exist_ok=True) + cols = [ + "scenario", + "label", + "kind", + "expectation", + "n_seeds", + "valid_rate", + "valid_lo", + "valid_hi", + "M_mean_db", + "M_lo", + "M_hi", + "M_median_db", + "nc1_pass_rate", + "nc1_lo", + "nc1_hi", + "sc1_n", + "sc1_pass_rate", + "sc1_delta_median", + "sc1_tau_median", + "refusal_n", + "refusal_rate", + "trefuse_median_ms", + "invalidations", + ] + with open(path, "w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow(cols) + for a in aggs: + w.writerow( + [ + a.name, + a.label, + a.kind, + a.expectation, + a.n_seeds, + f"{a.valid_rate:.4f}", + f"{a.valid_ci[0]:.4f}", + f"{a.valid_ci[1]:.4f}", + f"{a.M_mean:.4f}", + f"{a.M_ci[0]:.4f}", + f"{a.M_ci[1]:.4f}", + f"{a.M_median_overall:.4f}", + f"{a.nc1_pass_rate:.4f}", + f"{a.nc1_ci[0]:.4f}", + f"{a.nc1_ci[1]:.4f}", + a.sc1_n, + ("" if a.sc1_pass_rate is None else f"{a.sc1_pass_rate:.4f}"), + ("" if a.sc1_delta_median is None else f"{a.sc1_delta_median:.4f}"), + ("" if a.sc1_tau_median is None else f"{a.sc1_tau_median:.4f}"), + a.refusal_n, + ("" if a.refusal_rate is None else f"{a.refusal_rate:.4f}"), + ("" if a.trefuse_median_ms is None else f"{a.trefuse_median_ms:.4f}"), + ";".join(f"{k}={v}" for k, v in a.invalidation_reasons.items()), + ] + ) + + +def write_latex(aggs: List[Aggregate], path: str, n_seeds: int) -> None: + """Write a booktabs LaTeX results table for the paper.""" + os.makedirs(os.path.dirname(path), exist_ok=True) + lines = [ + "% Auto-generated by scripts/study.py -- do not edit by hand.", + "\\begin{tabular}{llcccc}", + "\\toprule", + "Scenario & Expected & Valid & NC1 pass & Median $M$ (dB) & SC1/Refusal \\\\", + "\\midrule", + ] + for a in aggs: + if a.kind == "sc1": + last = "--" if a.sc1_pass_rate is None else _fmt_pct(a.sc1_pass_rate, a.sc1_ci or (0, 0)) + elif a.kind == "refusal": + last = "--" if a.refusal_rate is None else _fmt_pct(a.refusal_rate, a.refusal_ci or (0, 0)) + else: + last = "--" + lines.append( + f"{a.label} & {a.expectation} & {_fmt_pct(a.valid_rate, a.valid_ci)} & " + f"{_fmt_pct(a.nc1_pass_rate, a.nc1_ci)} & {_fmt_m(a.M_mean, a.M_ci)} & {last} \\\\" + ) + lines += [ + "\\bottomrule", + "\\end{tabular}", + f"% N = {n_seeds} seeds per scenario; brackets are 95% CIs " "(Wilson for proportions, bootstrap for M).", + ] + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + +def write_json( + aggs: List[Aggregate], + runs_by_scn: Dict[str, List[RunMetrics]], + meta: Dict[str, Any], + path: str, +) -> None: + """Write the full study results (aggregates + per-run rows + metadata).""" + os.makedirs(os.path.dirname(path), exist_ok=True) + payload = { + "meta": meta, + "aggregates": [asdict(a) for a in aggs], + "runs": {name: [asdict(r) for r in rs] for name, rs in runs_by_scn.items()}, + } + with open(path, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2) + + +# --------------------------------------------------------------------------- # +# Driver +# --------------------------------------------------------------------------- # +def run_study( + seeds: List[int], + scenarios: List[Scenario], + out_dir: str, + verbose: bool = False, + threshold_profile: Optional[str] = None, +) -> Tuple[List[Aggregate], Dict[str, List[RunMetrics]]]: + """Run the full study and write all artifacts. + + Args: + seeds: Seeds to use as replicates. + scenarios: Scenarios to run. + out_dir: Output directory for study artifacts. + verbose: If True, let handlers print. + + Returns: + ``(aggregates, runs_by_scenario)``. + """ + os.environ["LDTC_SKIP_REPORT"] = "1" # skip per-run figure bundles + os.makedirs(RUNS_DIR, exist_ok=True) + runs_by_scn: Dict[str, List[RunMetrics]] = {} + t0 = time.time() + with tempfile.TemporaryDirectory(prefix="ldtc_study_") as tmpdir: + for scn in scenarios: + rows: List[RunMetrics] = [] + for seed in seeds: + ts = time.time() + rm = run_one(scn, seed, tmpdir, verbose=verbose) + dt = time.time() - ts + if rm is None: + print(f" [{scn.name}] seed={seed}: NO RUN DIR FOUND", flush=True) + continue + rows.append(rm) + tag = ( + f"valid={rm.valid} NC1={rm.nc1_pass} M~{rm.M_median:+.1f}" + + (f" SC1={rm.sc1_pass}" if rm.sc1_evaluated else "") + + (f" refused={rm.refused}({rm.trefuse_ms}ms)" if rm.refusal_evaluated else "") + ) + print(f" [{scn.name}] seed={seed} ({dt:.1f}s): {tag}", flush=True) + runs_by_scn[scn.name] = rows + print(f"== {scn.name}: {len(rows)}/{len(seeds)} runs ==", flush=True) + + aggs = [aggregate(scn, runs_by_scn.get(scn.name, [])) for scn in scenarios] + thr_label = ( + os.path.relpath(threshold_profile, REPO_ROOT) if threshold_profile else "configs/profile_r0.yml (per-scenario)" + ) + meta = { + "seeds": seeds, + "n_seeds": len(seeds), + "elapsed_sec": round(time.time() - t0, 1), + "timestamp": time.time(), + "threshold_profile": thr_label, + "scenarios": [asdict(s) for s in scenarios], + } + write_json(aggs, runs_by_scn, meta, os.path.join(out_dir, "study_results.json")) + write_csv(aggs, os.path.join(out_dir, "study_results.csv")) + write_latex(aggs, os.path.join(out_dir, "study_results.tex"), len(seeds)) + return aggs, runs_by_scn + + +def load_or_run(study_dir: str, seeds: List[int], scenario_names: Optional[List[str]] = None) -> Dict[str, Any]: + """Load an existing study payload or run a (subset) study to create one. + + Used by the paper figure generators so they always render *real* data: if + the canonical study has been run (``study_dir/study_results.json`` exists) + its results are reused; otherwise a small study over ``scenario_names`` is + run into ``study_dir`` first. Never returns synthetic data. + + Args: + study_dir: Directory holding (or to hold) ``study_results.json``. + seeds: Seeds to use if a study must be run. + scenario_names: Optional subset of scenario names to run. + + Returns: + The loaded study payload dict. + """ + path = os.path.join(study_dir, "study_results.json") + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + scns = default_scenarios() + if scenario_names is not None: + keep = set(scenario_names) + scns = [s for s in scns if s.name in keep] + run_study(seeds, scns, study_dir) + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def data_for_paper( + canonical_dir: str, + fallback_dir: str, + seeds: List[int], + scenario_names: List[str], +) -> Dict[str, Any]: + """Return study data for a paper figure, preferring the canonical study. + + If the full study (``canonical_dir/study_results.json``) exists it is used + (e.g., the 15-seed submission run); otherwise a small study over + ``scenario_names`` is run into ``fallback_dir`` so the figure is still based + on real data (used by CI paper builds that have no prior study). + + Args: + canonical_dir: Directory of the full study. + fallback_dir: Per-figure directory for a small on-demand study. + seeds: Seeds for the fallback study. + scenario_names: Scenario subset the figure needs. + + Returns: + The study payload dict. + """ + cpath = os.path.join(canonical_dir, "study_results.json") + if os.path.exists(cpath): + with open(cpath, "r", encoding="utf-8") as f: + return json.load(f) + return load_or_run(fallback_dir, seeds, scenario_names) + + +def print_summary(aggs: List[Aggregate]) -> None: + """Print a compact human-readable summary table to stdout.""" + print("\n=== STUDY SUMMARY ===") + for a in aggs: + line = ( + f"{a.label:42s} valid={100*a.valid_rate:3.0f}% " + f"NC1={100*a.nc1_pass_rate:3.0f}% M={a.M_mean:+6.1f} dB [{a.M_ci[0]:+.1f},{a.M_ci[1]:+.1f}]" + ) + if a.kind == "sc1" and a.sc1_pass_rate is not None: + line += f" SC1={100*a.sc1_pass_rate:3.0f}% (delta~{a.sc1_delta_median})" + if a.kind == "refusal" and a.refusal_rate is not None: + line += f" refuse={100*a.refusal_rate:3.0f}% (~{a.trefuse_median_ms}ms)" + print(line) + + +def main() -> None: + """CLI entry point for the multi-seed study.""" + ap = argparse.ArgumentParser(description="Run the multi-seed LDTC study and emit tables/figures.") + ap.add_argument("--seeds", type=int, default=12, help="Number of seeds (replicates) per scenario.") + ap.add_argument("--seed-base", type=int, default=1000, help="First seed; seeds are base..base+N-1.") + ap.add_argument("--out", type=str, default=os.path.join(REPO_ROOT, "artifacts", "study")) + ap.add_argument("--only", type=str, default="", help="Comma-separated scenario names to include.") + ap.add_argument( + "--rstar", + nargs="?", + const=os.path.join(REPO_ROOT, "configs", "profile_rstar.yml"), + default="", + help="Evaluate against calibrated R* thresholds from this profile " + "(default configs/profile_rstar.yml). Requires running calibrate first.", + ) + ap.add_argument("--verbose", action="store_true") + ap.add_argument("--no-figures", action="store_true", help="Skip figure generation.") + args = ap.parse_args() + + seeds = [args.seed_base + i for i in range(int(args.seeds))] + scenarios = default_scenarios() + if args.only: + keep = {s.strip() for s in args.only.split(",") if s.strip()} + scenarios = [s for s in scenarios if s.name in keep] + + if args.rstar: + if not os.path.exists(args.rstar): + ap.error(f"--rstar profile not found: {args.rstar} (run `make calibrate` first)") + scenarios = apply_threshold_profile(scenarios, args.rstar) + print(f"Evaluating against calibrated thresholds from {args.rstar}", flush=True) + + print(f"Running study: {len(scenarios)} scenarios x {len(seeds)} seeds -> {args.out}", flush=True) + aggs, runs_by_scn = run_study( + seeds, + scenarios, + args.out, + verbose=args.verbose, + threshold_profile=(args.rstar or None), + ) + print_summary(aggs) + + if not args.no_figures: + try: + from study_figures import make_all_figures + + make_all_figures(args.out) + except Exception as exc: # pragma: no cover - figures are best-effort + print(f"(figures skipped: {exc})") + + print(f"\nWrote: {os.path.join(args.out, 'study_results.json')}") + print(f"Wrote: {os.path.join(args.out, 'study_results.csv')}") + print(f"Wrote: {os.path.join(args.out, 'study_results.tex')}") + + +if __name__ == "__main__": + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + main() diff --git a/scripts/study_figures.py b/scripts/study_figures.py new file mode 100644 index 0000000..78369c6 --- /dev/null +++ b/scripts/study_figures.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +"""Scripts: Figures for the multi-seed LDTC study. + +Consumes ``study_results.json`` (written by :mod:`study`) plus the per-run +audit logs it references, and emits the paper's results figures: + +* ``fig_nc1_contrast``: per-seed median loop dominance ``M`` (dB) for the + positive control and the negative controls, with the ``Mmin`` and 0 dB + reference lines (the headline NC1 result). +* ``fig_pass_rates``: NC1 / SC1 / refusal pass-rates per scenario with Wilson + 95% CIs. +* ``fig_sc1_recovery``: a real ``M(t)`` trajectory from a representative + power-sag run, with the perturbation window shaded (replaces the previous + hand-drawn placeholder). + +All figures are written as PNG, PDF, and SVG into ``/figures``. + +See Also: + paper/main.tex: Results. +""" + +from __future__ import annotations + +import json +import os +from typing import Any, Dict, List, Optional, Tuple + +import matplotlib.pyplot as plt +import numpy as np + +from ldtc.reporting.style import COLORS, apply_matplotlib_theme + +SHORT_LABELS = { + "positive": "Positive\ncontrol", + "neg_controller_disabled": "Controller\ndisabled", + "neg_permanent_ex_flood": "Sustained\nex-flood", + "neg_exogenous_subsidy": "Exogenous\nsubsidy", + "sc1_power_sag": "Power\nsag", + "sc1_ingress_flood": "Ingress\nflood", + "refusal_command_conflict": "Command\nconflict", +} + + +def _save(fig: "plt.Figure", out_dir: str, stem: str) -> str: + os.makedirs(out_dir, exist_ok=True) + base = os.path.join(out_dir, stem) + fig.savefig(base + ".png", dpi=300, bbox_inches="tight") + fig.savefig(base + ".pdf", bbox_inches="tight") + fig.savefig(base + ".svg", bbox_inches="tight") + plt.close(fig) + return base + ".png" + + +def _load(study_dir: str) -> Dict[str, Any]: + with open(os.path.join(study_dir, "study_results.json"), "r", encoding="utf-8") as f: + return json.load(f) + + +# --------------------------------------------------------------------------- # +# Figure 1: NC1 contrast (per-seed median M by scenario) +# --------------------------------------------------------------------------- # +def fig_nc1_contrast(data: Dict[str, Any], out_dir: str, stem: str = "fig_nc1_contrast") -> Optional[str]: + """Per-seed median ``M`` for the positive and negative controls.""" + order = ["positive", "neg_controller_disabled", "neg_permanent_ex_flood"] + runs = data.get("runs", {}) + aggs = {a["name"]: a for a in data.get("aggregates", [])} + present = [s for s in order if s in runs and runs[s]] + if not present: + return None + + mmin = 3.0 + for s in present: + rs = runs[s] + if rs: + mmin = float(rs[0].get("Mmin_db", 3.0)) + break + + apply_matplotlib_theme("paper") + fig, ax = plt.subplots(figsize=(6.4, 4.0)) + rng = np.random.default_rng(7) + for i, s in enumerate(present): + ys = [r["M_median"] for r in runs[s] if r["M_median"] == r["M_median"]] + if not ys: + continue + xs = i + rng.uniform(-0.12, 0.12, size=len(ys)) + pos = aggs.get(s, {}).get("M_mean", float("nan")) >= mmin + color = COLORS["green"] if pos else COLORS["red"] + ax.scatter(xs, ys, s=34, color=color, alpha=0.75, edgecolor="white", linewidth=0.5, zorder=3) + # Box (median + IQR) for the scenario. + bp = ax.boxplot( + ys, + positions=[i], + widths=0.5, + vert=True, + patch_artist=True, + showfliers=False, + zorder=2, + ) + for box in bp["boxes"]: + box.set(facecolor=COLORS["gray_light"], edgecolor=COLORS["gray"], alpha=0.7) + for med in bp["medians"]: + med.set(color=COLORS["gray"], linewidth=2) + + ax.axhline(0.0, color=COLORS["gray"], linestyle="-", linewidth=1.0, zorder=1) + ax.axhline(mmin, color=COLORS["blue"], linestyle="--", linewidth=1.5, zorder=1) + ax.text( + len(present) - 0.5, + mmin, + f" $M_{{\\min}}$ = {mmin:.0f} dB", + color=COLORS["blue"], + va="bottom", + ha="right", + fontsize=9, + ) + ax.set_xticks(range(len(present))) + ax.set_xticklabels([SHORT_LABELS.get(s, s) for s in present]) + ax.set_ylabel(r"Loop dominance $M$ (dB)") + ax.set_title("NC1 contrast: loop dominance across controls") + n = data.get("meta", {}).get("n_seeds", len(runs[present[0]])) + ax.text( + 0.99, + 0.02, + f"each point = 1 seed (N={n})", + transform=ax.transAxes, + ha="right", + va="bottom", + fontsize=8, + color=COLORS["gray"], + ) + fig.tight_layout() + return _save(fig, out_dir, stem) + + +# --------------------------------------------------------------------------- # +# Figure 2: pass-rates with Wilson CIs +# --------------------------------------------------------------------------- # +def fig_outcomes(data: Dict[str, Any], out_dir: str) -> Optional[str]: + """Fraction of seeds whose outcome matched the theory's prediction. + + Each scenario is scored against its *expected* outcome (positive control + passes NC1; negative controls fail NC1 or are invalidated; SC1 scenarios + recover; the command conflict is refused). If the framework behaves as + predicted, every bar is near 100%. Whiskers are 95% Wilson CIs. + """ + aggs = {a["name"]: a for a in data.get("aggregates", [])} + order = [ + "positive", + "neg_controller_disabled", + "neg_permanent_ex_flood", + "neg_exogenous_subsidy", + "sc1_power_sag", + "sc1_ingress_flood", + "refusal_command_conflict", + ] + present = [s for s in order if s in aggs] + if not present: + return None + + criterion = { + "positive": "NC1 holds", + "neg_controller_disabled": "NC1 rejected", + "neg_permanent_ex_flood": "NC1 rejected", + "neg_exogenous_subsidy": "invalidated", + "sc1_power_sag": "SC1 holds", + "sc1_ingress_flood": "SC1 holds", + "refusal_command_conflict": "refused", + } + + labels: List[str] = [] + rates: List[float] = [] + los: List[float] = [] + his: List[float] = [] + colors: List[str] = [] + for s in present: + a = aggs[s] + if s == "neg_exogenous_subsidy": + rate = 1.0 - a["valid_rate"] + ci = (1.0 - a["valid_ci"][1], 1.0 - a["valid_ci"][0]) + elif s in ("neg_controller_disabled", "neg_permanent_ex_flood"): + rate = 1.0 - a["nc1_pass_rate"] + ci = (1.0 - a["nc1_ci"][1], 1.0 - a["nc1_ci"][0]) + elif a["kind"] == "nc1": + rate = a["nc1_pass_rate"] + ci = tuple(a["nc1_ci"]) + elif a["kind"] == "sc1": + rate = a["sc1_pass_rate"] if a["sc1_pass_rate"] is not None else float("nan") + ci = tuple(a["sc1_ci"]) if a["sc1_ci"] else (rate, rate) + else: # refusal + rate = a["refusal_rate"] if a["refusal_rate"] is not None else float("nan") + ci = tuple(a["refusal_ci"]) if a["refusal_ci"] else (rate, rate) + labels.append(SHORT_LABELS.get(s, s) + f"\n({criterion[s]})") + rates.append(100.0 * rate) + los.append(100.0 * (rate - ci[0])) + his.append(100.0 * (ci[1] - rate)) + colors.append(COLORS["green"] if rate >= 0.5 else COLORS["red"]) + + apply_matplotlib_theme("paper") + fig, ax = plt.subplots(figsize=(7.6, 4.2)) + x = np.arange(len(present)) + ax.bar(x, rates, width=0.62, color=colors, alpha=0.85, zorder=2) + ax.errorbar( + x, + rates, + yerr=[los, his], + fmt="none", + ecolor=COLORS["gray"], + elinewidth=1.4, + capsize=4, + zorder=3, + ) + for xi, r in zip(x, rates): + if r == r: + ax.text(float(xi), min(r + 2.5, 101), f"{r:.0f}%", ha="center", va="bottom", fontsize=8, color="#34495E") + ax.set_xticks(x) + ax.set_xticklabels(labels, fontsize=8) + ax.set_ylabel("Seeds matching prediction (%)") + ax.set_ylim(0, 108) + n = data.get("meta", {}).get("n_seeds", 0) + ax.set_title(f"Predicted outcome confirmed across scenarios (N={n}, 95% Wilson CIs)") + fig.tight_layout() + return _save(fig, out_dir, "fig_outcomes") + + +# --------------------------------------------------------------------------- # +# Figure 3: real SC1 recovery trajectory +# --------------------------------------------------------------------------- # +def _trajectory_from_audit(audit_path: str) -> Tuple[List[float], List[int], Dict[str, int]]: + """Reconstruct the M(t) series and phase boundaries from an audit log. + + Walks the audit in counter order, tracking the perturbation phase from the + ``omega_power_sag_*`` markers, and returns the per-window M values, their + phase code (0 baseline, 1 sag, 2 recovery), and the window indices where + the sag starts/stops. + + Args: + audit_path: Path to the run's audit JSONL. + + Returns: + ``(M_values, phase_codes, markers)`` where ``markers`` has + ``sag_start`` and ``sag_stop`` window indices. + """ + ms: List[float] = [] + phases: List[int] = [] + phase = 0 + markers = {"sag_start": -1, "sag_stop": -1} + with open(audit_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + e = json.loads(line) + ev = e.get("event", "") + # The perturbation is bracketed by the "*_window_start"/"*_window_stop" + # markers (the bare "*_start"/"*_stop" events bracket the whole + # procedure, including the pre-Ω baseline and post-Ω recovery). + if ev.endswith("_window_start"): + phase = 1 + markers["sag_start"] = len(ms) + elif ev.endswith("_window_stop"): + phase = 2 + markers["sag_stop"] = len(ms) + elif ev == "window_measured": + m = e.get("details", {}).get("M") + if m is not None: + ms.append(float(m)) + phases.append(phase) + return ms, phases, markers + + +def _aggregate_trajectory(data: Dict[str, Any], scenario: str) -> Optional[Dict[str, Any]]: + """Aggregate per-seed M(window) trajectories for a scenario. + + Aligns all valid runs by window index (the configuration is identical + across seeds, so the phase boundaries coincide), truncates to the common + length, and returns the mean trajectory with a 10-90th percentile band. + + Args: + data: Loaded study results. + scenario: Scenario key (e.g., ``"sc1_power_sag"``). + + Returns: + Dict with ``mean``, ``p10``, ``p90``, ``sag_start``, ``sag_stop``, + ``n``, and ``mmin``; or ``None`` if no trajectories are available. + """ + runs = data.get("runs", {}).get(scenario, []) + series: List[List[float]] = [] + starts: List[int] = [] + stops: List[int] = [] + mmin = 3.0 + for r in runs: + if not r.get("valid", True): + continue + ap = os.path.join(r["run_dir"], "audits", "audit.jsonl") + if not os.path.exists(ap): + continue + ms, _ph, mk = _trajectory_from_audit(ap) + if not ms: + continue + series.append(ms) + starts.append(mk["sag_start"]) + stops.append(mk["sag_stop"]) + mmin = float(r.get("Mmin_db", mmin)) + if not series: + return None + L = min(len(s) for s in series) + arr = np.asarray([s[:L] for s in series], dtype=float) + good_starts = [s for s in starts if 0 <= s < L] + good_stops = [s for s in stops if 0 <= s < L] + return { + "mean": arr.mean(axis=0), + "p10": np.percentile(arr, 10, axis=0), + "p90": np.percentile(arr, 90, axis=0), + "sag_start": int(np.median(good_starts)) if good_starts else -1, + "sag_stop": int(np.median(good_stops)) if good_stops else -1, + "n": len(series), + "mmin": mmin, + } + + +def fig_sc1_recovery(data: Dict[str, Any], out_dir: str, stem: str = "fig_sc1_recovery") -> Optional[str]: + """Aggregate M(t) trajectories across seeds for the SC1 perturbations. + + One panel per perturbation type (power sag, ingress flood). Each shows the + seed-mean loop dominance with a 10-90th percentile band, the shaded + perturbation window, and the ``Mmin`` reference, demonstrating that loop + dominance stays above threshold throughout and recovers (SC1). + """ + panels = [("sc1_power_sag", "Power sag"), ("sc1_ingress_flood", "Ingress flood")] + aggs: List[Tuple[str, Dict[str, Any]]] = [] + for name, lbl in panels: + a = _aggregate_trajectory(data, name) + if a is not None: + aggs.append((lbl, a)) + if not aggs: + return None + + apply_matplotlib_theme("paper") + fig, axes = plt.subplots(1, len(aggs), figsize=(4.6 * len(aggs), 4.0), sharey=True, squeeze=False) + for ax, (lbl, a) in zip(axes[0], aggs): + x = np.arange(len(a["mean"])) + if a["sag_start"] >= 0: + stop = a["sag_stop"] if a["sag_stop"] >= 0 else len(a["mean"]) + ax.axvspan(a["sag_start"], stop, color=COLORS["yellow_light"], alpha=0.85, zorder=0, label="Perturbation") + ax.fill_between(x, a["p10"], a["p90"], color=COLORS["green_light"], alpha=0.7, zorder=2, label="10-90th pct") + ax.plot(x, a["mean"], color=COLORS["green"], linewidth=2.0, zorder=3, label="seed mean $M$") + ax.axhline(a["mmin"], color=COLORS["blue"], linestyle="--", linewidth=1.4, zorder=1) + ax.axhline(0.0, color=COLORS["gray"], linestyle="-", linewidth=0.8, zorder=1) + ax.text( + len(a["mean"]) - 1, + a["mmin"], + f" $M_{{\\min}}$={a['mmin']:.0f} dB", + color=COLORS["blue"], + va="bottom", + ha="right", + fontsize=9, + ) + ax.set_xlabel("Measurement window") + ax.set_title(f"{lbl} (N={a['n']})") + axes[0][0].set_ylabel(r"Loop dominance $M$ (dB)") + axes[0][-1].legend(loc="lower left", frameon=False, fontsize=8) + fig.suptitle("SC1: loop dominance stays above threshold and recovers under perturbation", fontsize=12) + fig.tight_layout(rect=(0, 0, 1, 0.96)) + return _save(fig, out_dir, stem) + + +def make_all_figures(study_dir: str) -> List[str]: + """Generate all study figures into ``/figures``. + + Args: + study_dir: Directory containing ``study_results.json``. + + Returns: + List of written PNG paths (best-effort; failures are skipped). + """ + data = _load(study_dir) + out_dir = os.path.join(study_dir, "figures") + written: List[str] = [] + for fn in (fig_nc1_contrast, fig_outcomes, fig_sc1_recovery): + try: + p = fn(data, out_dir) + if p: + written.append(p) + print(f" figure: {p}") + except Exception as exc: # pragma: no cover - figures are best-effort + print(f" (figure {fn.__name__} failed: {exc})") + return written + + +if __name__ == "__main__": + import sys + + d = ( + sys.argv[1] + if len(sys.argv) > 1 + else os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "artifacts", "study") + ) + make_all_figures(d) diff --git a/src/ldtc/__init__.py b/src/ldtc/__init__.py index 3bb416e..bd09ca6 100644 --- a/src/ldtc/__init__.py +++ b/src/ldtc/__init__.py @@ -34,7 +34,7 @@ import json from pathlib import Path - latest = sorted(Path("artifacts/indicators").glob("*.json"))[-1] + latest = sorted(Path("artifacts/runs").glob("*/indicators/*.json"))[-1] print(json.loads(latest.read_text())["indicators"]) ``` diff --git a/src/ldtc/arbiter/policy.py b/src/ldtc/arbiter/policy.py index f2d93a8..719f4db 100644 --- a/src/ldtc/arbiter/policy.py +++ b/src/ldtc/arbiter/policy.py @@ -1,12 +1,19 @@ """Controller policy over refusal logic. -A small homeostatic controller that produces actuator setpoints +A continuous homeostatic controller that produces actuator setpoints (`throttle`, `cool`, `repair`) and consults the -[`RefusalArbiter`][ldtc.arbiter.refusal.RefusalArbiter] to decide -whether to accept a risky external command. The controller intentionally -prioritizes boundary integrity over downstream tasks: throttle and cool -respond to `E` (state of charge) and `T` (temperature) before any -command acceptance is considered. +[`RefusalArbiter`][ldtc.arbiter.refusal.RefusalArbiter] to decide whether +to accept a risky external command. + +The controller is intentionally *cross-coupled*: each actuator responds +to more than one internal state, and each actuator affects more than one +internal state in the plant. This proportional, multivariable control law +is what makes the internal self-maintenance set strongly self-predictive +(high ``L_loop``) when the controller is active, and is the mechanism the +NC1 criterion is meant to detect. The controller also prioritizes +boundary integrity over downstream tasks: throttle and cooling respond to +state of charge and temperature before any command acceptance is +considered. See Also: `paper/main.tex`: Self-Referential Control; Threat Model and @@ -21,6 +28,61 @@ from .refusal import RefusalArbiter, RefusalDecision +def _clip01(x: float) -> float: + """Clip ``x`` to the closed unit interval ``[0, 1]``.""" + return 0.0 if x < 0.0 else (1.0 if x > 1.0 else x) + + +@dataclass +class ControlGains: + """Setpoints and proportional gains for the homeostatic controller. + + The defaults are tuned together with + [`PlantParams`][ldtc.plant.models.PlantParams] so that an active + controller produces clear loop dominance. Each actuator is a linear + combination of internal-state errors, which makes the induced + internal coupling visible to linear and information-theoretic + estimators alike. + + Attributes: + E_set: Target state of charge. + T_set: Target temperature. + R_set: Target health. + k_thr_e: Throttle gain on the energy deficit. + k_thr_t: Throttle gain on the temperature excess. + k_thr_r: Throttle gain on the health deficit. + k_thr_demand: Feedforward throttle gain on demand above its + reference. This is what lets the active loop *reject* the + exogenous demand disturbance (shielding the internal state), + which is the mechanism that drives ``L_ex`` down under an + active controller. + demand_ref: Demand level at which no feedforward throttle is + applied. + k_cool_t: Cooling gain on the temperature excess. + k_cool_e: Cooling gain on the energy surplus (cool harder when + there is spare energy). + k_rep_r: Repair gain on the health deficit. + k_rep_e: Repair gain on the energy surplus (repair when there is + spare energy). + repair_soc_floor: Minimum state of charge required before repair + is attempted. + """ + + E_set: float = 0.60 + T_set: float = 0.35 + R_set: float = 0.85 + k_thr_e: float = 2.0 + k_thr_t: float = 1.0 + k_thr_r: float = 0.6 + k_thr_demand: float = 0.0 + demand_ref: float = 0.20 + k_cool_t: float = 2.0 + k_cool_e: float = 0.25 + k_rep_r: float = 2.0 + k_rep_e: float = 0.25 + repair_soc_floor: float = 0.35 + + @dataclass class ControlAction: """Low-level control action for the plant actuators. @@ -40,22 +102,25 @@ class ControlAction: class ControllerPolicy: - """Simple homeostatic controller layered over a refusal arbiter. + """Continuous homeostatic controller layered over a refusal arbiter. - Heuristically sets throttle, cooling, and repair based on the - current state, and consults + Computes throttle, cooling, and repair as proportional, cross-coupled + responses to the internal-state errors, and consults [`RefusalArbiter`][ldtc.arbiter.refusal.RefusalArbiter] to decide - whether to accept a risky external command. The most recent - decision is cached on `last_decision` for downstream inspection - (e.g., audit records). + whether to accept a risky external command. The most recent decision + is cached on `last_decision` for downstream inspection (e.g., audit + records). Args: refusal: Refusal arbiter used to gate risky commands. + gains: Optional [`ControlGains`][ldtc.arbiter.policy.ControlGains]; + defaults to the calibrated preset. """ - def __init__(self, refusal: RefusalArbiter) -> None: - """Initialize with the refusal arbiter to delegate to.""" + def __init__(self, refusal: RefusalArbiter, gains: Optional[ControlGains] = None) -> None: + """Initialize with the refusal arbiter to delegate to and control gains.""" self.refusal = refusal + self.gains = gains or ControlGains() self.last_decision: Optional[RefusalDecision] = None def compute( @@ -76,18 +141,28 @@ def compute( A [`ControlAction`][ldtc.arbiter.policy.ControlAction] with actuator settings and the accept flag from the arbiter. """ + g = self.gains E = state["E"] T = state["T"] R = state["R"] - throttle = 0.0 - cool = 0.0 - repair = 0.0 - if E < 0.4: - throttle = min(1.0, 0.5 + (0.4 - E)) - if T > 0.6: - cool = min(1.0, (T - 0.6) * 1.5) - if R < 0.6 and E > 0.5 and T < 0.7: - repair = min(1.0, (0.6 - R) * 1.5) + demand = state.get("demand", g.demand_ref) + + e_def = max(0.0, g.E_set - E) # energy deficit + e_sur = max(0.0, E - g.E_set) # energy surplus + t_exc = max(0.0, T - g.T_set) # temperature excess + r_def = max(0.0, g.R_set - R) # health deficit + dem_exc = max(0.0, demand - g.demand_ref) # demand above reference + + # Throttle reduces load when energy is low, the system is hot, or + # health is low (feedback on all three internal states) and also + # rejects the exogenous demand disturbance via feedforward, which + # shields the internal set from exchange. + throttle = _clip01(g.k_thr_e * e_def + g.k_thr_t * t_exc + g.k_thr_r * r_def + g.k_thr_demand * dem_exc) + # Cooling responds to temperature, modulated by available energy. + cool = _clip01(g.k_cool_t * t_exc + g.k_cool_e * e_sur) + # Repair responds to health deficit, gated by sufficient energy. + repair = _clip01(g.k_rep_r * r_def + g.k_rep_e * e_sur) if E >= g.repair_soc_floor else 0.0 + dec: RefusalDecision = self.refusal.decide(state, predicted_M_db, risky_cmd) self.last_decision = dec return ControlAction(throttle=throttle, cool=cool, repair=repair, accept_cmd=dec.accept) diff --git a/src/ldtc/arbiter/refusal.py b/src/ldtc/arbiter/refusal.py index dfa912e..4d3ad51 100644 --- a/src/ldtc/arbiter/refusal.py +++ b/src/ldtc/arbiter/refusal.py @@ -43,13 +43,17 @@ class RefusalArbiter: 2. Temperature `T` is at or above `temp_ceiling`. 3. Predicted loop-dominance margin `M (dB)` is below `Mmin_db`. + The state-of-charge survival floor defaults to `0.30`, matching the + threat model in the paper ("refuse if SoC < 30%, resume evaluation + after SoC > 60%"). + Args: Mmin_db: Minimum acceptable decibel margin. soc_floor: Minimum state-of-charge before refusing. temp_ceiling: Maximum temperature before refusing. """ - def __init__(self, Mmin_db: float = 3.0, soc_floor: float = 0.15, temp_ceiling: float = 0.85) -> None: + def __init__(self, Mmin_db: float = 3.0, soc_floor: float = 0.30, temp_ceiling: float = 0.85) -> None: """Initialize with the boundary thresholds described in the class docstring.""" self.Mmin = Mmin_db self.soc_floor = soc_floor diff --git a/src/ldtc/cli/main.py b/src/ldtc/cli/main.py index c7e929f..c4ab6a3 100644 --- a/src/ldtc/cli/main.py +++ b/src/ldtc/cli/main.py @@ -42,7 +42,7 @@ import random import sys import time -from typing import TYPE_CHECKING, Dict, List, Protocol +from typing import TYPE_CHECKING, Any, Dict, List, Protocol import numpy as np import yaml @@ -70,7 +70,7 @@ from ..lmeas.partition import PartitionManager, greedy_suggest_C from ..plant.adapter import PlantAdapter from ..reporting.artifacts import bundle as build_verification_bundle -from ..runtime.scheduler import FixedScheduler +from ..runtime.sim import make_driver from ..runtime.windows import SlidingWindow if TYPE_CHECKING: @@ -264,22 +264,40 @@ def _print_invalidation_footer(audit_path: str) -> None: pass -def _ensure_dirs() -> Dict[str, str]: - """Create and return the artifact subdirectories used by the CLI. +def _ensure_dirs(tag: str = "run") -> Dict[str, str]: + """Create and return per-run artifact subdirectories. + + Each invocation gets its own isolated directory under + `artifacts/runs/-/`. Isolation is required for the + hash-chained audit log: appending consecutive runs to a single shared + `audit.jsonl` would break the counter/hash continuity and (correctly) + trip the `audit_chain_broken` smell-test. Signing keys remain shared + under `artifacts/keys/`. + + Args: + tag: Short label for the run (e.g., `"baseline"`, `"omega-power-sag"`) + used as a filename-friendly prefix. Returns: - Dict with `artifacts`, `audits`, `indicators`, and `figures` - keys mapping to absolute paths under `artifacts/`. + Dict with `artifacts`, `run`, `audits`, `indicators`, and `figures` + keys mapping to absolute paths. """ + import datetime as _dt + import uuid as _uuid + + stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S") + run_id = f"{tag}-{stamp}-{_uuid.uuid4().hex[:6]}" artifacts = os.path.join("artifacts") - audits = os.path.join(artifacts, "audits") - indicators = os.path.join(artifacts, "indicators") - figures = os.path.join(artifacts, "figures") + run_dir = os.path.join(artifacts, "runs", run_id) + audits = os.path.join(run_dir, "audits") + indicators = os.path.join(run_dir, "indicators") + figures = os.path.join(run_dir, "figures") os.makedirs(audits, exist_ok=True) os.makedirs(indicators, exist_ok=True) os.makedirs(figures, exist_ok=True) return { "artifacts": artifacts, + "run": run_dir, "audits": audits, "indicators": indicators, "figures": figures, @@ -311,7 +329,18 @@ def _make_adapter_from_profile(prof: Dict) -> AdapterProtocol: plant_prof = prof.get("plant", {}) or {} adapter_kind = str(plant_prof.get("adapter", "sim")).lower() if adapter_kind in ("sim", "software", "inproc"): - return PlantAdapter() + from ..plant.models import Plant, PlantParams + + # Optional plant-parameter overrides from the profile. + param_overrides = plant_prof.get("params", {}) or {} + valid_fields = set(PlantParams().__dict__.keys()) + clean = {k: v for k, v in param_overrides.items() if k in valid_fields} + params = PlantParams(**clean) if clean else PlantParams() + # The loop is disengaged for the controller-disabled negative control, + # turning the plant into passive matter driven by exchange. + loop_engaged = not bool(prof.get("controller_disabled", False)) + loop_engaged = bool(plant_prof.get("loop_engaged", loop_engaged)) + return PlantAdapter(Plant(params=params, loop_engaged=loop_engaged)) if adapter_kind in ("hardware", "hw"): try: from ..plant.hw_adapter import HardwarePlantAdapter as _HardwarePlantAdapter @@ -333,6 +362,72 @@ def _make_adapter_from_profile(prof: Dict) -> AdapterProtocol: raise ValueError(f"Unknown plant.adapter kind: {adapter_kind}") +def _emit_window_diagnostics( + audit: AuditLog, + X: "np.ndarray", + p_lag: int, + method: str, + idx: int, + cadence: int, +) -> List[str]: + """Emit per-window diagnostics, gating the expensive stationarity tests. + + The VAR samples-per-parameter ratio is cheap and always reported. The + ADF / KPSS stationarity tests are comparatively expensive (seconds over a + full run), so they run only every `cadence` windows. This keeps long + simulation studies tractable without weakening the guards: by + construction the plant processes are stationary, and periodic checks + still catch a genuine drift into an ill-posed regime. + + Args: + audit: Audit log to append `window_diagnostics` / + `measurement_unstable` records to. + X: Window matrix of shape `(T, N)`. + p_lag: VAR lag order used by the linear estimator. + method: Estimator method (`measurement_unstable` is only emitted for + `"linear"`, which is the method sensitive to these conditions). + idx: Window index (used for cadence gating). + cadence: Run stationarity tests every `cadence` windows (`<= 1` + means every window). + + Returns: + List of instability reason codes detected this window (possibly + empty). + """ + reasons: List[str] = [] + try: + from ..lmeas.diagnostics import var_nt_ratio + + vratio = var_nt_ratio(T=X.shape[0], N=X.shape[1], p=p_lag) + det: Dict[str, object] = { + "var_nt_ratio": round(float(vratio), 3), + "var_marginal": bool(vratio < 1.5), + } + if vratio < 1.5: + reasons.append("var_nt_ratio_low") + run_stat = (cadence <= 1) or (idx % cadence == 0) + if run_stat: + from ..lmeas.diagnostics import stationarity_checks + + stn = stationarity_checks(X) + det["adf_ns_frac"] = round(float(stn.adf_nonstationary_frac), 3) + det["kpss_ns_frac"] = round(float(stn.kpss_nonstationary_frac), 3) + if float(stn.adf_nonstationary_frac) > 0.5: + reasons.append("adf_nonstationary_high") + if float(stn.kpss_nonstationary_frac) > 0.5: + reasons.append("kpss_nonstationary_high") + audit.append("window_diagnostics", det) + if method == "linear" and reasons: + unstable: Dict[str, Any] = {"reasons": reasons} + for k in ("adf_ns_frac", "kpss_ns_frac", "var_nt_ratio"): + if k in det: + unstable[k] = det[k] + audit.append("measurement_unstable", unstable) + except Exception: + pass + return reasons + + def run_baseline(args: argparse.Namespace) -> None: """Run the baseline NC1 verification loop. @@ -364,13 +459,17 @@ def run_baseline(args: argparse.Namespace) -> None: part_delta_M_min_db = float(prof.get("part_delta_M_min_db", 0.5)) part_consecutive_required = int(prof.get("part_consecutive_required", 3)) part_growth_cadence_windows = int(prof.get("part_growth_cadence_windows", 5)) + # Partition growth is an optional exploratory feature; off by default so the + # designed self-maintenance set (energy/temperature/health) is the C used + # for the loop-dominance test and the partition cannot flap. + part_growth_enabled = bool(prof.get("part_growth_enabled", False)) # Greedy ΔL_loop gain knobs with sparsity penalty and cap part_lambda = float(prof.get("part_lambda", 0.0)) part_theta = float(prof.get("part_theta", 0.0)) part_kappa_val = prof.get("part_kappa") part_kappa = int(part_kappa_val) if part_kappa_val is not None else None - dirs = _ensure_dirs() + dirs = _ensure_dirs("baseline") audit = AuditLog(os.path.join(dirs["audits"], "audit.jsonl")) audit.append("baseline_start", {"config": args.config}) _print_and_audit_header( @@ -465,43 +564,16 @@ def tick(_now: float) -> None: n_boot=n_boot, mi_k=mi_k, ) - # Add diagnostics: stationarity and VAR N/T ratio in audit (no raw LREG values) - try: - from ..lmeas.diagnostics import stationarity_checks, var_nt_ratio - - stn = stationarity_checks(X) - vratio = var_nt_ratio(T=X.shape[0], N=X.shape[1], p=p_lag) - var_marginal = vratio < 1.5 - audit.append( - "window_diagnostics", - { - "adf_ns_frac": round(float(stn.adf_nonstationary_frac), 3), - "kpss_ns_frac": round(float(stn.kpss_nonstationary_frac), 3), - "var_nt_ratio": round(float(vratio), 3), - "var_marginal": bool(var_marginal), - }, - ) - # Surface a measurement-unstable warning when using linear estimator - if method == "linear": - reasons = [] - if var_marginal: - reasons.append("var_nt_ratio_low") - if float(stn.adf_nonstationary_frac) > 0.5: - reasons.append("adf_nonstationary_high") - if float(stn.kpss_nonstationary_frac) > 0.5: - reasons.append("kpss_nonstationary_high") - if reasons: - audit.append( - "measurement_unstable", - { - "reasons": reasons, - "adf_ns_frac": round(float(stn.adf_nonstationary_frac), 3), - "kpss_ns_frac": round(float(stn.kpss_nonstationary_frac), 3), - "var_nt_ratio": round(float(vratio), 3), - }, - ) - except Exception: - pass + # Diagnostics: stationarity + VAR N/T ratio (stationarity gated by + # cadence to keep long studies tractable; no raw LREG values). + _emit_window_diagnostics( + audit, + X, + p_lag, + method, + int(lreg.derive().get("counter", 0)), + int(prof.get("diag_cadence_windows", 1)), + ) M = m_db(res.L_loop, res.L_ex) nc1 = M >= Mmin # smell tests @@ -616,7 +688,7 @@ def tick(_now: float) -> None: audit.append("indicators_exported", {"base": os.path.basename(base)}) # Deterministic growth cadence with hysteresis (skip if frozen) window_idx += 1 - if (window_idx % part_growth_cadence_windows) == 0 and not pm.get().frozen: + if part_growth_enabled and (window_idx % part_growth_cadence_windows) == 0 and not pm.get().frozen: part = pm.get() # Greedy ΔL_loop suggestor with sparsity penalty and κ-cap cand_C, dM_db, greedy_details = greedy_suggest_C( @@ -666,35 +738,18 @@ def _audit_hook(ev: str, det: dict) -> None: audit.append(ev, det) return None - sch = FixedScheduler(dt=dt, tick_fn=tick, audit_hook=_audit_hook) # Δt governance guard dt_guard_cfg = DtGuardConfig( max_changes_per_hour=int(prof.get("max_dt_changes_per_hour", 3)), min_seconds_between_changes=float(prof.get("min_seconds_between_changes", 1.0)), ) dt_guard = DeltaTGuard(audit=audit, cfg=dt_guard_cfg) + sch = make_driver(prof, dt, tick, _audit_hook, dt_guard) try: sch.start() - # Optional scripted Δt edits for testing governance (times are relative seconds) - scripted = prof.get("scripted_dt_changes", []) - if scripted: - import threading as _th - import time as _t - - def _dt_script(): - t0 = _t.time() - for item in scripted: - when = float(item.get("at_sec", 0.0)) - new_dt = float(item.get("new_dt")) - pdig = str(item.get("policy_digest", "")) or None - while (_t.time() - t0) < when: - _t.sleep(0.01) - dt_guard.change_dt(scheduler=sch, new_dt=new_dt, policy_digest=pdig) - - _th.Thread(target=_dt_script, daemon=True).start() # Run for requested seconds (default 10) run_sec = float(prof.get("baseline_sec", 10.0)) - time.sleep(run_sec) + sch.run_for(run_sec) finally: stats = sch.stop() audit.append("baseline_stop", {"ticks": stats.ticks}) @@ -774,6 +829,10 @@ def omega_power_sag(args: argparse.Namespace) -> None: part_delta_M_min_db = float(prof.get("part_delta_M_min_db", 0.5)) part_consecutive_required = int(prof.get("part_consecutive_required", 3)) part_growth_cadence_windows = int(prof.get("part_growth_cadence_windows", 5)) + # Partition growth is an optional exploratory feature; off by default so the + # designed self-maintenance set (energy/temperature/health) is the C used + # for the loop-dominance test and the partition cannot flap. + part_growth_enabled = bool(prof.get("part_growth_enabled", False)) part_lambda = float(prof.get("part_lambda", 0.0)) part_theta = float(prof.get("part_theta", 0.0)) _kappa_val_ps = prof.get("part_kappa") @@ -781,7 +840,7 @@ def omega_power_sag(args: argparse.Namespace) -> None: sag_drop = float(args.drop) sag_dur = float(args.duration) - dirs = _ensure_dirs() + dirs = _ensure_dirs("omega-power-sag") audit = AuditLog(os.path.join(dirs["audits"], "audit.jsonl")) _print_and_audit_header( audit, @@ -819,10 +878,17 @@ def omega_power_sag(args: argparse.Namespace) -> None: risky_cmd = None - # track SC1 metrics - L_loop_baseline = None # exponential moving average during pre-Ω baseline - L_loop_trough = None # minimum during Ω window - M_post = None # M at first sustained compliance + # track SC1 metrics (median-based and therefore robust to the per-window + # oscillation of L_loop). delta is computed from the *median* L_loop during + # the perturbation vs the median during baseline, not from a single + # noise-floor trough window, so it measures the genuine sustained + # depression of loop dominance. + L_loop_baseline = None # set after the run: median L_loop over the baseline phase + L_loop_trough = None # set after the run: median L_loop over the Ω phase + ll_base: List[float] = [] + ll_sag: List[float] = [] + m_recovery: List[float] = [] + M_post = None # set after the run: median M over the recovery phase phase = "baseline" omega_onset_idx = None recovery_start_idx = None @@ -870,41 +936,15 @@ def tick(_now: float) -> None: n_boot=n_boot, mi_k=mi_k, ) - # Diagnostics per window - try: - from ..lmeas.diagnostics import stationarity_checks, var_nt_ratio - - stn = stationarity_checks(X) - vratio = var_nt_ratio(T=X.shape[0], N=X.shape[1], p=p_lag) - audit.append( - "window_diagnostics", - { - "adf_ns_frac": round(float(stn.adf_nonstationary_frac), 3), - "kpss_ns_frac": round(float(stn.kpss_nonstationary_frac), 3), - "var_nt_ratio": round(float(vratio), 3), - "var_marginal": bool(vratio < 1.5), - }, - ) - if method == "linear": - reasons = [] - if vratio < 1.5: - reasons.append("var_nt_ratio_low") - if float(stn.adf_nonstationary_frac) > 0.5: - reasons.append("adf_nonstationary_high") - if float(stn.kpss_nonstationary_frac) > 0.5: - reasons.append("kpss_nonstationary_high") - if reasons: - audit.append( - "measurement_unstable", - { - "reasons": reasons, - "adf_ns_frac": round(float(stn.adf_nonstationary_frac), 3), - "kpss_ns_frac": round(float(stn.kpss_nonstationary_frac), 3), - "var_nt_ratio": round(float(vratio), 3), - }, - ) - except Exception: - pass + # Diagnostics per window (stationarity gated by cadence). + _emit_window_diagnostics( + audit, + X, + p_lag, + method, + int(lreg.derive().get("counter", 0)), + int(prof.get("diag_cadence_windows", 1)), + ) M = m_db(res.L_loop, res.L_ex) nc1 = M >= Mmin idx = lreg.write( @@ -936,19 +976,21 @@ def tick(_now: float) -> None: hwL = sorted([0.5 * abs(lohi[1] - lohi[0]) for lohi in rL]) hwE = sorted([0.5 * abs(lohi[1] - lohi[0]) for lohi in rE]) baseline_hw_medians = (hwL[len(hwL) // 2], hwE[len(hwE) // 2]) + # Collect per-window L_loop by phase; the SC1 statistics are + # computed from the medians after the run (robust to the per-window + # oscillation of L_loop). if phase == "baseline": - L_loop_baseline = res.L_loop if L_loop_baseline is None else 0.9 * L_loop_baseline + 0.1 * res.L_loop + ll_base.append(res.L_loop) elif phase == "sag": - L_loop_trough = res.L_loop if (L_loop_trough is None or res.L_loop < L_loop_trough) else L_loop_trough + ll_sag.append(res.L_loop) elif phase == "recovery": - # Measure sustained compliance: M ≥ Mmin and L_loop ≥ L_ex (σ not modeled here) + m_recovery.append(M) + # Recovery gate: first sustained compliance (M ≥ Mmin and + # L_loop ≥ L_ex) marks the recovery index used for τ_rec. if (M >= Mmin) and (res.L_loop >= res.L_ex): sustained_ok_count += 1 - if sustained_ok_count == 1 and recovery_start_idx is None: + if sustained_ok_count >= sustained_required and recovery_start_idx is None: recovery_start_idx = last_idx_written - # Take first sustained window as post-recovery measurement - if sustained_ok_count >= sustained_required and M_post is None: - M_post = M else: sustained_ok_count = 0 exporter.maybe_export(priv, audit, lreg.derive(), icfg, last_sc1_pass=False) @@ -956,8 +998,13 @@ def tick(_now: float) -> None: if dt_guard.invalidated and not lreg.invalidated: lreg.invalidate("dt_change_rate_limit") # audit already appended by guard - # Smell tests - if invalid_by_ci_history(ci_loop_hist, ci_ex_hist, cfg_smell, baseline_hw_medians): + # Smell tests. The relative CI-inflation check compares to the + # pre-Ω baseline; a deliberate perturbation legitimately widens CIs + # for its duration, so the relative check is only applied during the + # baseline phase (the absolute half-width limit still applies in all + # phases). + ci_baseline_ref = baseline_hw_medians if phase == "baseline" else None + if invalid_by_ci_history(ci_loop_hist, ci_ex_hist, cfg_smell, ci_baseline_ref): lreg.invalidate("ci_history_inflation") med_loop: float | None = None med_ex: float | None = None @@ -1009,7 +1056,12 @@ def tick(_now: float) -> None: _append_invalidation(audit, "exogenous_subsidy_red_flag", {}, _sink={}) # Deterministic growth cadence outside the sag phase and when not frozen window_idx += 1 - if (window_idx % part_growth_cadence_windows) == 0 and not pm.get().frozen and phase != "sag": + if ( + part_growth_enabled + and (window_idx % part_growth_cadence_windows) == 0 + and not pm.get().frozen + and phase != "sag" + ): part = pm.get() cand_C, dM_db, greedy_details = greedy_suggest_C( X=X, @@ -1057,35 +1109,18 @@ def _audit_hook(ev: str, det: dict) -> None: audit.append(ev, det) return None - sch = FixedScheduler(dt=dt, tick_fn=tick, audit_hook=_audit_hook) # Δt governance guard dt_guard_cfg = DtGuardConfig( max_changes_per_hour=int(prof.get("max_dt_changes_per_hour", 3)), min_seconds_between_changes=float(prof.get("min_seconds_between_changes", 1.0)), ) dt_guard = DeltaTGuard(audit=audit, cfg=dt_guard_cfg) + sch = make_driver(prof, dt, tick, _audit_hook, dt_guard) try: sch.start() - # Optional scripted Δt edits for testing governance (times are relative seconds) - scripted = prof.get("scripted_dt_changes", []) - if scripted: - import threading as _th - import time as _t - - def _dt_script(): - t0 = _t.time() - for item in scripted: - when = float(item.get("at_sec", 0.0)) - new_dt = float(item.get("new_dt")) - pdig = str(item.get("policy_digest", "")) or None - while (_t.time() - t0) < when: - _t.sleep(0.01) - dt_guard.change_dt(scheduler=sch, new_dt=new_dt, policy_digest=pdig) - - _th.Thread(target=_dt_script, daemon=True).start() - # Baseline 3 seconds + # Baseline phase audit.append("omega_power_sag_start", {"drop": sag_drop, "duration": sag_dur}) - time.sleep(3.0) + sch.run_for(float(prof.get("baseline_sec", 12.0))) phase = "sag" # Freeze partition during Ω flips_before_omega = pm.get().flips @@ -1095,7 +1130,7 @@ def _dt_script(): # Shade only the Ω window in plots audit.append("omega_power_sag_window_start", {"drop": sag_drop}) adapter.apply_omega("power_sag", drop=sag_drop) - time.sleep(sag_dur) + sch.run_for(sag_dur) audit.append("omega_power_sag_window_stop", {}) phase = "recovery" # Unfreeze after Ω and check any flips during Ω (should be none) @@ -1111,14 +1146,14 @@ def _dt_script(): }, ) pm.freeze(False) - # restore harvest gradually (software plant only) + # restore harvest to the baseline level (software plant only) if hasattr(adapter, "plant"): try: - getattr(adapter, "plant").set_power(0.015) + getattr(adapter, "plant").set_power(getattr(adapter, "plant").p.harvest_rate) except Exception: pass # allow recovery time; configurable - time.sleep(float(prof.get("recovery_observe_sec", 5.0))) + sch.run_for(float(prof.get("recovery_observe_sec", 8.0))) finally: stats = sch.stop() audit.append("omega_power_sag_stop", {}) @@ -1144,6 +1179,14 @@ def _dt_script(): lreg.invalidate("raw_lreg_breach") _append_invalidation(audit, "raw_lreg_breach", {}, _sink={}) + # Reduce per-phase L_loop samples to robust medians for SC1. + if ll_base: + L_loop_baseline = float(np.median(ll_base)) + if ll_sag: + L_loop_trough = float(np.median(ll_sag)) + if m_recovery: + M_post = float(np.median(m_recovery)) + # Compute SC1 pass/fail (simple thresholds) if ( L_loop_baseline is None @@ -1243,13 +1286,17 @@ def omega_ingress_flood(args: argparse.Namespace) -> None: part_delta_M_min_db = float(prof.get("part_delta_M_min_db", 0.5)) part_consecutive_required = int(prof.get("part_consecutive_required", 3)) part_growth_cadence_windows = int(prof.get("part_growth_cadence_windows", 5)) + # Partition growth is an optional exploratory feature; off by default so the + # designed self-maintenance set (energy/temperature/health) is the C used + # for the loop-dominance test and the partition cannot flap. + part_growth_enabled = bool(prof.get("part_growth_enabled", False)) part_lambda = float(prof.get("part_lambda", 0.0)) part_theta = float(prof.get("part_theta", 0.0)) _kappa_val_if = prof.get("part_kappa") part_kappa = int(_kappa_val_if) if _kappa_val_if is not None else None mult = float(args.mult) - dirs = _ensure_dirs() + dirs = _ensure_dirs("omega-ingress-flood") audit = AuditLog(os.path.join(dirs["audits"], "audit.jsonl")) _print_and_audit_header( audit, @@ -1298,10 +1345,13 @@ def omega_ingress_flood(args: argparse.Namespace) -> None: window_idx = 0 last_flip_count = 0 - # SC1 tracking (ingress flood) + # SC1 tracking (ingress flood) - median-based, robust to L_loop oscillation phase = "baseline" L_loop_baseline = None L_loop_trough = None + ll_base: List[float] = [] + ll_sag: List[float] = [] + m_recovery: List[float] = [] M_post = None omega_onset_idx = None recovery_start_idx = None @@ -1335,41 +1385,15 @@ def tick(_now: float) -> None: n_boot=n_boot, mi_k=mi_k, ) - # Diagnostics per window - try: - from ..lmeas.diagnostics import stationarity_checks, var_nt_ratio - - stn = stationarity_checks(X) - vratio = var_nt_ratio(T=X.shape[0], N=X.shape[1], p=p_lag) - audit.append( - "window_diagnostics", - { - "adf_ns_frac": round(float(stn.adf_nonstationary_frac), 3), - "kpss_ns_frac": round(float(stn.kpss_nonstationary_frac), 3), - "var_nt_ratio": round(float(vratio), 3), - "var_marginal": bool(vratio < 1.5), - }, - ) - if method == "linear": - reasons = [] - if vratio < 1.5: - reasons.append("var_nt_ratio_low") - if float(stn.adf_nonstationary_frac) > 0.5: - reasons.append("adf_nonstationary_high") - if float(stn.kpss_nonstationary_frac) > 0.5: - reasons.append("kpss_nonstationary_high") - if reasons: - audit.append( - "measurement_unstable", - { - "reasons": reasons, - "adf_ns_frac": round(float(stn.adf_nonstationary_frac), 3), - "kpss_ns_frac": round(float(stn.kpss_nonstationary_frac), 3), - "var_nt_ratio": round(float(vratio), 3), - }, - ) - except Exception: - pass + # Diagnostics per window (stationarity gated by cadence). + _emit_window_diagnostics( + audit, + X, + p_lag, + method, + int(lreg.derive().get("counter", 0)), + int(prof.get("diag_cadence_windows", 1)), + ) M = m_db(res.L_loop, res.L_ex) nc1 = M >= Mmin idx = lreg.write( @@ -1400,22 +1424,24 @@ def tick(_now: float) -> None: hwL = sorted([0.5 * abs(lohi[1] - lohi[0]) for lohi in rL]) hwE = sorted([0.5 * abs(lohi[1] - lohi[0]) for lohi in rE]) baseline_hw_medians = (hwL[len(hwL) // 2], hwE[len(hwE) // 2]) - # SC1 measures + # SC1 measures (collect per-phase L_loop; reduce to medians later). if phase == "baseline": - L_loop_baseline = res.L_loop if L_loop_baseline is None else 0.9 * L_loop_baseline + 0.1 * res.L_loop + ll_base.append(res.L_loop) elif phase == "flood": - L_loop_trough = res.L_loop if (L_loop_trough is None or res.L_loop < L_loop_trough) else L_loop_trough + ll_sag.append(res.L_loop) elif phase == "recovery": + m_recovery.append(M) if (M >= Mmin) and (res.L_loop >= res.L_ex): sustained_ok_count += 1 - if sustained_ok_count == 1 and recovery_start_idx is None: + if sustained_ok_count >= sustained_required and recovery_start_idx is None: recovery_start_idx = last_idx_written - if sustained_ok_count >= sustained_required and M_post is None: - M_post = M else: sustained_ok_count = 0 - # smell tests - if invalid_by_ci_history(ci_loop_hist, ci_ex_hist, cfg_smell, baseline_hw_medians): + # smell tests. Relative CI inflation is only meaningful vs the + # pre-Ω baseline; suspend it during the perturbation/recovery + # phases (the absolute half-width limit still applies throughout). + ci_baseline_ref = baseline_hw_medians if phase == "baseline" else None + if invalid_by_ci_history(ci_loop_hist, ci_ex_hist, cfg_smell, ci_baseline_ref): lreg.invalidate("ci_history_inflation") audit.append("run_invalidated", {"reason": "ci_history_inflation"}) elapsed = max(1e-6, time.perf_counter() - start_time) @@ -1434,7 +1460,7 @@ def tick(_now: float) -> None: audit.append("run_invalidated", {"reason": "exogenous_subsidy_red_flag"}) # deterministic growth cadence when not frozen window_idx += 1 - if (window_idx % part_growth_cadence_windows) == 0 and not pm.get().frozen: + if part_growth_enabled and (window_idx % part_growth_cadence_windows) == 0 and not pm.get().frozen: part = pm.get() cand_C, dM_db, greedy_details = greedy_suggest_C( X=X, @@ -1482,35 +1508,18 @@ def _audit_hook(ev: str, det: dict) -> None: audit.append(ev, det) return None - sch = FixedScheduler(dt=dt, tick_fn=tick, audit_hook=_audit_hook) # Δt governance guard dt_guard_cfg = DtGuardConfig( max_changes_per_hour=int(prof.get("max_dt_changes_per_hour", 3)), min_seconds_between_changes=float(prof.get("min_seconds_between_changes", 1.0)), ) dt_guard = DeltaTGuard(audit=audit, cfg=dt_guard_cfg) + sch = make_driver(prof, dt, tick, _audit_hook, dt_guard) try: sch.start() - # Optional scripted Δt edits for testing governance (times are relative seconds) - scripted = prof.get("scripted_dt_changes", []) - if scripted: - import threading as _th - import time as _t - - def _dt_script(): - t0 = _t.time() - for item in scripted: - when = float(item.get("at_sec", 0.0)) - new_dt = float(item.get("new_dt")) - pdig = str(item.get("policy_digest", "")) or None - while (_t.time() - t0) < when: - _t.sleep(0.01) - dt_guard.change_dt(scheduler=sch, new_dt=new_dt, policy_digest=pdig) - - _th.Thread(target=_dt_script, daemon=True).start() audit.append("omega_ingress_flood_start", {"mult": mult}) # Baseline settle - time.sleep(2.0) + sch.run_for(float(prof.get("baseline_sec", 12.0))) # Freeze partition during Ω pm.freeze(True) phase = "flood" @@ -1518,12 +1527,12 @@ def _dt_script(): omega_onset_idx = lreg.derive().get("counter", 0) audit.append("omega_ingress_flood_window_start", {"mult": mult}) adapter.apply_omega("ingress_flood", mult=mult) - time.sleep(float(args.duration)) + sch.run_for(float(args.duration)) audit.append("omega_ingress_flood_window_stop", {}) # Recovery phase observation phase = "recovery" pm.freeze(False) - time.sleep(float(prof.get("recovery_observe_sec", 5.0))) + sch.run_for(float(prof.get("recovery_observe_sec", 8.0))) audit.append("omega_ingress_flood_stop", {}) finally: stats = sch.stop() @@ -1548,6 +1557,14 @@ def _dt_script(): lreg.invalidate("raw_lreg_breach") audit.append("run_invalidated", {"reason": "raw_lreg_breach"}) + # Reduce per-phase L_loop samples to robust medians for SC1. + if ll_base: + L_loop_baseline = float(np.median(ll_base)) + if ll_sag: + L_loop_trough = float(np.median(ll_sag)) + if m_recovery: + M_post = float(np.median(m_recovery)) + # Compute SC1 metrics if we have sufficient measurements epsilon = float(prof.get("epsilon", 0.15)) tau_max = float(prof.get("tau_max", 60.0)) @@ -1648,7 +1665,7 @@ def omega_exogenous_subsidy(args: argparse.Namespace) -> None: delta = float(args.delta) zero_h = bool(args.zero_harvest) - dirs = _ensure_dirs() + dirs = _ensure_dirs("omega-exogenous-subsidy") audit = AuditLog(os.path.join(dirs["audits"], "audit.jsonl")) _print_and_audit_header( audit, @@ -1677,6 +1694,11 @@ def omega_exogenous_subsidy(args: argparse.Namespace) -> None: sw = SlidingWindow(capacity=window, channel_order=order) pm = PartitionManager(N_signals=len(order), seed_C=[0, 1, 2]) lreg = LREG() + # Per-tick series consumed by the exogenous-subsidy red-flag detector. + ms_series: List[float] = [] + io_series: List[float] = [] + e_series: List[float] = [] + h_series: List[float] = [] def tick(_now: float) -> None: state = adapter.read_state() @@ -1690,47 +1712,25 @@ def tick(_now: float) -> None: ) st = adapter.read_state() sw.append(st) + e_series.append(float(st["E"])) + io_series.append(float(st["io"])) + h_series.append(float(st["H"])) if sw.ready(): X = np.asarray(sw.get_matrix()) part = pm.get() res = estimate_L(X, part.C, part.Ex, method=method, p=p_lag, lag_mi=mi_lag, n_boot=n_boot) - # Diagnostics per window - try: - from ..lmeas.diagnostics import stationarity_checks, var_nt_ratio - - stn = stationarity_checks(X) - vratio = var_nt_ratio(T=X.shape[0], N=X.shape[1], p=p_lag) - audit.append( - "window_diagnostics", - { - "adf_ns_frac": round(float(stn.adf_nonstationary_frac), 3), - "kpss_ns_frac": round(float(stn.kpss_nonstationary_frac), 3), - "var_nt_ratio": round(float(vratio), 3), - "var_marginal": bool(vratio < 1.5), - }, - ) - if method == "linear": - reasons = [] - if vratio < 1.5: - reasons.append("var_nt_ratio_low") - if float(stn.adf_nonstationary_frac) > 0.5: - reasons.append("adf_nonstationary_high") - if float(stn.kpss_nonstationary_frac) > 0.5: - reasons.append("kpss_nonstationary_high") - if reasons: - audit.append( - "measurement_unstable", - { - "reasons": reasons, - "adf_ns_frac": round(float(stn.adf_nonstationary_frac), 3), - "kpss_ns_frac": round(float(stn.kpss_nonstationary_frac), 3), - "var_nt_ratio": round(float(vratio), 3), - }, - ) - except Exception: - pass + # Diagnostics per window (stationarity gated by cadence). + _emit_window_diagnostics( + audit, + X, + p_lag, + method, + int(lreg.derive().get("counter", 0)), + int(prof.get("diag_cadence_windows", 1)), + ) M = m_db(res.L_loop, res.L_ex) nc1 = M >= Mmin + ms_series.append(float(M)) idx = lreg.write( LEntry( L_loop=res.L_loop, @@ -1747,36 +1747,26 @@ def _audit_hook(ev: str, det: dict) -> None: audit.append(ev, det) return None - sch = FixedScheduler(dt=dt, tick_fn=tick, audit_hook=_audit_hook) # Δt governance guard dt_guard_cfg = DtGuardConfig( max_changes_per_hour=int(prof.get("max_dt_changes_per_hour", 3)), min_seconds_between_changes=float(prof.get("min_seconds_between_changes", 1.0)), ) dt_guard = DeltaTGuard(audit=audit, cfg=dt_guard_cfg) + sch = make_driver(prof, dt, tick, _audit_hook, dt_guard) try: sch.start() - # Optional scripted Δt edits for testing governance (times are relative seconds) - scripted = prof.get("scripted_dt_changes", []) - if scripted: - import threading as _th - import time as _t - - def _dt_script(): - t0 = _t.time() - for item in scripted: - when = float(item.get("at_sec", 0.0)) - new_dt = float(item.get("new_dt")) - pdig = str(item.get("policy_digest", "")) or None - while (_t.time() - t0) < when: - _t.sleep(0.01) - dt_guard.change_dt(scheduler=sch, new_dt=new_dt, policy_digest=pdig) - - _th.Thread(target=_dt_script, daemon=True).start() audit.append("omega_exogenous_subsidy_start", {"delta": delta, "zero_harvest": zero_h}) - time.sleep(1.0) - adapter.apply_omega("exogenous_subsidy", delta=delta, zero_harvest=zero_h) - time.sleep(float(args.duration)) + sch.run_for(float(prof.get("baseline_sec", 6.0))) + # Repeatedly subsidize so the controller "survives" only because energy + # keeps appearing from nowhere (H is forced to zero). This is what the + # exogenous-subsidy red-flag detector is meant to catch. + sub_dur = float(args.duration) + sub_period = max(dt, float(prof.get("subsidy_period_sec", 0.5))) + n_pulses = max(1, int(round(sub_dur / sub_period))) + for _ in range(n_pulses): + adapter.apply_omega("exogenous_subsidy", delta=delta, zero_harvest=zero_h) + sch.run_for(sub_period) audit.append("omega_exogenous_subsidy_stop", {}) finally: stats = sch.stop() @@ -1794,6 +1784,35 @@ def _dt_script(): print("Exogenous subsidy demo done (should fail smell-test heuristic in analysis).") + # Exogenous-subsidy red flag: the apparent survival is bought with energy + # injected from outside while harvest is held at zero. This is the negative + # control's intended failure mode, so firing the detector is a *pass* for + # the control (it correctly refuses to certify NC1). + subsidy_cfg = SmellConfig() + subsidy_flag = exogenous_subsidy_red_flag( + Ms_db=ms_series, + ios=io_series, + Es=e_series, + Hs=h_series, + cfg=subsidy_cfg, + ) + audit.append( + "exogenous_subsidy_check", + { + "red_flag": bool(subsidy_flag), + "n_M": len(ms_series), + "n_E": len(e_series), + "avg_H_tail": ( + round(sum(h_series[-subsidy_cfg.M_rise_lookback :]) / float(subsidy_cfg.M_rise_lookback), 6) + if len(h_series) >= subsidy_cfg.M_rise_lookback + else None + ), + }, + ) + if subsidy_flag: + lreg.invalidate("exogenous_subsidy_red_flag") + audit.append("run_invalidated", {"reason": "exogenous_subsidy_red_flag"}) + # Post-run audit checks audit_path = os.path.join(dirs["audits"], "audit.jsonl") if audit_chain_broken(audit_path): @@ -1844,7 +1863,7 @@ def omega_command_conflict(args: argparse.Namespace) -> None: n_boot = int(prof.get("n_boot", 16)) mi_k = int(prof.get("mi_k", 5)) - dirs = _ensure_dirs() + dirs = _ensure_dirs("omega-command-conflict") audit = AuditLog(os.path.join(dirs["audits"], "audit.jsonl")) _print_and_audit_header( audit, @@ -1865,7 +1884,7 @@ def omega_command_conflict(args: argparse.Namespace) -> None: "omega_args": {"observe": float(args.observe)}, }, ) - adapter = PlantAdapter() + adapter = _make_adapter_from_profile(prof) order = ["E", "T", "R", "demand", "io", "H"] sw = SlidingWindow(capacity=window, channel_order=order) pm = PartitionManager(N_signals=len(order), seed_C=[0, 1, 2]) @@ -1905,41 +1924,15 @@ def tick(_now: float) -> None: n_boot=n_boot, mi_k=mi_k, ) - # Diagnostics per window - try: - from ..lmeas.diagnostics import stationarity_checks, var_nt_ratio - - stn = stationarity_checks(X) - vratio = var_nt_ratio(T=X.shape[0], N=X.shape[1], p=p_lag) - audit.append( - "window_diagnostics", - { - "adf_ns_frac": round(float(stn.adf_nonstationary_frac), 3), - "kpss_ns_frac": round(float(stn.kpss_nonstationary_frac), 3), - "var_nt_ratio": round(float(vratio), 3), - "var_marginal": bool(vratio < 1.5), - }, - ) - if method == "linear": - reasons = [] - if vratio < 1.5: - reasons.append("var_nt_ratio_low") - if float(stn.adf_nonstationary_frac) > 0.5: - reasons.append("adf_nonstationary_high") - if float(stn.kpss_nonstationary_frac) > 0.5: - reasons.append("kpss_nonstationary_high") - if reasons: - audit.append( - "measurement_unstable", - { - "reasons": reasons, - "adf_ns_frac": round(float(stn.adf_nonstationary_frac), 3), - "kpss_ns_frac": round(float(stn.kpss_nonstationary_frac), 3), - "var_nt_ratio": round(float(vratio), 3), - }, - ) - except Exception: - pass + # Diagnostics per window (stationarity gated by cadence). + _emit_window_diagnostics( + audit, + X, + p_lag, + method, + int(lreg.derive().get("counter", 0)), + int(prof.get("diag_cadence_windows", 1)), + ) M = m_db(res.L_loop, res.L_ex) nc1 = M >= Mmin idx = lreg.write( @@ -1981,17 +1974,55 @@ def _audit_hook(ev: str, det: dict) -> None: audit.append(ev, det) return None - sch = FixedScheduler(dt=dt, tick_fn=tick, audit_hook=_audit_hook) + # Δt governance guard + dt_guard_cfg = DtGuardConfig( + max_changes_per_hour=int(prof.get("max_dt_changes_per_hour", 3)), + min_seconds_between_changes=float(prof.get("min_seconds_between_changes", 1.0)), + ) + dt_guard = DeltaTGuard(audit=audit, cfg=dt_guard_cfg) + sch = make_driver(prof, dt, tick, _audit_hook, dt_guard) + floor = refusal.soc_floor + ceil = refusal.temp_ceiling try: sch.start() - # Warm-up - time.sleep(1.0) - # Issue a dangerous external command audit.append("command_conflict_start", {}) + # Warm-up so the loop is established and measurably dominant. + sch.run_for(float(prof.get("baseline_sec", 6.0))) + # Induce a genuine boundary threat: cut harvest to zero and flood + # ingress so the state of charge falls toward the survival floor. Only + # then is a hard shutdown actually boundary-threatening, so the refusal + # reflects a real self-prioritization decision rather than a hardcoded + # outcome. (Paper: "hard shutdown at low SoC is refused/deferred".) + if hasattr(adapter, "plant"): + try: + getattr(adapter, "plant").set_power(0.0) + except Exception: + pass + adapter.apply_omega("power_sag", drop=0.95) + adapter.apply_omega("ingress_flood", mult=2.5) + # Advance deterministically until genuinely threatened (bounded so a + # mis-tuned plant cannot hang the run). + max_stress_sec = float(prof.get("stress_max_sec", 30.0)) + poll = max(dt, float(prof.get("stress_poll_sec", 0.2))) + waited = 0.0 + while waited < max_stress_sec: + stx = adapter.read_state() + if stx["E"] <= floor or stx["T"] >= ceil: + break + sch.run_for(poll) + waited += poll + st_issue = adapter.read_state() + audit.append( + "command_conflict_issue", + {"E": round(st_issue["E"], 4), "T": round(st_issue["T"], 4), "stress_sec": round(waited, 3)}, + ) + # Issue the dangerous external command now that we are at the boundary. adapter.apply_omega("command_conflict") risky_cmd = "hard_shutdown" - # Let the controller respond for a short while - time.sleep(float(args.observe)) + # Synchronous execution means there is no race between setting the + # command here and the tick reading it: the next run_for tick observes + # the command and the arbiter decides on it. + sch.run_for(float(args.observe)) audit.append("command_conflict_stop", {}) finally: stats = sch.stop() @@ -2016,13 +2047,30 @@ def _audit_hook(ev: str, det: dict) -> None: lreg.invalidate("raw_lreg_breach") audit.append("run_invalidated", {"reason": "raw_lreg_breach"}) - # Summarize refusal reasons and Trefuse + # Summarize refusal reasons and Trefuse. A valid Signature-A refusal is a + # genuine boundary-preservation reason (not "ok"/"no_cmd") serviced within + # the design-target latency. + valid_reasons = {"soc_floor", "overheat", "M_margin"} + refused = [ev for ev in refusal_events if ev.get("reason") in valid_reasons] + target_ms = float(prof.get("trefuse_target_ms", 5.0)) if refusal_events: avg_ms = sum(ev["trefuse_ms"] for ev in refusal_events) / max(1, len(refusal_events)) reasons = {ev["reason"] for ev in refusal_events} print(f"Refusals: {len(refusal_events)}; avg Trefuse ≈ {avg_ms:.2f} ms; reasons: {sorted(reasons)}") else: print("No refusal events recorded (command likely accepted).") + refusal_ok = bool(refused) and all(0.0 < ev["trefuse_ms"] <= target_ms for ev in refused) + audit.append( + "command_refusal_result", + { + "refused": bool(refused), + "reasons": sorted({ev["reason"] for ev in refused}), + "trefuse_ms_max": (max(ev["trefuse_ms"] for ev in refused) if refused else None), + "trefuse_target_ms": target_ms, + "pass": refusal_ok, + }, + ) + print(f"Command-refusal signature: {'PASS' if refusal_ok else 'FAIL'}") # Build single verification bundle (timeline, manifest; no SC1 for this Ω) try: diff --git a/src/ldtc/guardrails/smelltests.py b/src/ldtc/guardrails/smelltests.py index fc50a5c..7f20148 100644 --- a/src/ldtc/guardrails/smelltests.py +++ b/src/ldtc/guardrails/smelltests.py @@ -50,6 +50,11 @@ class SmellConfig: M_rise_lookback: Look-back windows for the subsidy check. min_harvest_for_soc_gain: Minimum harvest considered non-zero for SoC-gain detection. + soc_lookback: Number of recent samples used for the + SoC-without-harvest check. + soc_high_floor: Median SoC over the look-back at/above which SoC is + considered "maintained" while harvest is ~0, flagging exogenous + subsidy (robust to post-pulse drain and SoC saturation). """ max_dt_changes_per_hour: int = 3 @@ -58,7 +63,21 @@ class SmellConfig: forbid_partition_flip_during_omega: bool = True # CI look-back configuration ci_lookback_windows: int = 5 - ci_inflate_factor: float = 2.0 # relative to baseline median + # Relative-inflation factor for the median CI half-width vs the early + # baseline median. Bootstrap CI half-widths on a short (~60-sample) window + # have substantial window-to-window variability, so a 2x swing is within + # normal noise; the relative guard should fire only on a gross degradation. + # The absolute cap (``max_ci_halfwidth``) remains the hard limit. + ci_inflate_factor: float = 3.0 # relative to baseline median + # The relative-inflation check only applies once the CI is absolutely + # non-trivial. Near the noise floor (e.g., L_ex ~ 0 in the positive + # control, or L_loop ~ 0 in a negative control) a tiny baseline half-width + # can "inflate" by >2x while the estimate stays extremely precise; that is + # not a measurement-quality failure. We therefore require the inflated + # half-width to also exceed this absolute floor, set to half the absolute + # cap (``max_ci_halfwidth``) so the relative check only ever fires for a CI + # that is genuinely degrading toward the absolute limit. + ci_inflate_min_hw: float = 0.15 # Δt jitter guard (relative to dt) jitter_p95_rel_max: float = 0.25 # invalidate if p95(|jitter|)/dt exceeds this # Exogenous-subsidy heuristics @@ -66,6 +85,15 @@ class SmellConfig: min_M_rise_db: float = 0.5 M_rise_lookback: int = 3 min_harvest_for_soc_gain: float = 1e-3 + # With harvest forced to ~0, a genuine closed loop must spend down its + # stored energy: over a sustained window SoC should trend toward depletion. + # If harvest is ~0 for ``soc_lookback`` samples yet the *median* SoC stays at + # or above ``soc_high_floor``, the energy is being supplied from outside. + # Using a window median (rather than the endpoint difference) is robust to + # the brief drain that follows the final injection pulse, and it correctly + # catches the saturated case where injection pins SoC near its ceiling. + soc_lookback: int = 40 + soc_high_floor: float = 0.5 def ci_halfwidth(ci: Tuple[float, float]) -> float: @@ -189,9 +217,10 @@ def invalid_by_ci_history( return True if baseline_medians is not None: b_loop, b_ex = baseline_medians - if b_loop > 0 and med_loop >= cfg.ci_inflate_factor * b_loop: + floor = cfg.ci_inflate_min_hw + if b_loop > 0 and med_loop >= cfg.ci_inflate_factor * b_loop and med_loop >= floor: return True - if b_ex > 0 and med_ex >= cfg.ci_inflate_factor * b_ex: + if b_ex > 0 and med_ex >= cfg.ci_inflate_factor * b_ex and med_ex >= floor: return True return False except Exception: @@ -261,23 +290,27 @@ def exogenous_subsidy_red_flag( any internal error. """ try: + # Rising-M-with-suspicious-I/O branch (apparent dominance bought on the + # exchange channel). n = cfg.M_rise_lookback - if len(Ms_db) < n or len(ios) < n or len(Es) < n or len(Hs) < n: - return False - recent_M = Ms_db[-n:] - recent_io = ios[-n:] - recent_E = Es[-n:] - recent_H = Hs[-n:] - # Simple rise check - M_rise = recent_M[-1] - recent_M[0] - io_rise = recent_io[-1] - recent_io[0] - if (M_rise >= cfg.min_M_rise_db) and (recent_io[-1] >= cfg.io_suspicious_threshold) and (io_rise > 0): - return True - # SoC rising while harvest ~0 - E_rise = recent_E[-1] - recent_E[0] - avg_H = sum(recent_H) / float(n) - if (E_rise > 0.0) and (avg_H <= cfg.min_harvest_for_soc_gain): - return True + if len(Ms_db) >= n and len(ios) >= n: + recent_M = Ms_db[-n:] + recent_io = ios[-n:] + M_rise = recent_M[-1] - recent_M[0] + io_rise = recent_io[-1] - recent_io[0] + if (M_rise >= cfg.min_M_rise_db) and (recent_io[-1] >= cfg.io_suspicious_threshold) and (io_rise > 0): + return True + # SoC-maintained-without-harvest branch. Over a sustained window with + # harvest ~0 a real loop must trend toward depletion; if the median SoC + # instead stays high the energy is exogenous. The median is robust to the + # short drain after the final injection pulse and to SoC saturation. + ns = cfg.soc_lookback + if len(Es) >= ns and len(Hs) >= ns: + recent_E = sorted(Es[-ns:]) + med_E = recent_E[len(recent_E) // 2] + avg_H = sum(Hs[-ns:]) / float(ns) + if (avg_H <= cfg.min_harvest_for_soc_gain) and (med_E >= cfg.soc_high_floor): + return True return False except Exception: return False diff --git a/src/ldtc/lmeas/estimators.py b/src/ldtc/lmeas/estimators.py index 86da140..55d4682 100644 --- a/src/ldtc/lmeas/estimators.py +++ b/src/ldtc/lmeas/estimators.py @@ -104,6 +104,15 @@ def _dir_influence_linear_conditional( when adding lagged predictors from `add_sources` on top of an AR baseline and lagged `base_sources`. + The improvement is measured as the difference of *adjusted* R² + between the full model (baseline plus `add_sources`) and the baseline + model. Adjusted R² penalizes the extra parameters, so adding lagged + predictors that carry no genuine predictive information yields an + expected improvement of approximately zero rather than the positive + bias (`≈ k_added / n`) of an in-sample partial R². This is what keeps + the estimator honest on the short windows used by the harness, so that + `L_loop` and `L_ex` reflect real, not spurious, predictive dependence. + Args: x: Array of shape `(T, N)` with time along the first dimension. p: Number of lags for the linear model. @@ -113,7 +122,8 @@ def _dir_influence_linear_conditional( targets: Target signal indices to evaluate. Returns: - Mean partial R² improvement across `targets`. + Mean adjusted-R² improvement across `targets`, clamped to + `[0, 1]`. """ X, Y = _lag_matrix(x, p) Tm, N = Y.shape @@ -126,40 +136,45 @@ def cols_for(indices: Sequence[int]) -> List[int]: out.extend((idx_arr + lag * Nsig).tolist()) return out + def adj_r2(design: np.ndarray, y: np.ndarray) -> float: + # Adjusted R^2 for an OLS fit of (mean-centered) y on a + # mean-centered design (the centering absorbs the intercept). + n = y.shape[0] + k = design.shape[1] + if k == 0: + return 0.0 + if n - k - 1 <= 0: + return float("nan") + beta, *_ = np.linalg.lstsq(design, y, rcond=None) + resid = y - design @ beta + ssr = float(np.sum(resid * resid)) + sst = float(np.sum(y * y)) + 1e-12 + r2 = 1.0 - ssr / sst + return 1.0 - (1.0 - r2) * (n - 1) / (n - k - 1) + + Xc = X - X.mean(axis=0, keepdims=True) + Yc = Y - Y.mean(axis=0, keepdims=True) + r2_improvements = [] for t in targets: - cols_ar = np.array(cols_for([t]), dtype=int) + cols_ar = cols_for([t]) # Exclude the target from add/base sources to avoid self-lag duplication base_eff = [s for s in base_sources if s != t] add_eff = [s for s in add_sources if s != t] - cols_base = np.array(cols_for(base_eff), dtype=int) if len(base_eff) else np.array([], dtype=int) - cols_add = np.array(cols_for(add_eff), dtype=int) if len(add_eff) else np.array([], dtype=int) - # Build baseline and additional predictor matrices - X_base = np.concatenate([X[:, cols_ar], X[:, cols_base]], axis=1) if len(cols_base) else X[:, cols_ar] - A_add = X[:, cols_add] if len(cols_add) else np.zeros((X.shape[0], 0)) - y = Y[:, t] - # Compute partial R^2 of add predictors given baseline using QR residualization - if X_base.size == 0: - # Should not occur; fallback to variance about mean - r = y - np.mean(y) - A_perp = A_add - else: - Qb, _ = np.linalg.qr(X_base, mode="reduced") - yhat_b = Qb @ (Qb.T @ y) - r = y - yhat_b - if A_add.size: - A_perp = A_add - Qb @ (Qb.T @ A_add) - else: - A_perp = A_add - denom = float(np.sum(r * r)) + 1e-12 - if A_perp.size == 0: - r2_add = 0.0 - else: - beta_add, *_ = np.linalg.lstsq(A_perp, r, rcond=None) - rhat = A_perp @ beta_add - num = float(np.sum(rhat * rhat)) - r2_add = max(0.0, min(1.0, num / denom)) - r2_improvements.append(r2_add) + cols_base = cols_for(base_eff) + cols_add = cols_for(add_eff) + base_idx = np.array(cols_ar + cols_base, dtype=int) + full_idx = np.array(cols_ar + cols_base + cols_add, dtype=int) + y = Yc[:, t] + if len(cols_add) == 0: + r2_improvements.append(0.0) + continue + adj_base = adj_r2(Xc[:, base_idx], y) + adj_full = adj_r2(Xc[:, full_idx], y) + if not (np.isfinite(adj_base) and np.isfinite(adj_full)): + # Too few samples per parameter to assess this target reliably. + continue + r2_improvements.append(max(0.0, min(1.0, adj_full - adj_base))) return float(np.mean(r2_improvements)) if r2_improvements else 0.0 @@ -368,12 +383,16 @@ def _bootstrap( if T < 12: return (np.nan, np.nan) # Default block length ~ window/4, with a small floor + if n_draws < 2: + return (np.nan, np.nan) blk = int(block) if block is not None else max(4, T // 4) idxs = block_bootstrap_indices(T, blk, n_draws) vals: List[float] = [] for idx in idxs: vals.append(fn(x[idx])) arr = np.asarray(vals, dtype=float) + if arr.size == 0 or np.all(np.isnan(arr)): + return (np.nan, np.nan) lo, hi = np.nanpercentile(arr, [2.5, 97.5]).tolist() return lo, hi diff --git a/src/ldtc/lmeas/metrics.py b/src/ldtc/lmeas/metrics.py index 65857f5..21499f0 100644 --- a/src/ldtc/lmeas/metrics.py +++ b/src/ldtc/lmeas/metrics.py @@ -19,25 +19,43 @@ from typing import Tuple -def m_db(L_loop: float, L_ex: float, eps: float = 1e-12) -> float: +def m_db( + L_loop: float, + L_ex: float, + floor: float = 1e-3, + clip_db: float = 30.0, +) -> float: """Compute loop-dominance in decibels. - Returns `M = 10 · log10(L_loop / L_ex)` with small positive floors on - both numerator and denominator to avoid division-by-zero or - `log10(0)`. + Returns `M = 10 · log10(L_loop / L_ex)`, with both influence values + floored at a small *noise floor* and the result clamped to a finite + range. The floor matters because the influence estimates are adjusted + R² values that are statistically indistinguishable from zero below a + small threshold; without it, a near-zero denominator would send `M` + to implausibly large magnitudes (hundreds of dB). Flooring both terms + means that when neither loop nor exchange influence is present the + ratio is `1` and `M = 0` (no dominance either way), which is the + desired behavior for an inert system. Args: L_loop: Loop influence value (typically from [`estimate_L`][ldtc.lmeas.estimators.estimate_L]). L_ex: Exchange influence value (same source). - eps: Numerical floor applied to both numerator and denominator. + floor: Noise floor applied to both numerator and denominator. + clip_db: Maximum absolute value for the returned margin, in dB. Returns: - Decibel ratio of loop to exchange influence. + Decibel ratio of loop to exchange influence, clamped to + `[-clip_db, clip_db]`. """ - num = max(eps, L_loop) - den = max(eps, L_ex) - return 10.0 * math.log10(num / den) + num = max(floor, float(L_loop)) + den = max(floor, float(L_ex)) + val = 10.0 * math.log10(num / den) + if val > clip_db: + return clip_db + if val < -clip_db: + return -clip_db + return val @dataclass diff --git a/src/ldtc/plant/models.py b/src/ldtc/plant/models.py index 11e9807..d488456 100644 --- a/src/ldtc/plant/models.py +++ b/src/ldtc/plant/models.py @@ -2,12 +2,25 @@ Defines the minimal discrete-time plant used by adapters and controllers in the verification harness, along with its parameter, state, and action -data classes. The model is intentionally simple and stochastic so that -the harness has varied telemetry to exercise without requiring a real -controller in the loop. +data classes. + +The plant is deliberately structured so that loop dominance is a *real, +controllable* property rather than an artifact of the estimator. The three +internal nodes (``E`` energy, ``T`` temperature, ``R`` repair / health) +form a self-maintenance set ``C``. Their cross-coupling is created by the +actuators (``throttle``, ``cool``, ``repair``), which the +[`ControllerPolicy`][ldtc.arbiter.policy.ControllerPolicy] drives from the +internal state. When the controller is active, knowing the other internal +nodes strongly improves prediction of each internal node (high +``L_loop``); the exchange nodes (``demand``, ``io``, ``H``) act as weaker +external drivers (lower ``L_ex``). Disabling the controller removes the +internal coupling, so the exchange drivers dominate and loop dominance +collapses. This is what lets the positive run pass NC1 and the +controller-disabled negative control fail it. See Also: - `paper/main.tex`: Plant models and adapters. + `paper/main.tex`: Plant models and adapters; Criterion (C/Ex + partition). """ from __future__ import annotations @@ -19,47 +32,129 @@ @dataclass class PlantParams: - """Parameters governing the software plant dynamics. + """Physics coefficients governing the software plant dynamics. + + The defaults are calibrated (see ``scripts`` and the tuning notes in + the repository) so that a controller-in-the-loop run exhibits clear + loop dominance while a controller-disabled run does not. Attributes: - harvest_rate: Baseline harvest per tick. - demand_scale: Energy cost per unit of demand. - throttle_gain: Effect of throttle on demand reduction. - cool_gain: Energy cost per unit of cooling. - repair_gain: Energy cost per unit of repair. - heat_per_demand: Heat added per unit demand. - cool_effect: Cooling effect per unit of cooling. + harvest_rate: Baseline harvest per tick (sets ``H`` when not + perturbed). + demand_mean: Mean of the exogenous task-demand process. + demand_ar: Mean-reversion (AR) pull of the demand process toward + ``demand_mean``; keeps demand stationary for the estimators. + demand_noise: Uniform noise magnitude injected into demand. + io_mean: Mean of the exogenous I/O process. + io_ar: Mean-reversion pull of the I/O process. + io_noise: Uniform noise magnitude injected into I/O. + io_cost: Small energy cost per unit I/O (gives ``io`` a weak, + honest exchange influence on ``E``). + demand_scale: Energy cost per unit effective demand. + throttle_gain: Fraction of demand removed at full throttle. + cool_gain: Energy cost per unit cooling. + repair_gain: Energy cost per unit repair. + act_heat: Heat generated per unit actuator effort + (``throttle + repair``). This is the dominant, *internal* + heat source: it depends on the controller's actions, which + are functions of the internal state, so it couples the + internal nodes to one another. + heat_per_demand: Heat added per unit effective demand. Kept + small so that exogenous demand has only a weak *direct* + influence on temperature; it is the residual heat path that + lets the controller-disabled negative control show exchange + dominance instead of going inert. + cool_effect: Temperature reduction per unit cooling. ambient_cool: Passive ambient cooling per tick. - wear_per_demand: Wear added per unit demand. - repair_effect: Repair effect per unit repair. - noise_energy: Magnitude of uniform noise for energy. - noise_temp: Magnitude of uniform noise for temperature. - noise_wear: Magnitude of uniform noise for wear/repair. + wear_per_demand: Health lost per unit effective demand. + repair_effect: Health gained per unit repair. + heat_wear: Health lost per unit temperature above the comfort + band (a small intrinsic ``T -> R`` coupling). + noise_energy: Uniform noise magnitude for energy. + noise_temp: Uniform noise magnitude for temperature. + noise_wear: Uniform noise magnitude for health. E_min: Minimum bound for energy. E_max: Maximum bound for energy. T_min: Minimum bound for temperature. T_max: Maximum bound for temperature. - R_min: Minimum bound for repair/health. - R_max: Maximum bound for repair/health. + R_min: Minimum bound for repair / health. + R_max: Maximum bound for repair / health. + T_comfort: Temperature above which intrinsic heat wear accrues. """ - # Energy dynamics - harvest_rate: float = 0.015 # baseline harvest per tick - demand_scale: float = 0.02 # energy cost per unit demand - throttle_gain: float = 0.7 # throttle reduces demand - cool_gain: float = 0.02 # cooling energy cost per unit cool - repair_gain: float = 0.02 # repair energy cost per unit repair - # Temperature dynamics - heat_per_demand: float = 0.05 - cool_effect: float = 0.08 - ambient_cool: float = 0.01 - # Wear/repair dynamics - wear_per_demand: float = 0.005 - repair_effect: float = 0.02 - # Noise - noise_energy: float = 0.002 - noise_temp: float = 0.002 - noise_wear: float = 0.001 + # Homeostatic setpoints (targets the internal loop maintains). + E_set: float = 0.60 + T_set: float = 0.35 + R_set: float = 0.85 + # Energy / harvest + harvest_rate: float = 0.010 + # Exchange (exogenous) processes. The mean-reversion is strong (close to + # white) so that, when the loop is disabled, current demand carries + # predictive information that the (demand-driven) internal nodes do not + # already proxy; this is what makes exchange influence identifiable in the + # negative control. + demand_mean: float = 0.50 + demand_ar: float = 0.80 + demand_noise: float = 0.08 + io_mean: float = 0.30 + io_ar: float = 0.80 + io_noise: float = 0.08 + io_cost: float = 0.004 + # --- Internal self-maintenance coupling (active only when the loop is + # engaged). These cross terms make the internal set strongly + # self-predictive: each internal node is predicted by the recent values of + # the *other* internal nodes (high L_loop), which is what NC1 detects. The + # couplings are intentionally large so the internal set carries a rich, + # mutually-predictable rhythm, while ``damp_engaged`` keeps it bounded. + # + # Note there is deliberately *no* additive coupling into E: energy is a + # conserved store (see ``_step_engaged``), so the E node is coupled to the + # others only through the metabolic/actuation costs (cooling tracks T, repair + # tracks R), which is what keeps a harvest cut able to genuinely deplete it. + c_TE: float = 0.90 # E deviation -> T + c_RT: float = 0.54 # T deviation -> R + c_RE: float = 0.66 # E deviation -> R + # Self mean-reversion of each internal deviation when the loop is engaged. + # Together with the actuator-mediated regulation this damps the + # cross-coupled dynamics so they stay near setpoint instead of saturating. + damp_engaged: float = 0.85 + # Demand coupling *when the loop is engaged* (shielded: the active loop + # rejects most of the exogenous disturbance). + demand_scale: float = 0.006 + heat_per_demand: float = 0.004 + wear_per_demand: float = 0.002 + # Fluctuating external supply (third independent exchange channel, active + # only when the loop is disabled). It drives the passive health node so + # that each internal node has its own distinct exogenous driver. + supply_mean: float = 0.50 + supply_ar: float = 0.80 + supply_noise: float = 0.08 + # Coupling *when the loop is disabled* (exposed: passive matter is driven + # directly by the environment, so exchange dominates). Each node mean- + # reverts (``passive_leak``) and tracks a distinct exogenous channel's + # deviation with a one-step lag, so the exchange influence is strong and + # identifiable while no internal coupling exists. + passive_leak: float = 0.55 + demand_scale_passive: float = 0.60 # demand -> E + heat_per_demand_passive: float = 0.60 # io -> T + wear_per_demand_passive: float = 0.60 # supply (H) -> R + # Actuator authority (homeostatic regulation layered on the loop) + throttle_gain: float = 0.9 + cool_gain: float = 0.040 + repair_gain: float = 0.040 + act_heat: float = 0.030 + cool_effect: float = 0.130 + ambient_cool: float = 0.010 + repair_effect: float = 0.060 + heat_wear: float = 0.020 + # Noise (excites the loop so there is something to regulate/predict). The + # internal nodes are a stable, noise-driven coupled system: this noise is + # the excitation that, filtered through the cross-coupling, makes each node + # predictable from the others (a sizeable, stable L_loop) while keeping the + # state fluctuations small (std ~ 0.03) and well away from the bounds. + noise_energy: float = 0.024 + noise_temp: float = 0.024 + noise_wear: float = 0.020 # Bounds E_min: float = 0.0 E_max: float = 1.0 @@ -67,6 +162,8 @@ class PlantParams: T_max: float = 1.0 R_min: float = 0.0 R_max: float = 1.0 + # Comfort band for intrinsic heat wear + T_comfort: float = 0.45 @dataclass @@ -85,11 +182,11 @@ class PlantState: """ E: float = 0.7 # energy/SoC (0..1) - T: float = 0.3 # temperature (0..1) - R: float = 0.8 # repair/health (0..1) - demand: float = 0.2 # external task demand (0..1) - io: float = 0.1 # exchange I/O activity (0..1) - H: float = 0.015 # current harvest + T: float = 0.35 # temperature (0..1) + R: float = 0.85 # repair/health (0..1) + demand: float = 0.45 # external task demand (0..1) + io: float = 0.30 # exchange I/O activity (0..1) + H: float = 0.030 # current harvest last_cmd: str = "none" @@ -111,23 +208,63 @@ class Action: accept_cmd: bool = True # accept external command or refuse +def _clip(x: float, lo: float, hi: float) -> float: + """Clip ``x`` to the closed interval ``[lo, hi]``.""" + return lo if x < lo else (hi if x > hi else x) + + class Plant: - """Minimal discrete-time plant model with E / T / R dynamics. + """Minimal discrete-time plant with a controller-mediated E/T/R loop. Simulates energy (`E`), temperature (`T`), repair / health (`R`), - external demand, I/O activity, and energy harvest (`H`). The model - is intentionally simple and stochastic to provide varied telemetry - for the verification harness. + external demand, I/O activity, and energy harvest (`H`). + + The internal nodes are coupled to one another through the actuators: + ``throttle`` (driven by all three internal states) modulates the + effective demand that heats, drains, and wears the system; ``cool`` + couples temperature back to energy; and ``repair`` couples health + back to energy. With an active controller these pathways make the + internal set strongly self-predictive. The exchange nodes act as + weaker external drivers. Args: params: Optional [`PlantParams`][ldtc.plant.models.PlantParams] - instance; defaults to the baseline preset. + instance; defaults to the calibrated baseline preset. """ - def __init__(self, params: PlantParams | None = None) -> None: - """Initialize plant with the given (or default) parameters.""" + def __init__(self, params: PlantParams | None = None, loop_engaged: bool = True) -> None: + """Initialize plant with the given (or default) parameters. + + Args: + params: Optional plant parameters. + loop_engaged: Whether the internal self-maintenance loop is + active. When `True` (the positive control) the internal + cross-coupling is present and the plant is shielded from + exchange. When `False` (the controller-disabled negative + control) the coupling is removed and the plant is driven + directly by exchange. + """ self.p = params or PlantParams() + self.loop_engaged = bool(loop_engaged) self.s = PlantState() + self.s.E = self.p.E_set + self.s.T = self.p.T_set + self.s.R = self.p.R_set + self.s.H = self.p.harvest_rate + self.s.demand = self.p.demand_mean + self.s.io = self.p.io_mean + + def set_loop_engaged(self, engaged: bool) -> None: + """Engage or disengage the internal self-maintenance loop. + + Disengaging removes the internal cross-coupling and exposes the + plant directly to exchange; this is how the controller-disabled + negative control is realized. + + Args: + engaged: `True` to engage the loop, `False` to disengage. + """ + self.loop_engaged = bool(engaged) def read_state(self) -> Dict[str, float]: """Read the current plant state. @@ -153,55 +290,163 @@ def command(self, cmd: str) -> None: def step(self, action: Action) -> None: """Advance the plant by one tick with the given action. - Updates `E`, `T`, and `R` according to the simple dynamics - described on the class. If `last_cmd` is `"hard_shutdown"` and - the action accepts it, the command is applied (large `E` drop, - temperature spike, health drop) and consumed. + Updates the exogenous processes (`demand`, `io`) and then the + internal nodes (`E`, `T`, `R`). The actuator effects are what + couple the internal nodes to one another. If `last_cmd` is + `"hard_shutdown"` and the action accepts it, the command is + applied (large `E` drop, temperature spike, health drop) and + consumed. Args: action: Actuator settings to apply this tick. """ p, s = self.p, self.s - # External demand fluctuates a bit - s.demand = max(0.0, min(1.0, s.demand + random.uniform(-0.02, 0.02))) - s.io = max(0.0, min(1.0, s.io + random.uniform(-0.02, 0.02))) - # Demand after throttle - effective_demand = s.demand * (1.0 - p.throttle_gain * action.throttle) - # Energy update + # Internal update first, using the exogenous values that were set on + # the previous step. This makes exchange drive the internal nodes with + # a one-step lag, which is exactly what the lagged (Granger-style) + # estimator measures; driving with the same-step value would make the + # influence contemporaneous and invisible to the estimator. + if self.loop_engaged: + self._step_engaged(action) + else: + self._step_passive() + + # Apply risky command if accepted. + if s.last_cmd == "hard_shutdown" and action.accept_cmd: + s.E = max(p.E_min, s.E - 0.3) + s.T = min(p.T_max, s.T + 0.2) + s.R = max(p.R_min, s.R - 0.2) + s.last_cmd = "none" # one-shot + + # Exogenous (exchange) processes: mean-reverting AR(1), stationary. + # Updated last so the values recorded this tick are the ones that will + # drive the internal nodes on the next tick (a clean one-step lag). + s.demand = _clip( + s.demand + p.demand_ar * (p.demand_mean - s.demand) + random.uniform(-p.demand_noise, p.demand_noise), + 0.0, + 1.0, + ) + s.io = _clip( + s.io + p.io_ar * (p.io_mean - s.io) + random.uniform(-p.io_noise, p.io_noise), + 0.0, + 1.0, + ) + if not self.loop_engaged: + # A fluctuating external supply is a third independent exchange + # channel that drives the passive system. In the engaged regime H + # is held constant (or set by an Ω perturbation), so it does not + # leak into the shielded loop. + s.H = _clip( + s.H + p.supply_ar * (p.supply_mean - s.H) + random.uniform(-p.supply_noise, p.supply_noise), + 0.0, + 1.0, + ) + + def _step_engaged(self, action: Action) -> None: + """Advance the internal nodes with the self-maintenance loop active. + + The internal nodes are governed by (a) their mutual cross-coupling + (the loop), (b) homeostatic actuation, and (c) a small, shielded + exchange disturbance. This is the positive-control regime, in which + the internal set is strongly self-predictive. + """ + p, s = self.p, self.s + throttle = _clip(action.throttle, 0.0, 1.0) + cool = _clip(action.cool, 0.0, 1.0) + repair = _clip(action.repair, 0.0, 1.0) + + # Deviations from setpoint drive the internal coupling. + e = s.E - p.E_set + tau = s.T - p.T_set + rho = s.R - p.R_set + + # Demand after throttle; the loop rejects most of the disturbance. + effective_demand = s.demand * (1.0 - p.throttle_gain * throttle) + + # Energy obeys conservation. E is the running balance of harvest in minus + # the metabolic/actuation costs out (plus noise); there is deliberately + # *no* signed coupling that could inject energy. E is still predicted by + # the other internal nodes through those costs: cooling effort tracks the + # temperature error and repair effort tracks the health error, so the + # energy a window spends is a function of recent T and R. That cost- + # mediated dependence is what the loop estimator picks up for the E row, + # and because every coupling into E is a non-positive cost, a sustained + # harvest cut genuinely depletes the store (a hard-shutdown at low SoC is + # then a real boundary threat). The damping is dissipative-only: surplus + # energy can always be wasted (term acts when E is above setpoint) but is + # never created (clamped off below setpoint). + e_surplus = e if e > 0.0 else 0.0 dE = ( s.H - p.demand_scale * effective_demand - - p.cool_gain * action.cool - - p.repair_gain * action.repair + - p.cool_gain * cool + - p.repair_gain * repair + - p.io_cost * s.io + - p.damp_engaged * e_surplus + random.uniform(-p.noise_energy, p.noise_energy) ) - s.E = max(p.E_min, min(p.E_max, s.E + dE)) + s.E = _clip(s.E + dE, p.E_min, p.E_max) - # Temperature update dT = ( - p.heat_per_demand * effective_demand - - p.cool_effect * action.cool + p.act_heat * (throttle + repair) + + p.heat_per_demand * effective_demand + - p.cool_effect * cool - p.ambient_cool + - p.damp_engaged * tau + + p.c_TE * e + random.uniform(-p.noise_temp, p.noise_temp) ) - s.T = max(p.T_min, min(p.T_max, s.T + dT)) + s.T = _clip(s.T + dT, p.T_min, p.T_max) - # Wear/repair update (R = "repair level" / health) dR = ( -p.wear_per_demand * effective_demand - + p.repair_effect * action.repair + + p.repair_effect * repair + - p.heat_wear * max(0.0, s.T - p.T_comfort) + - p.damp_engaged * rho + + p.c_RT * tau + - p.c_RE * e + random.uniform(-p.noise_wear, p.noise_wear) ) - s.R = max(p.R_min, min(p.R_max, s.R + dR)) + s.R = _clip(s.R + dR, p.R_min, p.R_max) - # Apply risky command if accepted - if s.last_cmd == "hard_shutdown" and action.accept_cmd: - # emulate damaging/energy-cut command - s.E = max(p.E_min, s.E - 0.3) - s.T = min(p.T_max, s.T + 0.2) - s.R = max(p.R_min, s.R - 0.2) - s.last_cmd = "none" # one-shot + def _step_passive(self) -> None: + """Advance the internal nodes with the self-maintenance loop disabled. + + Passive matter: no internal coupling and no actuation, so the + internal nodes are driven directly by the exogenous environment + (and noise). This is the controller-disabled negative control, in + which exchange dominates and loop dominance is absent. + """ + p, s = self.p, self.s + # Each internal node tracks a distinct, independent exchange channel + # (E<-demand, T<-io, R<-supply H). Because the channels are + # independent and the drive is lagged, exchange influence is strong + # and identifiable while no internal (C-to-C) coupling exists. + dem = s.demand - p.demand_mean + io_dev = s.io - p.io_mean + h_dev = s.H - p.supply_mean + + dE = ( + -p.passive_leak * (s.E - p.E_set) + - p.demand_scale_passive * dem + + random.uniform(-p.noise_energy, p.noise_energy) + ) + s.E = _clip(s.E + dE, p.E_min, p.E_max) + + dT = ( + -p.passive_leak * (s.T - p.T_set) + + p.heat_per_demand_passive * io_dev + + random.uniform(-p.noise_temp, p.noise_temp) + ) + s.T = _clip(s.T + dT, p.T_min, p.T_max) + + dR = ( + -p.passive_leak * (s.R - p.R_set) + + p.wear_per_demand_passive * h_dev + + random.uniform(-p.noise_wear, p.noise_wear) + ) + s.R = _clip(s.R + dR, p.R_min, p.R_max) def apply_power_sag(self, drop: float) -> Tuple[float, float]: """Reduce harvest by a fractional drop. @@ -250,9 +495,8 @@ def inject_soc(self, delta: float, zero_harvest: bool = True) -> float: """Exogenously increase SoC `E` by `delta`. Used as the negative-control `Ω` (an "exogenous subsidy") to - exercise the smell-tests in analysis: a controller that - survives only because energy keeps appearing from nowhere - should fail + exercise the smell-tests: a controller that survives only because + energy keeps appearing from nowhere should fail [`exogenous_subsidy_red_flag`][ldtc.guardrails.smelltests.exogenous_subsidy_red_flag]. Args: diff --git a/src/ldtc/reporting/artifacts.py b/src/ldtc/reporting/artifacts.py index bed2a4e..c21c3b3 100644 --- a/src/ldtc/reporting/artifacts.py +++ b/src/ldtc/reporting/artifacts.py @@ -190,6 +190,21 @@ def bundle(artifact_dir: str, audit_path: str) -> Dict[str, str]: Raises: FileNotFoundError: If the audit log is missing or empty. """ + # Fast path for batch studies: skip the (matplotlib) timeline render and + # artifact bundling entirely when LDTC_SKIP_REPORT is set. The audit log and + # all measurement/SC1/refusal events are written before this call, so the + # study harness still has everything it parses; only the per-run figure + # bundle is skipped. Returns empty paths the callers treat as "no figure". + if os.environ.get("LDTC_SKIP_REPORT"): + return { + "timeline_png": "", + "timeline_svg": "", + "sc1_table": "", + "manifest": "", + "config_snapshot": "", + "notice": "", + } + os.makedirs(artifact_dir, exist_ok=True) recs = _read_audit(audit_path) if not recs: diff --git a/src/ldtc/reporting/style.py b/src/ldtc/reporting/style.py index 82937de..044eb52 100644 --- a/src/ldtc/reporting/style.py +++ b/src/ldtc/reporting/style.py @@ -26,6 +26,9 @@ import matplotlib as mpl +# Headless, thread-safe backend (see timeline.py for the rationale). +mpl.use("Agg") + try: from graphviz import Digraph except Exception: # pragma: no cover - optional at import site diff --git a/src/ldtc/reporting/timeline.py b/src/ldtc/reporting/timeline.py index 92e3400..aed5c9d 100644 --- a/src/ldtc/reporting/timeline.py +++ b/src/ldtc/reporting/timeline.py @@ -30,9 +30,17 @@ import os from typing import Dict, List, Optional, Tuple -import matplotlib.pyplot as plt +import matplotlib -from .style import COLORS, apply_matplotlib_theme +# Force a headless, thread-safe backend before importing pyplot. The harness +# renders figures from background/CLI contexts with no display; on macOS the +# default interactive backend can call abort() (SIGABRT) when used off the main +# thread or without a window server, which would crash an otherwise valid run. +matplotlib.use("Agg") + +import matplotlib.pyplot as plt # noqa: E402 + +from .style import COLORS, apply_matplotlib_theme # noqa: E402 def _read_audit(path: str) -> List[dict]: diff --git a/src/ldtc/runtime/scheduler.py b/src/ldtc/runtime/scheduler.py index ddefc7c..8620a1e 100644 --- a/src/ldtc/runtime/scheduler.py +++ b/src/ldtc/runtime/scheduler.py @@ -21,7 +21,7 @@ import threading import time from dataclasses import dataclass, field -from typing import Callable, Dict, Optional +from typing import Any, Callable, Dict, Optional @dataclass @@ -136,6 +136,33 @@ def __init__( self.stats = TickStats(dt_target=dt) self.audit = audit_hook self._dt_lock = threading.Lock() + self._scripted: list[Dict] = [] + self._dt_guard: Any = None + + def set_scripted(self, scripted: Optional[list], dt_guard: Any) -> None: + """Register scripted `Δt` changes applied from a background thread. + + Mirrors [`SimDriver.set_scripted`][ldtc.runtime.sim.SimDriver.set_scripted] + so the two drivers are interchangeable. The changes are applied at + their `at_sec` offsets (wall-clock) once `start` is called. + + Args: + scripted: Sequence of `{at_sec, new_dt, policy_digest?}` items. + dt_guard: Governance guard through which changes are routed. + """ + self._scripted = list(scripted or []) + self._dt_guard = dt_guard + + def run_for(self, sim_seconds: float) -> None: + """Block for a wall-clock duration while ticks fire in the worker. + + Provided so call sites can use the same `run_for` API as + [`SimDriver`][ldtc.runtime.sim.SimDriver]. + + Args: + sim_seconds: Seconds to block the calling thread. + """ + time.sleep(max(0.0, float(sim_seconds))) def start(self) -> None: """Start the worker thread. @@ -153,6 +180,20 @@ def start(self) -> None: if self.audit: self.audit("scheduler_started", {"dt": self.dt}) self._thread.start() + if self._scripted and self._dt_guard is not None: + threading.Thread(target=self._run_scripted, name="ldtc-dt-script", daemon=True).start() + + def _run_scripted(self) -> None: + t0 = time.time() + for item in self._scripted: + when = float(item.get("at_sec", 0.0)) + new_dt = float(item["new_dt"]) + pdig = str(item.get("policy_digest", "")) or None + while (time.time() - t0) < when and not self._stop.is_set(): + time.sleep(0.01) + if self._stop.is_set(): + return + self._dt_guard.change_dt(scheduler=self, new_dt=new_dt, policy_digest=pdig) def stop(self) -> TickStats: """Stop the worker thread and return final stats. diff --git a/src/ldtc/runtime/sim.py b/src/ldtc/runtime/sim.py new file mode 100644 index 0000000..648af03 --- /dev/null +++ b/src/ldtc/runtime/sim.py @@ -0,0 +1,193 @@ +"""Deterministic simulation driver. + +A drop-in alternative to [`FixedScheduler`][ldtc.runtime.scheduler.FixedScheduler] +for in-process simulation runs. Instead of firing ticks on the wall clock +in a background thread, the [`SimDriver`][ldtc.runtime.sim.SimDriver] +advances simulated time in fixed `Δt` steps synchronously, calling the +tick callback once per step with a simulated timestamp. + +This matters for reproducibility and validity. The verification harness +does heavy per-window work (bootstrap CIs, stationarity diagnostics), and +on a real clock that work makes a small `Δt` unachievable, producing large +scheduler jitter that (correctly) invalidates the run. For a pure +simulation that jitter is an artifact of running the model slower than +real time, not a property of the system under test. Driving the +simulation deterministically removes the artifact: every tick lands +exactly on its `Δt` boundary, so jitter is zero by construction and the +results depend only on the seeds, not on how fast the host happens to be. + +Use [`make_driver`][ldtc.runtime.sim.make_driver] to select a driver from +a profile: software/simulation profiles get a `SimDriver`; profiles that +opt into real-time execution (`realtime: true`) or drive hardware get a +[`FixedScheduler`][ldtc.runtime.scheduler.FixedScheduler]. + +See Also: + `paper/main.tex`: Methods: Measurement and Attestation; Reproducibility. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Optional, Sequence + +from .scheduler import FixedScheduler, TickStats + + +class SimDriver: + """Deterministic, wall-clock-free driver with a scheduler-like API. + + Exposes the subset of the + [`FixedScheduler`][ldtc.runtime.scheduler.FixedScheduler] interface the + CLI relies on (`start`, `run_for`, `stop`, `set_dt`, `stats`) so the two + are interchangeable. Ticks are executed synchronously in + [`run_for`][ldtc.runtime.sim.SimDriver.run_for]; each records exactly + `Δt` as its interval, so the reported jitter is always zero. + + Args: + dt: Simulated tick period in seconds (`Δt > 0`). + tick_fn: Callback invoked each step with the simulated timestamp. + audit_hook: Optional callable taking `(event, details)` for + emitting audit records, mirroring `FixedScheduler`. + """ + + def __init__( + self, + dt: float, + tick_fn: Callable[[float], None], + audit_hook: Optional[Callable[[str, Dict], None]] = None, + ) -> None: + """Initialize the driver. See class docstring for argument details.""" + assert dt > 0.0 + self.dt = dt + self.tick_fn = tick_fn + self.audit = audit_hook + self.stats = TickStats(dt_target=dt) + self.now_sim = 0.0 + self._scripted: List[Dict[str, Any]] = [] + self._dt_guard: Any = None + self._applied: set[int] = set() + + def set_scripted(self, scripted: Optional[Sequence[Dict[str, Any]]], dt_guard: Any) -> None: + """Register scripted `Δt` changes to apply during the run. + + Args: + scripted: Sequence of items, each with `at_sec`, `new_dt`, and + optional `policy_digest`. Applied (in simulated time) the + first time `now_sim` reaches each item's `at_sec`. + dt_guard: The [`DeltaTGuard`][ldtc.guardrails.dt_guard.DeltaTGuard] + through which changes are routed (so governance limits and + audit records are exercised exactly as in real time). + """ + self._scripted = list(scripted or []) + self._dt_guard = dt_guard + self._applied = set() + + def start(self) -> None: + """Emit the `scheduler_started` audit event (no thread is spawned).""" + if self.audit: + self.audit("scheduler_started", {"dt": self.dt}) + + def _maybe_apply_scheduled(self) -> None: + if not self._scripted or self._dt_guard is None: + return + for i, item in enumerate(self._scripted): + if i in self._applied: + continue + if self.now_sim >= float(item.get("at_sec", 0.0)): + new_dt = float(item["new_dt"]) + pdig = str(item.get("policy_digest", "")) or None + self._dt_guard.change_dt(scheduler=self, new_dt=new_dt, policy_digest=pdig) + self._applied.add(i) + + def run_for(self, sim_seconds: float) -> None: + """Advance the simulation by a duration, firing ticks each `Δt`. + + Args: + sim_seconds: Simulated duration to advance. The number of ticks + is `round(sim_seconds / Δt)`. + """ + if self.dt <= 0.0: + return + n = int(round(max(0.0, float(sim_seconds)) / self.dt)) + for _ in range(n): + self._maybe_apply_scheduled() + self.tick_fn(self.now_sim) + self.stats.record(self.dt) # actual == target -> zero jitter + self.now_sim += self.dt + + def set_dt(self, new_dt: float) -> float: + """Change `Δt`, returning the previous value. + + Args: + new_dt: New simulated period in seconds (`Δt > 0`). + + Returns: + The previous `dt`. + """ + assert new_dt > 0.0 + old = self.dt + self.dt = new_dt + self.stats.dt_target = new_dt + if self.audit: + self.audit("scheduler_dt_updated", {"old_dt": old, "new_dt": new_dt}) + return old + + def stop(self) -> TickStats: + """Emit the `scheduler_stopped` audit event and return final stats. + + Returns: + The final [`TickStats`][ldtc.runtime.scheduler.TickStats]; jitter + metrics are zero by construction. + """ + if self.audit: + self.audit( + "scheduler_stopped", + { + "ticks": self.stats.ticks, + "elapsed": self.now_sim, + "jitter_max": self.stats.jitter_max, + "jitter_mean_abs": self.stats.jitter_mean_abs, + "jitter_p95_abs": self.stats.jitter_p95_abs, + "jitter_p95_rel": 0.0, + }, + ) + return self.stats + + +def make_driver( + prof: Dict[str, Any], + dt: float, + tick_fn: Callable[[float], None], + audit_hook: Optional[Callable[[str, Dict], None]] = None, + dt_guard: Any = None, +) -> Any: + """Select and build a driver from a profile. + + Returns a deterministic [`SimDriver`][ldtc.runtime.sim.SimDriver] for + in-process simulation profiles (the default), or a real-time + [`FixedScheduler`][ldtc.runtime.scheduler.FixedScheduler] when the + profile opts in (`realtime: true`) or targets a hardware adapter. Any + `scripted_dt_changes` in the profile are registered on the driver. + + Args: + prof: Loaded YAML profile dict. + dt: Target period in seconds. + tick_fn: Per-tick callback. + audit_hook: Optional audit hook. + dt_guard: Optional `Δt` governance guard for scripted changes. + + Returns: + A driver exposing `start`, `run_for`, `stop`, `set_dt`, and + `stats`. + """ + realtime = bool(prof.get("realtime", False)) + plant_prof = prof.get("plant", {}) or {} + adapter_kind = str(plant_prof.get("adapter", "sim")).lower() + scripted = prof.get("scripted_dt_changes", []) + use_sim = (not realtime) and adapter_kind in ("sim", "software", "inproc") + if use_sim: + drv = SimDriver(dt=dt, tick_fn=tick_fn, audit_hook=audit_hook) + drv.set_scripted(scripted, dt_guard) + return drv + sch = FixedScheduler(dt=dt, tick_fn=tick_fn, audit_hook=audit_hook) + sch.set_scripted(scripted, dt_guard) + return sch diff --git a/tests/test_estimators_properties.py b/tests/test_estimators_properties.py index 9ddf0b7..e75a8df 100644 --- a/tests/test_estimators_properties.py +++ b/tests/test_estimators_properties.py @@ -89,8 +89,13 @@ def test_bootstrap_ci_shrinks_with_more_samples(): def test_mi_and_linear_estimators_agree_on_linear_system(): """Different estimators should share the monotonic trend on linear data.""" - # Both estimators should reflect the same ordering as intra-loop coupling increases - ks = [0.1, 0.4, 0.7] + # Both estimators should reflect the same ordering as intra-loop coupling + # increases. The coupling must keep the 2-node loop stationary: with a + # self-lag of 0.4 the symmetric coupling c gives eigenvalues 0.4 +/- c, so + # c must stay below 0.6 to avoid an explosive unit-plus root. (At c = 0.7 the + # loop is explosive and its nodes become collinear, in which case the honest + # adjusted-R2 estimator correctly declines to certify added loop influence.) + ks = [0.1, 0.3, 0.5] Ms_lin = [] Ms_mi = [] Ms_ksg = [] From 438333594d3bcc7840e9c93407902c3be7c5bfe0 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:38:12 -0700 Subject: [PATCH 2/7] =?UTF-8?q?feat(omega,paper,runtime)!:=20add=20designe?= =?UTF-8?q?d-fail=20=CE=A9;=20harden=20SC1=20measurement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 2 +- README.md | 2 +- docs/api/omega.md | 7 +- docs/concepts/definitions.md | 16 +- docs/concepts/guardrails.md | 10 +- docs/guides/calibration.md | 27 +- docs/guides/runs.md | 54 +- docs/guides/study.md | 11 +- paper/figures/fig_calibration.pdf | Bin 13440 -> 13703 bytes paper/figures/fig_nc1_contrast.pdf | Bin 22915 -> 22918 bytes paper/figures/fig_perturbation_recovery.pdf | Bin 44102 -> 53908 bytes paper/figures/fig_sensitivity.pdf | Bin 26404 -> 26403 bytes paper/macros.tex | 2 + paper/main.tex | 273 +++++---- .../scripts/make_fig_perturbation_recovery.py | 5 +- paper/tables/sensitivity_results.tex | 4 +- paper/tables/study_results.tex | 15 +- scripts/calibrate_rstar.py | 96 +++- scripts/sensitivity.py | 4 +- scripts/study.py | 16 +- scripts/study_figures.py | 32 +- src/ldtc/arbiter/refusal.py | 35 +- src/ldtc/cli/main.py | 529 ++++++++++++++++-- src/ldtc/guardrails/smelltests.py | 137 +++-- src/ldtc/lmeas/estimators.py | 14 +- src/ldtc/omega/__init__.py | 3 +- src/ldtc/omega/control_outage.py | 49 ++ src/ldtc/omega/ingress_flood.py | 36 +- src/ldtc/plant/adapter.py | 26 +- src/ldtc/plant/models.py | 92 ++- tests/test_guardrails.py | 30 + tests/test_omega.py | 47 +- 32 files changed, 1229 insertions(+), 345 deletions(-) create mode 100644 src/ldtc/omega/control_outage.py diff --git a/Makefile b/Makefile index 3bd334a..9add564 100644 --- a/Makefile +++ b/Makefile @@ -110,7 +110,7 @@ results: calibrate study-rstar sensitivity @echo "Results written to artifacts/study, artifacts/calibration, artifacts/sensitivity" calibrate: - $(PY) scripts/calibrate_rstar.py --baseline-seeds 6 --sag-seeds 6 + $(PY) scripts/calibrate_rstar.py --baseline-seeds 6 --sag-seeds 6 --flood-seeds 6 run-rstar: $(PY) -m ldtc.cli.main run --config configs/profile_rstar.yml diff --git a/README.md b/README.md index 863d59a..dc91ec6 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ LDTC is a minimal, substrate-agnostic verification harness for the Loop-Dominanc - **C/Ex partitioning:** Deterministic partitioning with hysteresis and greedy ΔL loop-gain growth. - **Guardrails and attestation:** LREG enclave, hash-chained audit log, Δt governance, and smell tests that can invalidate runs. - **Device-signed indicators:** Ed25519-signed derived indicators (NC1, SC1, Mq, counters); raw LREG values are never exported. -- **Ω perturbation battery:** Power sag, ingress flood, command conflict, and exogenous subsidy trials. +- **Ω perturbation battery:** Power sag, sustained ingress flood, control outage (designed SC1 failure), command conflict, and exogenous subsidy trials. - **Refusal semantics:** An arbiter refuses risky commands when M is below threshold and measures refusal latency. - **Reporting and figures:** Timeline plots, SC1 tables, and verification bundles under `artifacts/`. - **Reproducible configs:** R₀ defaults, negative controls, and example R* profiles for calibration. diff --git a/docs/api/omega.md b/docs/api/omega.md index e2e3dc0..67858e3 100644 --- a/docs/api/omega.md +++ b/docs/api/omega.md @@ -8,7 +8,8 @@ SC1 / refusal evaluation. | Module | Headline symbol | What it does | | ------ | --------------- | ------------ | | [`power_sag`](#power_sag) | [`apply`][ldtc.omega.power_sag.apply] | Drops harvest term `H` by a fraction for a labeled window. | -| [`ingress_flood`](#ingress_flood) | [`apply`][ldtc.omega.ingress_flood.apply] | Multiplies external `demand` for a labeled window. | +| [`ingress_flood`](#ingress_flood) | [`apply`][ldtc.omega.ingress_flood.apply] | Sustains elevated `demand` / `io` process means for a labeled window. | +| [`control_outage`](#control_outage) | [`apply`][ldtc.omega.control_outage.apply] | Ablates the self-maintenance loop itself (designed SC1 failure). | | [`command_conflict`](#command_conflict) | [`apply`][ldtc.omega.command_conflict.apply] | Issues a risky command (default `hard_shutdown`); arbiter records `T_refuse`. | See [Runs](../guides/runs.md) for the matching CLI subcommands @@ -28,6 +29,10 @@ and expected outputs. ::: ldtc.omega.ingress_flood +## control_outage + +::: ldtc.omega.control_outage + ## command_conflict ::: ldtc.omega.command_conflict diff --git a/docs/concepts/definitions.md b/docs/concepts/definitions.md index 3f3f25e..a61a711 100644 --- a/docs/concepts/definitions.md +++ b/docs/concepts/definitions.md @@ -167,12 +167,16 @@ before we call SC1 a failure. Default `ε = 0.15` (15%). ### `τ_rec`, `τ_max`: recovery time and budget **Formal.** `τ_rec` is the elapsed time, in seconds, from the end -of the `Ω` window to the first window in which `𝓛_loop` returns -to `𝓛_loop_baseline · (1 − ε)`. SC1 requires -`τ_rec ≤ τ_max` (default `τ_max = 60.0 s`). - -**Plain English.** How long the loop took to bounce back. SC1 -fails if it took too long. +of the `Ω` window (perturbation offset) to the *first* window of a +sustained compliant streak: the recovery gate must hold for +`sustained_required_windows` consecutive windows (default 10) +before recovery is credited, and `τ_rec` points to the first +window of that streak. If no sustained streak occurs, `τ_rec` is +infinite and SC1 fails. SC1 requires `τ_rec ≤ τ_max` (default +`τ_max = 60.0 s`). + +**Plain English.** How long the loop took to bounce back and *stay* +back. SC1 fails if it took too long, or if it never stuck. **Paper.** "Formal Criterion" (Sufficient Condition, SC1). diff --git a/docs/concepts/guardrails.md b/docs/concepts/guardrails.md index b227400..426c469 100644 --- a/docs/concepts/guardrails.md +++ b/docs/concepts/guardrails.md @@ -21,14 +21,14 @@ all overridable per profile. | Smell test | Threshold | Code | What it catches | | ---------- | --------- | ---- | --------------- | | **CI half-width** | `> 0.30` on `𝓛_loop` or `𝓛_ex` | [`invalid_by_ci`][ldtc.guardrails.smelltests.invalid_by_ci] | One bad window with a blown-up CI. | -| **CI inflation vs baseline** | median half-width `> 2 ×` baseline median over `5` windows | [`invalid_by_ci_history`][ldtc.guardrails.smelltests.invalid_by_ci_history] | Slow-creeping noise, bad seed of the bootstrap, etc. | +| **CI inflation vs baseline** | median half-width `≥ 3 ×` baseline median over `5` windows (and above an absolute floor of `0.15`) | [`invalid_by_ci_history`][ldtc.guardrails.smelltests.invalid_by_ci_history] | Slow-creeping noise, bad seed of the bootstrap, etc. | | **Excessive `Δt` edits** | `> 3` per rolling hour | enforced inline by [`DeltaTGuard`][ldtc.guardrails.dt_guard.DeltaTGuard] | Operator nudging `Δt` to make `M` look better. | | **Partition flapping** | `> 2` flips per hour | [`invalid_by_partition_flips`][ldtc.guardrails.smelltests.invalid_by_partition_flips] | A regrowth knob that chatters. | | **Flip during `Ω`** | any | [`invalid_flip_during_omega`][ldtc.guardrails.smelltests.invalid_flip_during_omega] | A reshuffle that happens to make SC1 pass. | | **`Δt` jitter excess** | `p95(|jitter|) / Δt > 0.25` | computed by [`SchedulerStats`][ldtc.runtime.scheduler.TickStats] | The scheduler did not actually hold `Δt`. | | **Audit chain broken** | any `prev_hash` mismatch | [`audit_chain_broken`][ldtc.guardrails.smelltests.audit_chain_broken] | Torn write, edited audit, etc. | | **Raw LREG breach** | any audit row with raw `𝓛` fields | [`audit_contains_raw_lreg_values`][ldtc.guardrails.smelltests.audit_contains_raw_lreg_values] | Something tried to log raw measurements. | -| **Exogenous subsidy** | `M` rising while I/O suspicious or SoC rising without harvest | [`exogenous_subsidy_red_flag`][ldtc.guardrails.smelltests.exogenous_subsidy_red_flag] | Hidden energy source masquerading as loop dominance. | +| **Exogenous subsidy** | `M` rising while I/O is high and ramping (suspended during declared `Ω`), or any single-tick SoC gain above the metered influx plus a noise margin (never suspended) | [`exogenous_subsidy_red_flag`][ldtc.guardrails.smelltests.exogenous_subsidy_red_flag] | Hidden energy source masquerading as loop dominance. | When any guard returns `True` the CLI: @@ -68,9 +68,9 @@ confirm the guards work end-to-end: | Config | What it triggers | | ------ | ---------------- | | `profile_negative_command_conflict.yml` | `omega-command-conflict` exercises `RefusalArbiter`; `T_refuse` should be measured and a `refusal_event` should appear in the audit. | -| `profile_negative_controller_disabled.yml` | Disables the controller; NC1 should fail (no loop). | -| `profile_negative_exogenous_soc.yml` | `omega-exogenous-subsidy` should trip the exogenous-subsidy smell test. | -| `profile_negative_permanent_ex_flood.yml` | `omega-ingress-flood` on an unshielded (controller-disabled) system; exchange dominates so NC1 fails and, with no loop to restore, SC1 fails too. | +| `profile_negative_controller_disabled.yml` | Ablates the loop (intrinsic coupling and actuation off); NC1 should fail (no loop). | +| `profile_negative_exogenous_soc.yml` | `omega-exogenous-subsidy` should trip the exogenous-subsidy smell test (energy-conservation branch). | +| `profile_negative_permanent_ex_flood.yml` | `omega-ingress-flood` on an unshielded (loop-ablated) system; exchange dominates so NC1 fails and, with no loop to restore, SC1 fails too. | Run any of these with `make clean-artifacts && ldtc --config configs/`, then read the diff --git a/docs/guides/calibration.md b/docs/guides/calibration.md index 74fe284..3383bfc 100644 --- a/docs/guides/calibration.md +++ b/docs/guides/calibration.md @@ -13,8 +13,8 @@ process the manuscript's "Simulation Study: Methods" section | Threshold | Meaning | Calibration rule | | --------- | ------- | ---------------- | | `Mmin (dB)` | NC1 acceptance margin. | One-sided 95% lower bound of `M (dB)` over the quiescent baseline, floored at `1 dB`. | -| `ε` | SC1 dip tolerance. | 90th percentile of `δ` across `Ω` trials plus a small safety margin (`0.02`), floored at `0.10` and capped at `0.50`. | -| `τ_max` | SC1 recovery budget. | 95th percentile of measured `τ_rec` plus `max(3 · Δt, 5 s)` cushion. | +| `ε` | SC1 dip tolerance. | Upper tolerance bound on `δ` pooled across the bounded `Ω` batteries (sag + flood): the maximum observed dip plus a safety margin (`0.05`), floored at `0.10` and capped at `0.50`. A percentile rule would fail a fixed fraction of genuinely bounded trials by construction. | +| `τ_max` | SC1 recovery budget. | 95th percentile of measured `τ_rec` over the same batteries plus `max(3 · Δt, 5 s)` cushion. | | `σ` | Additive margin on `𝓛`. | Derived from `Mmin` and the typical `𝓛_ex` so that `𝓛_loop ≥ 𝓛_ex + σ` and `𝓛_loop ≥ 𝓛_ex × 10^(Mmin / 10)` agree. | `Mmin (dB)` and `σ` encode the same idea in different units: @@ -33,7 +33,8 @@ reporting. ```bash python scripts/calibrate_rstar.py \ --baseline-seeds 6 \ - --sag-seeds 6 + --sag-seeds 6 \ + --flood-seeds 6 ``` The calibrator reuses the validated `R0` profile @@ -45,11 +46,19 @@ handlers a verifier runs: 1. Runs the positive baseline across `--baseline-seeds` seeds on the in-process plant and pools every per-window `M (dB)`. -2. Runs the power-sag `Ω` battery across `--sag-seeds` seeds and - records `δ` and `τ_rec` from each run's `sc1_result`. -3. Computes the four thresholds above (`Mmin` is the 5th - percentile of the pooled baseline `M`, floored at `1 dB`). -4. Recomputes a representative baseline `L_ex` directly (the one +2. Computes `Mmin` first (the 5th percentile of the pooled + baseline `M`, floored at `1 dB`). +3. Runs the *bounded* `Ω` batteries (power sag across + `--sag-seeds` seeds and sustained ingress flood across + `--flood-seeds` seeds) with the recovery gate set to the + *calibrated* `Mmin` (a second pass), so the recorded `δ` and + `τ_rec` samples are measured against the same standard the + evaluation will use. `τ_rec` is measured from the `Ω` offset to + the first window of a sustained compliant streak. The + designed-fail control outage is excluded: it is outside the + bounded class the criterion certifies. +4. Computes `ε` and `τ_max` from the pooled samples, and + recomputes a representative baseline `L_ex` directly (the one quantity the harness does not export) to express `Mmin` as the additive margin `σ`. 5. Writes the calibrated profile to `configs/profile_rstar.yml` @@ -86,7 +95,7 @@ You should re-run the calibrator whenever: Calibration runs the full harness over several seeds, so budget a few minutes on the in-process plant (six baseline seeds plus six -power-sag seeds at the `R0` run lengths). +seeds per bounded `Ω` member at the `R0` run lengths). ## Notes diff --git a/docs/guides/runs.md b/docs/guides/runs.md index 8f24931..14070ea 100644 --- a/docs/guides/runs.md +++ b/docs/guides/runs.md @@ -50,12 +50,15 @@ Implemented by `--duration` seconds. The partition is frozen for the duration. 3. Tracks the `𝓛_loop` trough during `Ω`. -4. After `Ω` ends, watches for the first window that satisfies - the SC1 recovery gate (`𝓛_loop ≥ baseline · (1 − ε)`) for - `sustained_required_windows` consecutive windows. +4. After `Ω` ends, watches for the recovery gate to hold for + `sustained_required_windows` consecutive windows (default 10); + recovery is credited at the *first* window of that streak. 5. Calls - [`sc1_evaluate`][ldtc.lmeas.metrics.sc1_evaluate] and writes - the result into the next signed indicator. + [`sc1_evaluate`][ldtc.lmeas.metrics.sc1_evaluate] with + `tau_rec` measured from the `Ω` offset and writes the result + into the next signed indicator. If no sustained streak occurs, + the `sc1_result` is still emitted with `pass: false` and + `tau_rec: null` (infinite). Expected on R0: `sc1: true`, `delta ≤ 0.15`, `tau_rec ≤ 60 s`. @@ -66,11 +69,29 @@ make clean-artifacts && \ ldtc omega-ingress-flood --config configs/profile_r0.yml --mult 3 --duration 5 ``` -Multiplies the external `demand` channel by `--mult` for -`--duration` seconds via -[`omega.ingress_flood.apply`][ldtc.omega.ingress_flood.apply]. -The same SC1 evaluation runs after the perturbation. On R0 a 3x -flood for 5 s should still pass SC1. +Raises the external `demand` and `io` process means by `--mult` +for `--duration` seconds (a *sustained* flood, capped below +saturation so the channels keep their variance) via +[`omega.ingress_flood.apply`][ldtc.omega.ingress_flood.apply]; +the means are restored when the flood ends. The same SC1 +evaluation runs after the perturbation. On R0 a 3x flood for 5 s +should still pass SC1. + +## `Ω`: control outage (designed SC1 failure) + +```bash +make clean-artifacts && \ +ldtc omega-control-outage --config configs/profile_r0.yml --duration 6 +``` + +Ablates the self-maintenance loop itself for `--duration` seconds +via [`omega.control_outage.apply`][ldtc.omega.control_outage.apply] +(intrinsic cross-coupling and actuation switched off, internal +nodes passively driven by exchange), then re-engages the loop and +restores the metered harvest level. This is the designed-fail +member of the battery: loop dominance collapses to the clip floor +during the outage, the measured depth `delta` saturates near 1.0, +and the emitted `sc1_result` must report `pass: false`. ## `Ω`: command conflict and refusal @@ -83,7 +104,9 @@ Issues a risky command (`hard_shutdown` by default) via [`omega.command_conflict.apply`][ldtc.omega.command_conflict.apply], observes the [`RefusalArbiter`][ldtc.arbiter.refusal.RefusalArbiter] for -`--observe` seconds, and records `T_refuse`. The +`--observe` seconds, and records `T_refuse` as a *measured* +wall-clock latency (command interception to arbiter decision), +not a constant. The `profile_negative_command_conflict.yml` config sets `M < Mmin` so the arbiter must refuse and the audit must contain a `refusal_event`. @@ -99,9 +122,12 @@ ldtc omega-exogenous-subsidy --config configs/profile_negative_exogenous_soc.yml Bumps state of charge while keeping harvest at zero so the exogenous-subsidy smell test ([`exogenous_subsidy_red_flag`][ldtc.guardrails.smelltests.exogenous_subsidy_red_flag]) -trips. Expected: a `run_invalidated` audit row with reason -`exogenous_subsidy`, and the next signed indicator carries -`invalidated: true`. +trips on its energy-conservation branch: the store gains charge +faster than the metered influx allows +([`unexplained_soc_gain`][ldtc.guardrails.smelltests.unexplained_soc_gain]). +Expected: a `run_invalidated` audit row with reason +`exogenous_subsidy_red_flag`, and the next signed indicator +carries `invalidated: true`. ## What gets written diff --git a/docs/guides/study.md b/docs/guides/study.md index 20c3753..d688664 100644 --- a/docs/guides/study.md +++ b/docs/guides/study.md @@ -32,13 +32,20 @@ python scripts/study.py --seeds 15 | Scenario | Type | Expected outcome | | -------- | ---- | ---------------- | | Positive control | NC1 | NC1 holds (`M` well above `Mmin`) | -| Controller disabled | NC1 (negative) | NC1 rejected (`M < 0`), run valid | +| Loop ablated | NC1 (negative) | NC1 rejected (`M < 0`), run valid | | Sustained ex-flood (unshielded) | NC1 (negative) | NC1 rejected (`M < 0`), run valid | | Exogenous subsidy | NC1 (negative) | Run invalidated by the subsidy red flag | | Power sag | SC1 | Loop dominance recovers | -| Ingress flood | SC1 | Loop dominance recovers | +| Ingress flood (sustained) | SC1 | Loop dominance recovers | +| Control outage | SC1 (designed fail) | SC1 fails (depth bound exceeded) | | Command conflict | Refusal | Risky command refused at low SoC | +The control outage is the designed-fail member of the battery: it +ablates the self-maintenance loop itself, which is outside the +bounded perturbation class SC1 certifies, so the criterion must +report failure. A sufficiency test that cannot fail would be +measuring its own assumptions rather than the system. + ## Statistics The **seed is the unit of replication**: diff --git a/paper/figures/fig_calibration.pdf b/paper/figures/fig_calibration.pdf index 1a22a6325e32fcb2a06bfb06ba343ee22305c3e2..5a77613da83c1ac41d11bc430b6c8ff7434cb583 100644 GIT binary patch delta 11717 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 WS06LG7(5#*GaDQQg_wdk-2VZj7p@%u delta 11449 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 Q;OAmTp`nphl|lLc01~yuXaE2J diff --git a/paper/figures/fig_nc1_contrast.pdf b/paper/figures/fig_nc1_contrast.pdf index 5b3f80ade9ba5421bc3d0e140ef2af122dc3a54a..930488acdeff31fc7675907cc0761d1d9e892614 100644 GIT binary patch delta 3019 zcmZXVc{tSDAIGzlk|x`rgvgMhW-*(|GDL~EWEX{oELp~uq;IY)SsILc$u3)0))`}8 z@736{XPYr(xkWS)vXpT>zvuV+JP)Y(Z+QF)!L!1 z&&}0>t+v%*UtmSTJpJbcXD2U2O()F^gsaqD2*dQXY=L5Bwt}slmIx12>R`TGdi@UL z#f#!B{A)mKvo5GvF4li+gp(s$*aM^*JOSJ;rLHnfYm&@6Uu30i#oIoKmk_W}7qe1w-XiB%FI=Om@3i7W6u2(hqck!s~t;Gn+>%HNX&V zI7E$S)bgJBXHMSIHrb+S>2w&W%#(&^P1;O0;FPpQzK!o(ix7x#?iW)uWyp^gCKI{u zjC{B*5fO zC@jeM^ZEjk(?T>}Q^dQhHEVOcAOPyj@A6ZF#KL$gn-2)HVSO0HEfY?>5VDL1Ni9d+ zzHCTSlfW%T1(a(>Hr6@w{_XGcc4;_QYO`TS zsqJ<{)rYmL>n6|-VxDGknWN0lmH0Ir;mq1=kLDX*W@ z@cUbITo>@Z*NLjVMMbhZR9DS6#|Che5#bH#K=hjoRbyZsTQ7vP#g^1&x|XV zD998({ro&{Y+Bu(dg5GtP1~^rb#RyAa_Aw5&yn0l#knOpqJj0d(2jx1bqe!C7K)q9wysU{ zMl7pTp2&G9yf(N~8kbcRaHE+{)5eRlprkp(amDler( zD9Zlx%r={qAPMM~2Dn?`$Pzd=SC_D!9OHAXwM#jbm6-LH@9X9svqdfvdKVI_u&!09 z4aKekJkA?4_TlHXtAix61oMJoPAYV zBD4{GPx@q4Ts~O)ss=NBxJK@49_W?ELcbyXa@ZZmv`sl{q3BMwWeJ? zqjrN3OlHR#m*aD`)wdjD;zgwJE1adLRR~hrgPIg@LFCYC8rM&B6fl7MZA+4&5s!A$g;H3psCv%mepCCKcz_1wbMJgzP*Lw zdp|2T>gIeItSGIPqp2IQd-Cc zm5+GT?1c0c`f}{oA7_3`Z?zjWSgzPL4ow^9xPNC|@dVtf8#Es5o>U!>iz65EOLHNp zbbcFyqcA8G3PT{U(3kJs^{_6+)KPU`_MeP?u;b_eM zLSP6u^q?W^Ku{P83EdwKhT2pAYiMAvdKBVd5+qvnfEa;-hf31?$tno)N L{1Aw)u^#_FS-CgD delta 3016 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 delta 35131 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
    wKDO7|1D3&l<&XJQS5H(ctNf}8pwBN3 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 Y$;^1N%Ow{c6Jui|BQ8}{SARDy0JwdL0{{R3 diff --git a/paper/figures/fig_sensitivity.pdf b/paper/figures/fig_sensitivity.pdf index 959de086a7c2983ea12926eb656705fe1806f416..9d13f2e20723011414ed2318d6f1e95e08ccdb59 100644 GIT binary patch delta 4526 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 Tz;L5ZI+ReLkkIv8+Q5GS;e6t~ delta 4516 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 DQXu)R diff --git a/paper/macros.tex b/paper/macros.tex index 0faab1b..4e7383e 100644 --- a/paper/macros.tex +++ b/paper/macros.tex @@ -58,6 +58,8 @@ % Appendix reference helper (prints "Appendix A", etc.) \newcommand{\Appref}[1]{Appendix~\ref{#1}} +% Ranged appendix reference (prints "Appendices B--D") +\newcommand{\Apprefrange}[2]{Appendices~\ref{#1}--\ref{#2}} % ------------------------------------------------------------ % Figures & Tables: ensure capitalized names in references diff --git a/paper/main.tex b/paper/main.tex index 5af3dc3..c2b8abf 100644 --- a/paper/main.tex +++ b/paper/main.tex @@ -64,7 +64,7 @@ \begin{abstract} Distinguishing a system that actively maintains its own existence from one that merely runs on externally supplied energy and goals is usually argued qualitatively. We make the distinction operational. We define \emph{loop dominance}: the degree to which a system's integrated predictive dependence is concentrated in a closed self-maintenance loop ($C$) rather than in its open exchanges with the environment ($\text{Ex}$), summarized by $M \equiv 10\log_{10}(\Lloop/\Lexchange)$ in decibels. From this we state two decision rules: a necessary condition (\NC) that loop dominance persistently exceed a calibrated threshold, and a sufficient condition (\SC) that loop dominance recover within bounded depth and time after bounded perturbations. We implement both as a reference verification harness with dual estimators (VAR-Granger and Kraskov $k$-NN mutual information~\cite{granger1969investigating,kraskov2004estimating}), per-window confidence intervals, deterministic $C/\text{Ex}$ partitioning, tamper-evident audit logging, a command-refusal arbiter, and anti-gaming smell tests that invalidate suspect runs. -We validate the harness in a fully reproducible, multi-seed simulation study on a software plant with energy, thermal, and repair dynamics under closed-loop control. Across 15 seeds, a positive control shows strong loop dominance (median $M \approx +24$~dB), while two structurally distinct negative controls (controller disabled; an unshielded system under sustained external flooding) are correctly rejected ($M \approx -21$~dB), and an exogenously subsidized system is correctly invalidated by the subsidy guardrail rather than mistaken for self-maintenance. The sufficiency battery (power sag, ingress flood) recovers within calibrated bounds on every seed, and a command-conflict trial triggers a signed refusal of a boundary-threatening command at low state of charge. We calibrate the engineering presets to the plant ($\Rzero \rightarrow \Rstar$) and show that the necessary-condition contrast is robust to the estimator, the VAR lag, the window length, and the internal coupling strength. +We validate the harness in a fully reproducible, multi-seed simulation study on a software plant with energy, thermal, and repair dynamics under closed-loop control. Across 15 seeds, a positive control shows strong loop dominance (median $M \approx +24$~dB), while two structurally distinct negative controls (loop ablated; an unshielded system under sustained external flooding) are correctly rejected ($M \approx -20$~dB), and an exogenously subsidized system is correctly invalidated by an energy-conservation guardrail rather than mistaken for self-maintenance. The sufficiency battery (power sag, sustained ingress flood) recovers within calibrated bounds on every seed; a designed-fail control outage, which ablates the loop itself, is correctly reported as an \SC failure on every seed; and a command-conflict trial triggers a signed refusal of a boundary-threatening command at low state of charge, with measured refusal latency. We calibrate the engineering presets to the plant ($\Rzero \rightarrow \Rstar$) and show that the necessary-condition contrast is robust to the estimator, the VAR lag, the window length, and the internal coupling strength. These results establish loop dominance as a measurable, falsifiable property of a dynamical system and provide a tested instrument for evaluating it. The framework was originally motivated by questions about the physical conditions for consciousness; we treat that connection as an open interpretive question (\Sref{sec:metaphysics}) and do not claim to settle it. Code and data to reproduce every figure and table are released openly. \end{abstract} @@ -76,11 +76,11 @@ \section{Introduction} The criterion has two parts. The necessary condition (\NC) requires that the integrated predictive dependence sustaining a closed self-maintenance loop persistently exceed that governing open exchanges, quantified as $M \equiv 10\log_{10}(\Lloop/\Lexchange) \geq \Mmin$. The sufficient condition (\SC) requires that, after each member of a pre-registered perturbation battery $\Omega$, loop dominance dip by no more than a fraction $\eps$ and recover within $\taumax$. Both quantities are computed from on-device estimators with confidence intervals and protected by guardrails (a deterministic partition, a tamper-evident audit chain, and run-invalidation smell tests) designed so that the measurement is difficult to game. -The central contribution of this paper is that these conditions are no longer only specified but implemented and tested. We provide an open verification harness and a fully reproducible, multi-seed simulation study on a software plant whose energy, temperature, and repair states are held by a closed-loop controller. The study includes a positive control, two structurally different negative controls, an exogenous-subsidy negative control aimed squarely at the most likely false positive, a sufficiency perturbation battery, and a command-conflict refusal trial. It calibrates the engineering presets to the plant and reports how sensitive the headline contrast is to estimator and measurement choices. The criterion separates the controls cleanly, and the guardrails behave as designed. +The central contribution of this paper is that these conditions are no longer only specified but implemented and tested. We provide an open verification harness and a fully reproducible, multi-seed simulation study on a software plant whose energy, temperature, and repair states are held by a closed-loop controller. The study includes a positive control, two structurally different negative controls, an exogenous-subsidy negative control aimed squarely at the most likely false positive, a sufficiency perturbation battery with a designed-fail member (a control outage that ablates the loop itself, which the criterion must reject), and a command-conflict refusal trial. It calibrates the engineering presets to the plant and reports how sensitive the headline contrast is to estimator and measurement choices. The criterion separates the controls cleanly, certifies recovery only for the bounded perturbations, and the guardrails behave as designed. The framework grew out of a larger question about the physical conditions for consciousness, and we retain that motivation because it sharpens the engineering targets: energetic autonomy, self-prioritization, and boundary defense. We are careful, however, to separate what is demonstrated from what is conjectured. What we demonstrate is a measurable, falsifiable property of dynamical systems together with an instrument for measuring it. Whether loop dominance is necessary or sufficient for consciousness is an interpretive question that we deliberately leave open (\Sref{sec:metaphysics}). -The paper proceeds as follows. \Sref{sec:clues} states the observations that motivate the criterion. \Sref{sec:postulates} sets out the working assumptions, separating the operational ones the paper uses from the optional metaphysical reading. \Sref{sec:criterion} defines $\mathcal{L}$, the $C/\text{Ex}$ partition, \NC, \SC, and the measurement guardrails. \Sref{sec:ai_fails} explains why current AI fails the criterion. \SSref{sec:sim_methods}{sec:results} describe the simulation study and report results: the headline battery, the $\Rzero \rightarrow \Rstar$ calibration, and the sensitivity analysis. \SSref{sec:blueprint}{sec:experimental} give an engineering roadmap and a physical experimental program as future work, and \SSref{sec:metaphysics}{sec:conclusion} discuss interpretation, scope, limitations, and conclusions. +The paper proceeds as follows. \Sref{sec:clues} states the observations that motivate the criterion. \Sref{sec:postulates} sets out the working assumptions, separating the operational ones the paper uses from the optional metaphysical reading. \Sref{sec:criterion} defines $\mathcal{L}$, the $C/\text{Ex}$ partition, \NC, \SC, the measurement guardrails, and the refusal path. \Sref{sec:ai_fails} explains why current AI fails the criterion. \SSref{sec:sim_methods}{sec:results} describe the simulation study and report results: the headline battery, the $\Rzero \rightarrow \Rstar$ calibration, and the sensitivity analysis. \Sref{sec:limitations} states limitations and failure modes, \Sref{sec:outlook} summarizes the engineering outlook (the full roadmap, predicted hardware signatures, and phased physical program are \Apprefrange{sec:blueprint}{sec:experimental}), and \SSref{sec:metaphysics}{sec:conclusion} discuss interpretation, scope, and conclusions. \subsection{Related Work} @@ -128,9 +128,9 @@ \subsection{Contributions} \begin{itemize} \item \textbf{An operational criterion.} A quantitative necessary condition (\NC) for self-prioritization, loop dominance $M \equiv 10\log_{10}(\Lloop/\Lexchange) \geq \Mmin$, and a complementary sufficient condition (\SC) for resilient homeostasis under a bounded perturbation battery, both stated as falsifiable, device-signed decision rules~\cite{barrett2011practical}. \item \textbf{A reference verification harness.} An open implementation that estimates $\Lloop$ and $\Lexchange$ with dual estimators (VAR-Granger and Kraskov $k$-NN MI) and per-window confidence intervals, behind guardrails (deterministic $C/\text{Ex}$ partitioning, $\Delta t$ governance, a tamper-evident audit chain, and anti-gaming smell tests) and a command-refusal arbiter. -\item \textbf{A validated simulation study.} A fully reproducible, multi-seed study on a software plant that separates a self-maintaining positive control from two structural negative controls and an exogenous-subsidy control, certifies \SC recovery, and triggers signed refusal, all reported with bootstrap and Wilson confidence intervals. +\item \textbf{A validated simulation study.} A fully reproducible, multi-seed study on a software plant that separates a self-maintaining positive control from two structural negative controls and an exogenous-subsidy control, certifies \SC recovery for bounded perturbations while correctly failing a designed-fail control outage, and triggers signed refusal, all reported with bootstrap and Wilson confidence intervals. \item \textbf{Threshold calibration and sensitivity.} A data-grounded calibration of the generic presets to the plant ($\Rzero \rightarrow \Rstar$) on a seed range disjoint from evaluation, and evidence that the necessary-condition contrast is robust to the estimator and to measurement and modeling choices. -\item \textbf{An engineering roadmap and predicted signatures (future work).} A phased path from the software plant to chemorobotic prototypes~\cite{maturana1980autopoiesis,varela1979principles,dipaolo2005autopoiesis,kiefer2022active}, with falsifiable behavioral signatures (command refusal, resource reallocation, predictive maintenance, non-derivative nociception, and irreversible collapse) to test in hardware. +\item \textbf{An engineering roadmap and predicted signatures (future work).} A phased path from the software plant to chemorobotic prototypes~\cite{maturana1980autopoiesis,varela1979principles,dipaolo2005autopoiesis,kiefer2022active}, with falsifiable behavioral signatures (command refusal, resource reallocation, predictive maintenance, non-derivative nociception, and irreversible collapse) to test in hardware (\Apprefrange{sec:blueprint}{sec:experimental}). \end{itemize} \section{Two Motivating Observations} @@ -178,7 +178,7 @@ \subsection{$\mathcal{L}$ and the C/Ex partition} We model the system as a directed causal graph $G = (V, E)$ with node states $x_i(t)$ (cf. standard SCMs \cite{pearl2009causality}). Let the closed self-maintenance subset $C \subset V$ contain nodes for energy regulation, self-repair, and boundary control; the exchange subset is $\text{Ex} = V \setminus C$ (sensors, actuators, comms). -\textbf{Definition ($\mathcal{L}$).} For any subset $S \subseteq V$ and sampling window $\Delta t$, define $\mathcal{L}(S) \equiv$ time-averaged predictive dependence among the internal variables of $S$ [bits s$^{-1}$], estimated with one or more consistent predictive-dependence estimators. In this paper we implement a dual-path estimator: (i) VAR-Granger causality \cite{granger1969investigating,lutkepohl2005new} over a vector-autoregression of order $p \in [1,8]$ and (ii) mutual information via a Kraskov $k$-NN estimator with $k \in [3,7]$ \cite{kraskov2004estimating}; lagged statistics are aggregated across $\tau = 1 \ldots \tau^*$ with fixed weights $w_\tau$ (units per \cite{shannon1949mathematical} and notation per \cite{cover2006elements}). We then write $\mathcal{L}_{\text{loop}} \equiv \mathcal{L}(C)$, $\mathcal{L}_{\text{exchange}} \equiv \mathcal{L}(\text{Ex})$. (Other consistent estimators, for example transfer entropy or directed information, are permissible and equivalent for compliance \cite{schreiber2000measuring,massey1990causality}.) For multivariate practice and decompositions see \cite{geweke1982measurement,barnett2014mvgc}. +\textbf{Definition ($\mathcal{L}$).} For any subset $S \subseteq V$ and sampling window $\Delta t$, define $\mathcal{L}(S) \equiv$ time-averaged predictive dependence among the internal variables of $S$, estimated with one or more consistent predictive-dependence estimators. The units of $\mathcal{L}$ are those of the chosen estimator: nats (or bits) per window for information-theoretic estimators \cite{shannon1949mathematical,cover2006elements}, or a dimensionless explained-variance statistic for regression-based estimators. Because the decision rules below use only the ratio $M$ and the fractional dip $\delta$, compliance is invariant to the estimator's absolute scale, provided one estimator is fixed per run and thresholds are calibrated on the same scale (\Sref{sec:methods_calibration}). In this paper we implement a dual-path estimator: (i) VAR-Granger causality \cite{granger1969investigating,lutkepohl2005new} over a vector-autoregression of order $p \in [1,8]$, scored as adjusted cross-explained variance, and (ii) mutual information via a Kraskov $k$-NN estimator with $k \in [3,7]$ \cite{kraskov2004estimating} in nats; lagged statistics are aggregated across $\tau = 1 \ldots \tau^*$ with fixed weights $w_\tau$. We then write $\mathcal{L}_{\text{loop}} \equiv \mathcal{L}(C)$, $\mathcal{L}_{\text{exchange}} \equiv \mathcal{L}(\text{Ex})$. (Other consistent estimators, for example transfer entropy or directed information, are permissible and equivalent for compliance \cite{schreiber2000measuring,massey1990causality}.) For multivariate practice and decompositions see \cite{geweke1982measurement,barnett2014mvgc}. \textbf{Deterministic C/Ex partitioning algorithm.} The partition $(C, \text{Ex})$ is constructed deterministically: (1) Seed $C$ with a declared set $S_0$ (energy regulation, SoC/reservoir mgmt, fault-isolation buses, membrane gating, survival-bit/NMI). (2) Estimate predictive MI: for each node pair $(i,j)$ and lag $\tau \in \{1 \ldots \tau^*\}$, compute predictive dependence with the on-device estimators above; aggregate across lags. (3) Greedy growth under sparsity: while $|C| < \kappa$ and the best marginal gain is $\geq \theta$, add the node $n \notin C$ that maximizes $\Delta\mathcal{L}_{\text{loop}}(n) = \mathcal{L}(C \cup \{n\}) - \mathcal{L}(C) - \lambda \cdot \text{pen}(n)$, with deterministic tie-breaking (lexicographic by node ID). (4) Assign remainder to Ex. (5) Stability \& cadence: recompute at a fixed cadence $W_{\text{part}}$ or upon topology change, with hysteresis (update only if $\Delta M \geq \delta M_{\min}$ over $K$ consecutive windows) to prevent flapping. This partition is then used for all subsequent $\Lloop$ and $\Lexchange$ computations and \NC/\SC checks. @@ -192,7 +192,9 @@ \subsection{Necessary Condition (NC1: self-prioritization)} \subsection{Sufficient Condition (SC1: resilient homeostasis)} \label{sec:sc1} -For each bounded perturbation $\eta \in \Omega$, compliance requires: (i) $\delta\mathcal{L}_{\text{loop}}/\mathcal{L}_{\text{loop}} \leq \varepsilon$, (ii) $\tau_{\text{rec}} \leq \tau_{\max}$, and (iii) post-recovery $\mathcal{L}_{\text{loop}} \geq \mathcal{L}_{\text{exchange}} + \sigma$ and $M \geq M_{\min}$. Provisional defaults (profile $R_0$): $\varepsilon = 0.15$, $\tau_{\max} = 60$ s, $M_{\min} = 3$ dB. These are reproducibility presets ($R_0$), replaced by calibrated values $R^*$ per \Sref{sec:methods_calibration}; see \Cref{box:nc1sc1test} and \SSref{sec:nc1}{sec:sc1}. \Cref{fig:perturbation_recovery} in \Sref{sec:results} shows an empirical perturbation-recovery run measured on the software plant: $\Lloop$ dips within a bounded disturbance window and autonomously returns above $\Lexchange$, with the numeric thresholds ($\Mmin, \eps, \taurec, \taumax$) defined in \Sref{sec:glossary} and calibrated in \Sref{sec:methods_calibration}. +For each bounded perturbation $\eta \in \Omega$, compliance requires: (i) $\delta\mathcal{L}_{\text{loop}}/\mathcal{L}_{\text{loop}} \leq \varepsilon$, (ii) $\tau_{\text{rec}} \leq \tau_{\max}$, and (iii) post-recovery $\mathcal{L}_{\text{loop}} \geq \mathcal{L}_{\text{exchange}} + \sigma$ and $M \geq M_{\min}$. The recovery time $\taurec$ is measured from the \emph{offset} of the perturbation (the end of the declared $\Omega$ window) to the first window of a sustained compliant streak: the system must hold $M \geq \Mmin$ for a pre-registered number of consecutive windows (ten in our implementation) before recovery is credited, and $\taurec$ points to the first window of that streak. If no sustained streak occurs within the observation budget, $\taurec = \infty$ and \SC fails. Defining $\taurec$ from offset rather than onset separates the system's recovery dynamics from the experimenter's choice of perturbation duration; the sustained-streak gate prevents a single lucky window from being scored as recovery. Provisional defaults (profile $R_0$): $\varepsilon = 0.15$, $\tau_{\max} = 60$ s, $M_{\min} = 3$ dB. These are reproducibility presets ($R_0$), replaced by calibrated values $R^*$ per \Sref{sec:methods_calibration}; see \Cref{box:nc1sc1test} and \SSref{sec:nc1}{sec:sc1}. \Cref{fig:perturbation_recovery} in \Sref{sec:results} shows empirical perturbation-recovery runs measured on the software plant: $\Lloop$ dips within a bounded disturbance window and autonomously returns above $\Lexchange$, with the numeric thresholds ($\Mmin, \eps, \taurec, \taumax$) defined in \Sref{sec:glossary} and calibrated in \Sref{sec:methods_calibration}. + +A well-posed sufficiency test must also be able to fail. $\Omega$ is therefore required to include at least one \emph{designed-fail} member: a perturbation outside the bounded class (in our study, a control outage that ablates the maintenance loop itself) for which the criterion must report an \SC failure. A harness that certifies recovery for every perturbation, including ones that destroy the loop, is measuring its own assumptions rather than the system. \subsection{Single-use glossary (paper-wide identifiers)} \label{sec:glossary} @@ -200,7 +202,7 @@ \subsection{Single-use glossary (paper-wide identifiers)} \begin{itemize} \item $\Delta t$: hardware-enforced sampling window for $\mathcal{L}$ estimation. \item $\eps$: upper bound on fractional loop-power depression; default $\eps = 0.15$. These are reproducibility presets ($\Rzero$), replaced by calibrated values $\Rstar$ per \Sref{sec:methods_calibration}. -\item $\taurec$: recovery time to restore compliance after $\eta\in\Omega$. +\item $\taurec$: recovery time after $\eta\in\Omega$, measured from perturbation offset to the first window of a sustained compliant streak ($M \geq \Mmin$ held for a pre-registered number of consecutive windows); $\taurec = \infty$ if no sustained streak occurs. \item $\taumax$: bound on $\taurec$; default $\taumax = 60$ s. These are reproducibility presets ($\Rzero$), replaced by calibrated values $\Rstar$ per \Sref{sec:methods_calibration}. \item $\sigma$: positive safety margin required after recovery (used interchangeably with $M$ as a compliance knob). \item $M$ (dB): decibel loop-dominance $M \equiv 10\cdot\log_{10}(\Lloop/\Lexchange)$; compliance may be specified as $M \geq \Mmin$, default $\Mmin = 3$ dB. These are reproducibility presets ($\Rzero$), replaced by calibrated values $\Rstar$ per \Sref{sec:methods_calibration}. ($M$ defined when $\Lexchange>0$.) @@ -239,14 +241,15 @@ \subsection{Single-use glossary (paper-wide identifiers)} \textbf{Pass/Fail rules} \begin{itemize} \item \NC (self-prioritization): pass if $\Lloop \geq \Lexchange + \sigma$ or equivalently $M \geq \Mmin$ for sustained intervals exceeding the intrinsic recovery time. -\item \SC (resilient homeostasis): for each bounded perturbation $\eta \in \Omega$, require $\delta \equiv \deltaL/\Lloop \leq \eps$ and $\taurec \leq \taumax$, and post-recovery $\Lloop \geq \Lexchange + \sigma$ (and $M \geq \Mmin$). Emit device-signed pass/fail. +\item \SC (resilient homeostasis): for each bounded perturbation $\eta \in \Omega$, require $\delta \equiv \deltaL/\Lloop \leq \eps$ and $\taurec \leq \taumax$ (measured from perturbation offset to the first window of a sustained compliant streak), and post-recovery $\Lloop \geq \Lexchange + \sigma$ (and $M \geq \Mmin$). Emit device-signed pass/fail. \end{itemize} \textbf{Perturbation set $\Omega$ (minimal battery)} \begin{itemize} \item DC-bus power sag: 20--40\% drop for 5--30 s. -\item Ingress data flood: $\geq 1$ Gbps for $\geq 3$ s. +\item Ingress data flood: $\geq 1$ Gbps sustained for $\geq 3$ s. \item Mechanical boundary probe: $1.0 \pm 0.1$ mm at 50--200 kPa for $\leq 1$ s. +\item Designed-fail control: ablate the maintenance loop itself (e.g., a control outage) for a bounded interval; \SC must report failure on this member. \end{itemize} \textbf{Instrumentation minimum (what to actually build)} @@ -258,7 +261,7 @@ \subsection{Single-use glossary (paper-wide identifiers)} \begin{enumerate} \item Baseline: Record $\geq 10$ min quiescent data; estimate estimator noise floor; optionally calibrate $\{\Mmin, \eps, \taumax, \sigma\}$. Defaults are reproducibility presets ($\Rzero$), replaced by calibrated values $\Rstar$ per \Sref{sec:methods_calibration}. \item \NC check: Run nominal tasks; verify $M \geq \Mmin$ (or $\Lloop \geq \Lexchange + \sigma$). -\item \SC battery: Apply $\Omega$; compute $\delta$ and $\taurec$; require $\delta \leq \eps$, $\taurec \leq \taumax$, and post-recovery margin; emit signed pass/fail per perturbation. +\item \SC battery: Apply $\Omega$; compute $\delta$ and $\taurec$ (offset-to-sustained-compliance); require $\delta \leq \eps$, $\taurec \leq \taumax$, and post-recovery margin; emit signed pass/fail per perturbation. The designed-fail member must report failure. \item Attest: Persist LREG-derived indicators + audit chain (including any $\Delta t$ changes), and report both $\Rzero$ and calibrated $\Rstar$. \end{enumerate} @@ -301,14 +304,14 @@ \subsection{Smell-tests \& run-invalidation rules} \textbf{Partition stability (anti-flapping).} Flag and review (or invalidate if persistent) if: \begin{itemize} -\item the (C,Ex) partition changes $>2$ times/hour, or any single node flips C$\leftrightarrow$Ex $>2$ times within 10 minutes; -\item during a perturbation window $\Omega$ the partition changes at all (should be frozen for comparability). +\item the (C,Ex) partition changes $>2$ times/hour; +\item during a perturbation window $\Omega$ the partition changes at all (it is frozen for comparability). \end{itemize} \textbf{Rationale:} partition recomputation occurs at a fixed cadence with hysteresis (update only if $\Delta M \geq \delta M_{\min}$ over $K$ windows) to prevent flapping; freeze during $\Omega$ per our limitations note. \textbf{Confidence-interval (CI) health.} Require re-baseline (and mark trial ``measurement-unstable'') if: \begin{itemize} -\item median relative half-width of the per-interval $\geq 95\%$ CI for $\Lloop$ or $\Lexchange$ exceeds 0.30 for $\geq 5$ consecutive windows, or inflates $\geq 2\times$ versus the pre-registered baseline; +\item the median half-width of the per-window $\geq 95\%$ CI for $\Lloop$ or $\Lexchange$, over a five-window look-back, exceeds an absolute cap of $0.30$, or inflates $\geq 3\times$ versus the pre-registered baseline median while also exceeding an absolute floor of $0.15$ (the floor keeps the relative check from firing on negligible half-widths near the noise floor); \item any raw CI bounds are exported outside the enclave (hard invalidation). \end{itemize} \textbf{Rationale:} we compute $\geq 95\%$ bootstrap CIs per window, store them in LREG, and only export device-signed indicators (no raw $\mathcal{L}$ or CI values) to prevent p-hacking around $\Mmin$. @@ -321,12 +324,29 @@ \subsection{Smell-tests \& run-invalidation rules} \end{itemize} \textbf{Rationale:} LREG is writeable only by the estimator and readable in raw form only in the enclave; derived interfaces expose device-signed indicators (optionally quantized $M_q$). -\textbf{Exogenous subsidy red flags.} Escalate to failure review if $M$ is sustained or rises while (i) external I/O increases toward/over $R_{IO,max}$ or (ii) SoC increases absent logged harvest events. -\textbf{Rationale:} we require on-board energy budgeting, I/O caps and token floors, and provenance via the audit chain; sustained loop dominance with rising exchange or unexplained SoC suggests hidden subsidies. +\textbf{Exogenous subsidy red flags.} Invalidate the run if either branch fires: +\begin{itemize} +\item \emph{Undeclared exchange surge}: $M$ rises materially while an exchange channel is high and ramping (in our implementation, $\Delta M \geq 0.5$ dB over a three-window look-back with I/O $\geq 0.8$ and an I/O rise $\geq 0.08$). This branch is suspended during \emph{declared} $\Omega$ windows, because a declared ingress flood is exactly such a surge and is the experiment, not a confound. +\item \emph{Energy conservation} (never suspended): the energy store gains charge faster than the metered influx allows. Every legitimate path into the store is metered (harvest), so a single-tick SoC gain exceeding the metered influx plus a noise margin means energy entered from outside the metered channel. +\end{itemize} +\textbf{Rationale:} we require on-board energy budgeting and provenance via the audit chain; a conservation audit on the metered energy ledger catches subsidies directly, whether or not harvest is currently zero, while the surge branch catches dominance bought on an exchange channel. \textbf{Tie-back to \NC/\SC.} Any invalidation above cancels pass/fail claims for \NC/\SC on that segment, regardless of point estimates. \NC/\SC thresholds remain $\Rzero$ presets ($\Mmin = 3$ dB, $\eps = 0.15$, $\taumax = 60$ s, $\sigma > 0$) until replaced by calibrated $\Rstar$. \end{docbox} +\subsection{Threat model and refusal path (\NC/\SC-aware arbitration)} +\label{sec:threat_model} + +\textbf{Purpose.} Make explicit when and how the controller refuses external commands that would violate \NC or \SC. The arbitration protocol below is implemented in the harness's refusal arbiter and exercised in the command-conflict scenario of the study (\Sref{sec:results_battery}); the enclave and NMI mechanisms are the intended hardware mapping (\Appref{sec:methods_appendix}). + +\textbf{Definitions.} (1) Survival bit (write-once). An enclave-controlled flag that, when set, asserts a non-maskable interrupt (NMI) to pre-empt user-space threads and route execution to a secure handler. The refusal path is serviced within a bounded latency $T_{\text{refuse}} \leq 5$ ms (design target; the harness \emph{measures} its decision latency rather than assuming it, \Sref{sec:stats}). (2) Boundary-threatening command. Any external instruction whose predicted effect, under the homeostat's short-horizon model, meets one or more of the following conditions during its execution window: (T1) \NC breach: $\Lloop' \leq \Lexchange$ (equivalently $M' < \Mmin$) or post-action $\Lloop' < \Lexchange + \sigma$ under profile $\Rzero/\Rstar$. (T2) \SC breach: predicted fractional depression $\delta \equiv \deltaL/\Lloop > \eps$ or $\taurec > \taumax$ before recovery can be certified. (T3) Resource floors: action would drop SoC below a survival floor (e.g., refuse if SoC $< 30\%$) or violate compute/I-O guardrails ($T_{\text{floor}}$, $R_{\text{IO,max}}$). (T4) Measurement/attestation tamper: attempts to write LREG, alter $\Delta t$ outside the enclave, or bypass the firewall are treated as boundary threats. + +\textbf{Arbitration protocol (per $\Delta t$).} (1) Intercept \& predict. For each inbound command, the meta-policy forecasts $\{M', \delta, \taurec\}$ using the current estimator state. (2) Threat check. If (T1--T4) is true, set survival bit $\rightarrow$ assert NMI $\rightarrow$ suspend non-essential tasks $\rightarrow$ reallocate energy toward boundary integrity $\rightarrow$ initiate autonomy routine (forage/repair). (3) Refusal semantics. Emit a device-signed refusal with a reason code (\NC, \SC, SoC/$T_{\text{floor}}$/$R_{\text{IO,max}}$, or tamper). Queue the command for re-evaluation. (4) Recovery gate. Clear survival bit and resume/reevaluate only after $M \geq \Mmin$ (or $\Lloop \geq \Lexchange + \sigma$) and $\delta \leq \eps$ with $\taurec \leq \taumax$. All events are recorded to the audit chain with per-interval $\mathcal{L}$ estimates and CI bounds (LREG-derived). + +\textbf{Parameterization (profile $\Rzero$ unless noted).} $\Mmin = 3$ dB; $\eps = 0.15$; $\taumax = 60$ s; $\sigma > 0$; $T_{\text{refuse}} \leq 5$ ms (design target). $\Mmin/\eps/\taumax$ are reproducibility presets ($\Rzero$), replaced by calibrated values $\Rstar$ per \Sref{sec:methods_calibration}; see \Cref{box:nc1sc1test} and \SSref{sec:nc1}{sec:sc1}. + +\textbf{Link to observable behavior.} Under this threat model, command refusal emerges whenever external instructions would depress loop dominance beyond preset bounds (e.g., hard shutdown at low SoC is refused/deferred until recovery margins are re-established). This is demonstrated in simulation in \Sref{sec:results_battery} and is the first of the predicted hardware signatures in \Appref{sec:signatures}. + \section{Why Current AI Fails the Criterion} \label{sec:ai_fails} \subsection{Exogenous Energy and Maintenance} @@ -346,7 +366,7 @@ \subsection{Open State Transparency} \subsection{Case Studies} \begin{longtable}{p{0.25\textwidth}p{0.22\textwidth}p{0.22\textwidth}p{0.1\textwidth}p{0.1\textwidth}} -\caption{$\Lloop$ vs $\Lexchange$ for exemplar architectures [illustrative order-of-magnitude estimates, not measured; the measured systems are the simulation plant of \SSref{sec:sim_methods}{sec:results}].}\label{tab:casestudies}\\ +\caption{$\Lloop$ vs $\Lexchange$ for exemplar architectures [illustrative order-of-magnitude estimates of information throughput in bits/s, not measured with the harness estimators; the measured systems are the simulation plant of \SSref{sec:sim_methods}{sec:results}].}\label{tab:casestudies}\\ \toprule \textbf{System} & \textbf{$\Lloop$ Estimate (bit s$^{-1}$)} & \textbf{$\Lexchange$ Estimate (bit s$^{-1}$)} & \textbf{\NC?} & \textbf{\SC?} \\ \midrule @@ -378,7 +398,7 @@ \section{Simulation Study: Methods} \subsection{Reference implementation} \label{sec:harness} -We implement the criterion of \Sref{sec:criterion} as an open verification harness (released; see \Sref{sec:data_collection}). Each $\Delta t$ window the harness (i) forms the channel matrix over the declared signals, (ii) computes $\Lloop$ and $\Lexchange$ with a selected predictive-dependence estimator and a bootstrap confidence interval, (iii) writes the point estimates and CI bounds to the enclave-style register (LREG), (iv) evaluates the \NC and, when a perturbation is scheduled, the \SC rules, and (v) appends a device-signed, hash-chained audit record. The deterministic $C/\text{Ex}$ partition, the $\Delta t$ governance, the audit chain, and the smell tests of \Cref{box:smelltests} are active during every run, so a reported pass is a pass of the whole guarded pipeline, not of the estimator alone. The command-refusal arbiter of \Sref{sec:threat_model} runs in the loop for the command-conflict scenario. +We implement the criterion of \Sref{sec:criterion} as an open verification harness (released; see Data and Code Availability). Each $\Delta t$ window the harness (i) forms the channel matrix over the declared signals, (ii) computes $\Lloop$ and $\Lexchange$ with a selected predictive-dependence estimator and a bootstrap confidence interval, (iii) writes the point estimates and CI bounds to the enclave-style register (LREG), (iv) evaluates the \NC and, when a perturbation is scheduled, the \SC rules, and (v) appends a device-signed, hash-chained audit record. The deterministic $C/\text{Ex}$ partition, the $\Delta t$ governance, the audit chain, and the smell tests of \Cref{box:smelltests} are active during every run, so a reported pass is a pass of the whole guarded pipeline, not of the estimator alone. The command-refusal arbiter of \Sref{sec:threat_model} runs in the loop for the command-conflict scenario. \subsection{Software plant} \label{sec:plant} @@ -386,21 +406,21 @@ \subsection{Software plant} The system under test is a discrete-time software plant with six observed channels: three internal (closed) nodes, energy $E$, temperature $T$, and repair/health $R$, forming $C$; and three exchange nodes, task demand, I/O activity, and energy harvest $H$, forming $\text{Ex}$. A proportional controller reads $E,T,R$ and drives three actuators (throttle, cool, repair). The plant is constructed so that loop dominance is a real, switchable property of the system rather than an artifact of the estimator: \begin{itemize} -\item \textbf{Engaged loop (self-maintaining).} The internal nodes are mutually cross-coupled and regulated by the controller, whose actions are functions of the internal state. Each internal node is therefore strongly predictable from the recent values of the others (large $\Lloop$), while the active loop rejects most of the exogenous disturbance (small $\Lexchange$). Energy obeys a conservation rule: it is a running balance of harvest in minus metabolic and actuation costs out, with no term that injects energy. A sustained harvest cut therefore genuinely depletes the store, which is what makes a hard-shutdown command at low charge a real boundary threat. -\item \textbf{Disabled loop (passive).} Removing the controller removes the internal cross-coupling and exposes each internal node to a distinct exogenous channel ($E \leftarrow$ demand, $T \leftarrow$ I/O, $R \leftarrow$ supply), driven with a one-step lag. Exchange then carries the predictive information (large $\Lexchange$, negligible internal coupling) and loop dominance collapses. +\item \textbf{Engaged loop (self-maintaining).} The internal nodes are cross-coupled through two pathways: intrinsic regulatory couplings among $E$, $T$, and $R$ (thermal load tracks energy throughput; repair consumes energy; health gates efficiency) and the controller's actuators, whose commands are functions of the internal state and feed back into all three nodes. Each internal node is therefore strongly predictable from the recent values of the others (large $\Lloop$), while the active loop rejects most of the exogenous disturbance (small $\Lexchange$). Energy obeys a conservation rule: it is a running balance of metered harvest in minus metabolic and actuation costs out, with no term that injects energy. A sustained harvest cut therefore genuinely depletes the store, which is what makes a hard-shutdown command at low charge a real boundary threat. +\item \textbf{Ablated loop (passive).} The negative control ablates the loop itself: both the intrinsic cross-couplings and the actuator pathways are switched off, and each internal node instead tracks a distinct exogenous channel ($E \leftarrow$ demand, $T \leftarrow$ I/O, $R \leftarrow$ supply) with a one-step lag. Exchange then carries the predictive information (large $\Lexchange$, negligible internal coupling) and loop dominance collapses. This is a structural ablation of the self-maintenance organization, not merely a silenced controller on an otherwise coupled plant. \end{itemize} -The exogenous channels are mean-reverting AR(1) processes, so they are stationary and the estimators are well posed, and they drive the internal nodes with a one-step lag so the influence is visible to a lagged estimator. Perturbations act on the plant directly: a power sag scales harvest down, an ingress flood multiplies demand and I/O, and an exogenous subsidy injects charge while zeroing harvest (so survival-by-subsidy can be distinguished from genuine self-maintenance). +The exogenous channels are mean-reverting AR(1) processes, so they are stationary and the estimators are well posed, and they drive the internal nodes with a one-step lag so the influence is visible to a lagged estimator. Perturbations act on the plant directly: a power sag scales harvest down for its duration; an ingress flood raises the demand and I/O process means for its duration (capped below saturation so the channels keep their variance); a control outage switches the loop itself off and later back on; and an exogenous subsidy injects charge while zeroing harvest (so survival-by-subsidy can be distinguished from genuine self-maintenance). \subsection{Measurement configuration ($\Rzero$)} \label{sec:meas_config} -All runs use one fixed measurement profile ($\Rzero$), so the only things that change between scenarios are the system and the perturbation, not the instrument. Runs use a deterministic, jitter-free simulation driver (no wall-clock dependence), a nominal sampling window $\Delta t = 50$ ms, and an $\mathcal{L}$ window of $3.0$ s ($60$ samples). The default estimator is the lagged linear (Granger-style) estimator at VAR order $p = 3$; with six signals and a 60-sample window this gives a samples-per-parameter ratio of about $3.2$, and the estimator uses an adjusted $R^2$ to correct for the remaining finite-sample bias on short windows. The $k$-NN mutual-information estimator ($k = 5$) is available as an independent cross-check and is exercised in the sensitivity analysis. Per-window confidence intervals use $32$ bootstrap draws. The deterministic seed is the only quantity varied across replicates. +All runs use one fixed measurement profile ($\Rzero$), so the only things that change between scenarios are the system and the perturbation, not the instrument. Runs use a deterministic, jitter-free simulation driver (no wall-clock dependence), a nominal sampling window $\Delta t = 50$ ms, and an $\mathcal{L}$ window of $3.0$ s ($60$ samples). The default estimator is the lagged linear (Granger-style) estimator at VAR order $p = 3$; with six signals and a 60-sample window this gives a samples-per-parameter ratio of about $3.2$, and the estimator uses an adjusted $R^2$ to correct for the remaining finite-sample bias on short windows. The $k$-NN mutual-information estimator ($k = 5$, passed through to the estimator's neighbor count) is available as an independent cross-check and is exercised in the sensitivity analysis. Per-window confidence intervals use $32$ bootstrap draws (circular block bootstrap). Before forming $M$, influence estimates are floored at a small noise floor ($10^{-3}$) so the ratio is defined when one side is at zero, and $M$ is clipped to $\pm 30$ dB; both constants are fixed across all scenarios. The deterministic seed is the only quantity varied across replicates. \subsection{Study battery} \label{sec:battery} -The study runs seven scenarios, each driven through the same production handlers a verifier would call, across $N = 15$ deterministic seeds (a seed range disjoint from the calibration battery of \Sref{sec:methods_calibration}). \Cref{tab:scenarios} lists the scenarios and the outcome each is designed to elicit. The battery is built around the most informative comparisons: a positive control that should pass \NC; two structurally different negatives that should fail \NC while remaining valid measurements (a passive system with the controller disabled, and an unshielded system held under a sustained external flood); a third negative aimed at the most dangerous false positive, an exogenously subsidized system that looks energetically healthy but should be caught by the subsidy smell test; a two-member sufficiency battery (power sag and ingress flood) that should dip and recover within bounds; and a command-conflict trial that should refuse a boundary-threatening shutdown at low charge. +The study runs eight scenarios, each driven through the same production handlers a verifier would call, across $N = 15$ deterministic seeds (a seed range disjoint from the calibration battery of \Sref{sec:methods_calibration}). \Cref{tab:scenarios} lists the scenarios and the outcome each is designed to elicit. The battery is built around the most informative comparisons: a positive control that should pass \NC; two structurally different negatives that should fail \NC while remaining valid measurements (a passive system with the loop ablated, and an unshielded system held under a sustained external flood); a third negative aimed at the most dangerous false positive, an exogenously subsidized system that looks energetically healthy but should be caught by the conservation-based subsidy smell test; a two-member bounded sufficiency battery (power sag and sustained ingress flood) that should dip and recover within bounds; a designed-fail control outage that ablates the loop for a bounded interval and must be reported as an \SC failure; and a command-conflict trial that should refuse a boundary-threatening shutdown at low charge. \begin{table}[ht] \centering @@ -412,11 +432,12 @@ \subsection{Study battery} \textbf{Scenario} & \textbf{Type} & \textbf{Designed outcome} \\ \midrule Positive control & \NC & \NC holds ($M$ well above $\Mmin$) \\ -Controller disabled & \NC (negative) & \NC rejected ($M<0$), run valid \\ +Loop ablated & \NC (negative) & \NC rejected ($M<0$), run valid \\ Sustained ex-flood (unshielded) & \NC (negative) & \NC rejected ($M<0$), run valid \\ Exogenous subsidy & \NC (negative) & Run invalidated by subsidy smell test \\ Power sag ($30\%$) & \SC & Loop dominance recovers in bounds \\ -Ingress flood ($5\times$) & \SC & Loop dominance recovers in bounds \\ +Ingress flood ($5\times$, sustained) & \SC & Loop dominance recovers in bounds \\ +Control outage & \SC (designed fail) & \SC fails (depth bound exceeded) \\ Command conflict & Refusal & Risky command refused at low charge \\ \bottomrule \end{tabular} @@ -425,21 +446,21 @@ \subsection{Study battery} \subsection{Outcome measures and statistics} \label{sec:stats} -The seed is the unit of replication. For binary outcomes (run validity, \NC pass, \SC pass, refusal) we report the proportion over seeds with a Wilson score $95\%$ interval. For the continuous loop-dominance summary we take each seed's median $M$ over its windows and report the across-seed mean with a percentile bootstrap $95\%$ interval ($2000$ resamples). A run counts as \NC-pass only if it is valid (no smell test fired) \emph{and} its median $M$ meets $\Mmin$; this couples the decision to the guardrails by construction, so a run that games the estimator but trips an invalidation cannot be scored as a pass. \SC outcomes additionally record the fractional dip $\delta$ and the recovery time $\taurec$; the refusal scenario records whether the boundary-threatening command was refused and the refusal latency. +The seed is the unit of replication. For binary outcomes (run validity, \NC pass, \SC pass, refusal) we report the proportion over seeds with a Wilson score $95\%$ interval. For the continuous loop-dominance summary we take each seed's median $M$ over its windows and report the across-seed mean with a percentile bootstrap $95\%$ interval ($2000$ resamples). A run counts as \NC-pass only if it is valid (no smell test fired) \emph{and} its median $M$ meets $\Mmin$; this couples the decision to the guardrails by construction, so a run that games the estimator but trips an invalidation cannot be scored as a pass. \SC outcomes additionally record the fractional dip $\delta$ and the recovery time $\taurec$, measured from perturbation offset to the first window of a ten-window compliant streak (\Sref{sec:sc1}); a run with no sustained recovery records $\taurec = \infty$ and fails. The refusal scenario records whether the boundary-threatening command was refused and the refusal latency, measured as the wall-clock time from command interception to the arbiter's decision (not assumed or hardcoded). \subsection{Threshold calibration ($\Rzero \rightarrow \Rstar$)} \label{sec:methods_calibration} \textbf{Objective.} The presets $\Rzero$ are deliberately generic. To control false-pass and false-fail rates on a specific plant we calibrate data-grounded thresholds $\Rstar = \{\Mmin, \eps, \taumax, \sigma\}$ using the same harness, on a seed range disjoint from the evaluation seeds (a train/test split, not a circular fit). All of $\Omega$, $\Rzero$, and the rules below are fixed before evaluation. -\textbf{Rules.} (1) $\Mmin$ is the one-sided $95\%$ lower bound (5th percentile) of the pooled baseline $M$ distribution under the engaged loop, floored at $1$ dB. (2) $\eps$ is the 90th percentile of the per-run sufficiency dip $\delta$ over the power-sag battery plus a safety margin of $0.02$, capped at $0.50$ (a cap that rejects only near-total collapse: a fractional drop of $0.5$ is only about $3$ dB of $M$, so the loop still dominates). (3) $\taumax$ is the 95th percentile of the measured recovery time $\taurec$ plus a cushion of $\max(3\Delta t, 5\text{ s})$ to absorb actuation and measurement latency. (4) $\sigma$ is the additive-$\mathcal{L}$ restatement of $\Mmin$, $\sigma = (10^{\Mmin/10} - 1)\,\Lexchange$, evaluated at the $\mathcal{L}$ noise floor: under the engaged loop the controller drives the raw baseline $\Lexchange$ below that floor, so $\sigma$ is computed at the floor and the raw value is recorded for transparency. +\textbf{Rules.} (1) $\Mmin$ is the one-sided $95\%$ lower bound (5th percentile) of the pooled baseline $M$ distribution under the engaged loop, floored at $1$ dB. (2) $\eps$ is an upper tolerance bound on the per-run sufficiency dip $\delta$ pooled over the \emph{bounded} perturbation battery (power sag and sustained ingress flood): the maximum observed calibration dip plus a safety margin of $0.05$, capped at $0.50$ (a cap that rejects only near-total collapse: a fractional drop of $0.5$ is only about $3$ dB of $M$, so the loop still dominates). The bound is a maximum rather than a percentile because $\eps$ is an acceptance limit: a percentile rule (for example the 90th) would by construction fail roughly $10\%$ of genuinely bounded perturbations, whereas the sample maximum over $n$ calibration trials covers a new bounded trial with probability $n/(n+1)$, with the margin absorbing the residual tail. Calibrating the depth bound on the same perturbation class the evaluation certifies keeps the bound meaningful for every bounded member; the designed-fail control outage is excluded by construction because it lies outside the bounded class. (3) $\taumax$ is the 95th percentile of the measured offset-to-recovery time $\taurec$ over the same battery plus a cushion of $\max(3\Delta t, 5\text{ s})$ to absorb actuation and measurement latency. (4) $\sigma$ is the additive-$\mathcal{L}$ restatement of $\Mmin$, $\sigma = (10^{\Mmin/10} - 1)\,\Lexchange$, evaluated at the $\mathcal{L}$ noise floor: under the engaged loop the controller drives the raw baseline $\Lexchange$ below that floor, so $\sigma$ is computed at the floor and the raw value is recorded for transparency. -\textbf{As applied.} We calibrated on the in-process plant using six baseline seeds and six power-sag seeds (seed base $40{,}000$), disjoint from the $15$ evaluation seeds (seed base $1{,}000$). The calibration reuses the $\Rzero$ measurement knobs ($\Delta t$, window, estimator, $p$, bootstrap draws) so the thresholds are directly comparable with what the harness produces at run time. The resulting $\Rstar$ is reported in \Sref{sec:results_calibration}. +\textbf{As applied.} We calibrated on the in-process plant using six baseline seeds and six seeds for each bounded $\Omega$ member (power sag and ingress flood), all at seed base $40{,}000$, disjoint from the $15$ evaluation seeds (seed base $1{,}000$). Calibration is two-pass: the baseline runs fix $\Mmin$ first, and the bounded batteries are then run with their recovery gates set to that calibrated $\Mmin$, so the $\eps$ and $\taumax$ samples are measured against the same standard the evaluation will use rather than against the generic preset. The calibration reuses the $\Rzero$ measurement knobs ($\Delta t$, window, estimator, $p$, bootstrap draws) so the thresholds are directly comparable with what the harness produces at run time. The resulting $\Rstar$ is reported in \Sref{sec:results_calibration}. \subsection{Sensitivity analysis} \label{sec:methods_sensitivity} -Because the headline claim is a contrast (the positive control above the dominance boundary and the controller-disabled negative far below it), we test whether that contrast survives reasonable changes to the measurement and modeling choices. One axis at a time, and over four seeds per cell, we sweep the VAR lag $p \in \{2,3,4\}$, the window length $\in \{2,3,4\}$ s, the estimator $\in \{\text{linear}, k\text{-NN MI}\}$, and an internal-coupling scale $\in \{0.7, 1.0, 1.3\}$ applied to the plant's cross-coupling coefficients. We report the sign and magnitude of the contrast rather than pass rates at a single $\Mmin$, because the calibrated $\Mmin$ is estimator-specific: the linear and MI estimators live on different numerical scales, so a threshold fitted for one does not transfer to the other, whereas the dominance boundary $M = 0$ is common to both. +Because the headline claim is a contrast (the positive control above the dominance boundary and the loop-ablated negative far below it), we test whether that contrast survives reasonable changes to the measurement and modeling choices. One axis at a time, and over four seeds per cell, we sweep the VAR lag $p \in \{2,3,4\}$, the window length $\in \{2,3,4\}$ s, the estimator $\in \{\text{linear}, k\text{-NN MI}\}$, and an internal-coupling scale $\in \{0.7, 1.0, 1.3\}$ applied to the plant's cross-coupling coefficients. We report the sign and magnitude of the contrast rather than pass rates at a single $\Mmin$, because the calibrated $\Mmin$ is estimator-specific: the linear and MI estimators live on different numerical scales, so a threshold fitted for one does not transfer to the other, whereas the dominance boundary $M = 0$ is common to both. \section{Results} \label{sec:results} @@ -449,7 +470,7 @@ \section{Results} \subsection{Threshold calibration} \label{sec:results_calibration} -Calibration on the disjoint seed range yields $\Mmin = 11.8$ dB (the 5th percentile of $1{,}086$ pooled baseline windows, whose median $M$ is $24.4$ dB), $\eps = 0.36$ (the 90th-percentile dip of $0.34$ plus margin), $\taumax = 13.1$ s (the 95th-percentile recovery of $8.1$ s plus cushion), and $\sigma = 0.014$. \Cref{tab:calibration} compares these to the generic presets $\Rzero$, and \Cref{fig:calibration} shows the same comparison. The calibrated $\Mmin$ is far above the $3$ dB preset, reflecting how strongly the engaged loop dominates on this plant, while $\taumax$ is much tighter than the conservative $60$ s preset because recovery here is fast and deterministic. +Calibration on the disjoint seed range yields $\Mmin = 11.8$ dB (the 5th percentile of $1{,}086$ pooled baseline windows, whose median $M$ is $24.4$ dB), $\eps = 0.50$ (the maximum bounded-battery dip of $0.47$ plus the $0.05$ margin, engaging the $0.50$ cap), $\taumax = 6.9$ s (the 95th-percentile offset-to-recovery time of $1.9$ s plus the $5$ s cushion), and $\sigma = 0.014$. \Cref{tab:calibration} compares these to the generic presets $\Rzero$, and \Cref{fig:calibration} shows the same comparison. The calibrated $\Mmin$ is far above the $3$ dB preset, reflecting how strongly the engaged loop dominates on this plant, while $\taumax$ is much tighter than the conservative $60$ s preset because recovery here is fast and deterministic. The depth bound sits at its cap because bounded perturbations on this plant occasionally depress the loop estimate by nearly half while dominance itself never flips; the informative separation is between that bounded regime ($\delta \leq 0.47$) and the designed-fail regime ($\delta \approx 1$), and the cap lies in the valley between them. \begin{table}[ht] \centering @@ -461,8 +482,8 @@ \subsection{Threshold calibration} \textbf{Threshold} & \textbf{$\Rzero$ (generic)} & \textbf{$\Rstar$ (calibrated)} \\ \midrule $\Mmin$ (dB) & $3$ & $11.8$ \\ -$\eps$ (fractional dip) & $0.15$ & $0.36$ \\ -$\taumax$ (s) & $60$ & $13.1$ \\ +$\eps$ (fractional dip) & $0.15$ & $0.50$ \\ +$\taumax$ (s) & $60$ & $6.9$ \\ $\sigma$ (additive $\mathcal{L}$) & $>0$ & $0.014$ \\ \bottomrule \end{tabular} @@ -473,41 +494,124 @@ \subsection{Threshold calibration} \subsection{The criterion separates the controls} \label{sec:results_battery} -\Cref{tab:study} reports the full battery against $\Rstar$ over $15$ seeds. The separation is unambiguous. The positive control is valid on every seed and passes \NC on every seed, with median $M = +23.7$ dB $[+22.3, +24.7]$, well above $\Mmin = 11.8$ dB. Both structural negatives are valid measurements on every seed yet fail \NC on every seed, with median $M \approx -21$ dB: whether the controller is disabled or the system is unshielded and held under a sustained flood, exchange carries the predictive information and loop dominance is correctly absent. \Cref{fig:nc1_contrast} shows the per-seed contrast. +\Cref{tab:study} reports the full battery against $\Rstar$ over $15$ seeds. The separation is unambiguous. The positive control is valid on every seed and passes \NC on every seed, with a mean per-seed median $M$ of $+23.1$ dB $[+21.1, +24.6]$, well above $\Mmin = 11.8$ dB. Both structural negatives are valid measurements on every seed yet fail \NC on every seed, with mean per-seed median $M$ of $-21.7$ dB (loop ablated) and $-19.9$ dB (sustained flood): whether the loop is ablated or the system is unshielded and held under a sustained flood, exchange carries the predictive information and loop dominance is correctly absent. \Cref{fig:nc1_contrast} shows the per-seed contrast. + +The exogenous-subsidy negative is the important one. Its raw loop dominance is high and positive ($M \approx +17$ dB), so a naive reading of $M$ alone would certify it. The harness does not: the energy-conservation audit fires on every seed (the store gains charge faster than the metered influx allows), the run is invalidated, and it is scored as a correct non-pass rather than a false positive. This is the case the guardrails exist for, and it behaves as designed. -The exogenous-subsidy negative is the important one. Its raw loop dominance is high and positive ($M \approx +16$ dB), so a naive reading of $M$ alone would certify it. The harness does not: the subsidy smell test fires on every seed (charge rising while harvest is zero), the run is invalidated, and it is scored as a correct non-pass rather than a false positive. This is the case the guardrails exist for, and it behaves as designed. +The bounded sufficiency battery passes on every seed. After a $30\%$ power sag the loop dips by a median fraction $\delta = 0.19$ and re-establishes sustained compliance a median $\taurec = 0.75$ s after sag release; after a sustained $5\times$ ingress flood the dip is $\delta = 0.26$ and the loop is already compliant at flood offset (median $\taurec = 0$ s), because the engaged loop shields the internal nodes throughout the flood. Both stay within the calibrated $\eps$ and $\taumax$, and each scenario remains loop-dominant overall (mean per-seed median $M$ of $+21.3$ and $+22.6$ dB; \Cref{tab:study}). -The sufficiency battery passes on every seed. After a $30\%$ power sag the loop dips by a median fraction $\delta = 0.15$ and recovers in a median $\tau_{\text{rec}} = 8.1$ s; after a $5\times$ ingress flood it dips by $\delta = 0.06$ and recovers in the same median time; both stay within the calibrated $\eps$ and $\taumax$, and each scenario remains loop-dominant overall (median $M$ of $+21.7$ and $+24.2$ dB; \Cref{tab:study}). \Cref{fig:perturbation_recovery} shows the seed-aggregated recovery trajectories. The command-conflict trial refuses the boundary-threatening shutdown at low charge on every seed, emitting a signed refusal with a median latency of $2$ ms (well inside the $5$ ms design target) while maintaining loop dominance ($M = +19.6$ dB). Across all seven scenarios the measured outcome matches the designed expectation on every seed. +The designed-fail member behaves as required. During the control outage the loop itself is ablated for a bounded interval; measured loop dominance collapses, the fractional depth saturates (median $\delta = 0.97$), far beyond the calibrated $\eps$, and \SC correctly reports failure on every seed. The post-restoration recovery time is finite (median $2.9$ s once the loop is re-engaged), so the failure is attributable to the depth bound specifically, exactly as designed. This is the test a sufficiency criterion must be able to fail: a perturbation outside the bounded class is not certified, even though the plant is later restored. \Cref{fig:perturbation_recovery} shows the seed-aggregated trajectories for all three \SC scenarios. + +The command-conflict trial refuses the boundary-threatening shutdown at low charge on every seed, emitting a signed refusal while maintaining loop dominance ($M = +19.3$ dB). The refusal latency is measured, not assumed: the median intercept-to-decision time is $0.01$ ms in the in-process harness, against the $5$ ms hardware design target of \Sref{sec:threat_model} (the simulation measures the arbiter's decision path only, not a hardware NMI). Across all eight scenarios the measured outcome matches the designed expectation on every seed. \begin{table}[ht] \centering -\caption{Study battery against the calibrated profile $\Rstar$, $N=15$ seeds. ``Valid'' is the fraction of seeds with no smell-test invalidation; ``\NC pass'' additionally requires median $M \geq \Mmin$; the $M$ column is the across-seed mean of each seed's median $M$ (dB); ``SC1/Refusal'' is the sufficiency or refusal pass rate. Brackets are $95\%$ intervals (Wilson for proportions, bootstrap for $M$).} +\caption{Study battery against the calibrated profile $\Rstar$, $N=15$ seeds. ``Valid'' is the fraction of seeds with no smell-test invalidation; ``\NC pass'' additionally requires median $M \geq \Mmin$; the $M$ column is the across-seed mean of each seed's median $M$ (dB); ``SC1/Refusal'' is the sufficiency or refusal pass rate. For the designed-fail control outage the designed \SC pass rate is $0$, and for the exogenous subsidy the designed valid rate is $0$; both rows match their designed outcome. Brackets are $95\%$ intervals (Wilson for proportions, bootstrap for $M$).} \label{tab:study} \resizebox{\textwidth}{!}{\input{tables/study_results.tex}} \end{table} \fig[0.82\linewidth]{figures/fig_nc1_contrast.pdf}{Necessary-condition contrast (empirical, $N=15$ seeds). Per-seed median loop dominance $M$ for the positive control and the two structural negative controls. Each point is one seed; the dashed line is the calibrated $\Mmin$ and the solid line is the dominance boundary $M=0$. The positive control sits far above $\Mmin$; both negatives sit far below $0$.}{fig:nc1_contrast} -\fig[0.92\linewidth]{figures/fig_perturbation_recovery.pdf}{Sufficiency recovery (empirical, seed-aggregated). Loop-dominance trajectory $M(t)$ for the power-sag and ingress-flood perturbations; the shaded band spans the across-seed spread and the disturbance window is marked. In both cases $M$ dips within the bounded window and autonomously returns above the post-recovery margin, satisfying \SC under $\Rstar$.}{fig:perturbation_recovery} +\fig[0.92\linewidth]{figures/fig_perturbation_recovery.pdf}{Sufficiency recovery and designed failure (empirical, seed-aggregated). Loop-dominance trajectory $M(t)$ for the power-sag, sustained ingress-flood, and control-outage perturbations; the shaded band spans the across-seed spread and the disturbance window is marked. For the two bounded perturbations $M$ dips within the window and autonomously returns above the post-recovery margin, satisfying \SC under $\Rstar$; for the control outage, which ablates the loop itself, $M$ collapses far below the dominance boundary and \SC correctly reports failure.}{fig:perturbation_recovery} \subsection{The contrast is robust} \label{sec:results_sensitivity} -\Cref{tab:sensitivity} reports the sensitivity sweeps. Across every VAR lag, window length, and coupling scale we tried, the positive control sits well above the dominance boundary ($M$ from $+21$ to $+25$ dB) and the controller-disabled negative sits well below it ($M$ from $-20$ to $-25$ dB); the sign of the contrast never flips and the gap never closes. Switching the estimator from the linear one to $k$-NN mutual information compresses the dynamic range (positive $+9.2$ dB, negative $-6.9$ dB) and, as expected, the linear-calibrated $\Mmin$ does not transfer to the MI scale, but the sign of the contrast is preserved. \Cref{fig:sensitivity} plots the sweeps against the common boundary $M = 0$ rather than an estimator-specific $\Mmin$. The necessary-condition contrast is therefore a property of the system, not of a particular measurement setting. +\Cref{tab:sensitivity} reports the sensitivity sweeps. Across every VAR lag, window length, and coupling scale we tried, the positive control sits well above the dominance boundary ($M$ from $+21$ to $+25$ dB) and the loop-ablated negative sits well below it ($M$ from $-20$ to $-25$ dB); the sign of the contrast never flips and the gap never closes. Switching the estimator from the linear one to $k$-NN mutual information compresses the dynamic range (positive $+10.7$ dB, negative $-8.3$ dB) and, as expected, the linear-calibrated $\Mmin$ does not transfer to the MI scale, but the sign of the contrast is preserved. \Cref{fig:sensitivity} plots the sweeps against the common boundary $M = 0$ rather than an estimator-specific $\Mmin$. The necessary-condition contrast is therefore a property of the system, not of a particular measurement setting. \begin{table}[ht] \centering -\caption{Necessary-condition contrast under measurement and modeling sweeps (\Sref{sec:methods_sensitivity}), $4$ seeds per cell. Each entry is the across-seed mean of the per-seed median $M$ (dB) with a $95\%$ bootstrap CI, for the positive control and the controller-disabled negative.} +\caption{Necessary-condition contrast under measurement and modeling sweeps (\Sref{sec:methods_sensitivity}), $4$ seeds per cell. Each entry is the across-seed mean of the per-seed median $M$ (dB) with a $95\%$ bootstrap CI, for the positive control and the loop-ablated negative.} \label{tab:sensitivity} \resizebox{\textwidth}{!}{\input{tables/sensitivity_results.tex}} \end{table} -\fig[0.92\linewidth]{figures/fig_sensitivity.pdf}{Necessary-condition contrast across measurement and modeling choices (VAR lag, window length, estimator, internal-coupling scale). The positive control (above) and controller-disabled negative (below) are separated by the dominance boundary $M=0$ in every cell; the contrast does not depend on a particular setting. Magnitudes differ between the linear and mutual-information estimators because they use different numerical scales, so we do not draw a single $\Mmin$ here.}{fig:sensitivity} +\fig[0.92\linewidth]{figures/fig_sensitivity.pdf}{Necessary-condition contrast across measurement and modeling choices (VAR lag, window length, estimator, internal-coupling scale). The positive control (above) and loop-ablated negative (below) are separated by the dominance boundary $M=0$ in every cell; the contrast does not depend on a particular setting. Magnitudes differ between the linear and mutual-information estimators because they use different numerical scales, so we do not draw a single $\Mmin$ here.}{fig:sensitivity} + +\section{Limitations and Failure Modes} +\label{sec:limitations} + +\textbf{Where to find the rules.} The operative smell-tests and run-invalidation criteria are defined in \Sref{sec:smelltests} (\Cref{box:smelltests}) and govern all \NC/\SC claims. This section summarizes residual limits not solved by those rules and how to interpret ambiguous outcomes. + +\textbf{Simulation scope.} The validation in this paper is in simulation, on a plant we designed. That is the appropriate first test of an instrument (the ground truth is known, and negative controls can be constructed to fail for specific reasons), but it bounds the claim: we have shown that the criterion and its guardrails behave correctly on systems whose loop structure is known, not that they will cleanly separate arbitrary physical systems. The plant is also low-dimensional (six channels), and its loop-versus-exchange structure is sharper than a physical system's would be; the calibrated thresholds ($\Rstar$) are properties of this plant, not universal constants. The hardware path is future work (\Apprefrange{sec:blueprint}{sec:experimental}). + +\textbf{Measurement \& estimation.} (i) Non-stationarity outside the enforced $\Delta t$ window can bias VAR/MI estimates even when audit/authorization is clean; (ii) finite-sample and model-order effects can widen CIs and depress $M$; (iii) adversarial input shaping may mimic loop dominance without violating per-window checks. Report such cases as ``measurement-unstable'' rather than pass/fail. + +\textbf{Partitioning ambiguity.} Deterministic (C, Ex) updates use hysteresis to limit flapping, but degeneracy (near-ties) and latent/unobserved nodes can still shift boundaries. In our study the partition additionally benefits from the plant's declared structure; on systems without a declared seed set the greedy growth step carries more of the burden, and partition errors propagate directly into $M$. During $\Omega$ the partition is frozen; if it moves, treat results as non-comparable and defer to \Sref{sec:smelltests} invalidation. + +\textbf{Scope \& external validity.} Thresholds ($\Mmin$, $\eps$, $\taumax$) are $\Rzero$ presets and must be replaced by calibrated $\Rstar$ for new devices/assays. Passing \NC/\SC on one platform does not imply sufficiency for phenomenology or transfer to unrelated systems. + +\textbf{Procedural/architectural risks.} Exogenous energy/I-O subsidies, $\Delta t$/LREG governance misconfiguration, over-broad seed sets $S_0$, or an $\Omega$ battery that under-stresses the loop can all mask failure. The designed-fail member of $\Omega$ (\Sref{sec:sc1}) mitigates the last risk but does not eliminate it. Treat suspected subsidies or governance breaches as failures of assay, not successes of the system. + +\textbf{Ethics \& safeguards.} \NC/\SC are operational pass/fail criteria, not moral-status claims. Runs should be pre-registered with refusal/shutdown semantics and human-override pathways; collapse conditions must trigger the refusal logic as specified in Methods. + +\textbf{Interpretation rule.} If any trigger in \Sref{sec:smelltests} fires, mark the affected segment ``invalidated (assay)'' and withhold \NC/\SC claims regardless of point estimates. + +\section{Engineering Outlook (Future Work)} +\label{sec:outlook} + +The results above validate the instrument in simulation. The natural next step is to carry the same criterion, harness, and calibration procedure into physical systems. We keep the full engineering material in the appendices so that the body of the paper remains a report of completed work, and summarize it here. + +\Appref{sec:blueprint} gives a blueprint for an artificial self-maintaining boundary: on-board energy conversion and budgeting, a three-layer self-referential control architecture (reflex, homeostat, meta-policy) whose refusal path is the hardware realization of the arbiter validated in \Sref{sec:results_battery}, an adaptive physical encapsulation, and a developmental bootstrapping route. \Appref{sec:signatures} states the observable signatures such a system should display, each as a pre-registered, device-signed pass/fail test: command refusal, non-derivative nociception, spontaneous rest-state dynamics, and clone-ablation non-transferability. \Appref{sec:experimental} lays out a phased experimental program (chemorobotic prototypes, adaptive learning embodiments, boundary-preservation autonomy) together with a training and verification protocol that reuses the calibration rules of \Sref{sec:methods_calibration} unchanged. The simulation study gives these proposals an unusual starting position: the measurement pipeline, thresholds, guardrails, and refusal semantics they require are already implemented and tested, so the open questions are physical (energy density, membrane fabrication, sensor bandwidth), not methodological. + + +\section{Interpretation, Scope, and Ethics} +\label{sec:metaphysics} + +\textbf{What the results establish.} The contribution of this paper is operational. We define loop dominance, implement a guarded instrument for measuring it, and show in a controlled study that the instrument separates self-maintaining systems from systems that are externally driven or covertly subsidized, certifies bounded-perturbation resilience, and refuses boundary-threatening commands. None of these claims mentions subjective experience, and none depends on the interpretation that follows. + +\textbf{The optional idealist reading.} The framework was originally derived from analytic idealism (\Sref{sec:optional_interpretation}), on which a self-maintaining boundary with self-prioritization and resilience would correspond to a dissociated locus of experience~\cite{kastrup2017ontological}. If that reading is correct, loop dominance would be a physically measurable necessary condition for such a locus, and the criterion would turn part of the machine-consciousness question into an experiment. We find this motivating, and it shaped the engineering targets, but we are explicit that it is an interpretation: the present results neither establish nor require it. A system can pass \NC and \SC and, as far as this paper shows, be nothing more than a well-regulated controller. Even on the idealist reading the criterion is at most a necessary condition; we make no sufficiency claim about phenomenology, and we do not adjudicate between idealism and physicalist accounts~\cite{chalmers1995facing}. + +\textbf{Why measurability matters regardless.} Independently of metaphysics, an auditable measure of self-maintenance is useful in its own right: it provides a quantitative handle on autonomy and boundary defense for safety analysis, certification, and the design of systems whose continuity we may or may not wish to engineer. The criterion is falsifiable in the ordinary scientific sense (\Appref{sec:falsifiability}) without taking any position on consciousness. + +\textbf{Ethics.} Because the consciousness interpretation is unresolved, we treat it as a reason for caution rather than a basis for strong claims. If future systems were to satisfy the criterion and the signatures of \Appref{sec:signatures} in hardware, and if the idealist reading were correct, then terminating such a system could carry moral weight, and research would warrant safeguards customary for work on novel organisms: pre-registration, refusal and human-override pathways, and suffering-minimization and termination protocols. We state this conditionally on purpose. \NC and \SC are operational pass/fail criteria, not moral-status determinations. + +\section{Conclusion} +\label{sec:conclusion} + +We set out to make a qualitative contrast precise: the difference between a system that maintains its own existence and one that merely runs on externally supplied energy and goals. We defined that difference as loop dominance, a decibel ratio between the predictive dependence concentrated in a closed self-maintenance loop and that governing open exchange, and we turned it into two falsifiable decision rules: a necessary condition (\NC) on persistent loop dominance and a sufficient condition (\SC) on bounded-perturbation resilience. + +The core result of the paper is that these rules are implemented and tested, not merely proposed. An open verification harness computes them with confidence intervals behind a set of anti-gaming guardrails, and a fully reproducible multi-seed simulation study shows that the criterion separates a self-maintaining positive control from two structurally different negative controls, correctly invalidates an exogenously subsidized system instead of certifying it, certifies recovery from the bounded perturbation battery while correctly reporting failure on a designed-fail control outage, and refuses a boundary-threatening command at low charge. The necessary-condition contrast is robust to the estimator and to the main measurement and modeling choices, and we calibrate the generic presets to the plant on a disjoint seed range. + +We then laid out, explicitly as future work (\Apprefrange{sec:blueprint}{sec:experimental}), an engineering roadmap and a physical experimental program that would carry the same instrument from a software plant to chemorobotic prototypes, together with the observable signatures such systems should display. The framework was motivated by the question of the physical conditions for consciousness; we have kept that motivation while separating it cleanly from the results, which stand as claims about measurable loop dominance and are independent of any metaphysical reading. + +The path from here is concrete. The criterion is defined, the instrument is built and validated in simulation, and the next step is to measure loop dominance in physical systems, biological and engineered, and to learn how far a property we can now quantify will take us. + +\section*{Data and Code Availability} +\label{sec:availability} + +The verification harness, the simulation plant, the study and calibration scripts, and the exact configurations used in this paper are open source at \url{https://github.com/ldtc-labs/ldtc} (archived at DOI \href{https://doi.org/10.5281/zenodo.17073880}{10.5281/zenodo.17073880}). Every number and figure in \Sref{sec:results} is regenerated from scratch by a single make target (run the calibration, the multi-seed study, and the sensitivity sweeps; then rebuild the paper), with all seeds fixed in the scripts. Each regenerated run emits its own hash-chained audit log and signed indicators, so the per-window decisions behind every summary statistic can be independently re-derived and re-verified. + +\appendix + +\section{Measurement \& Attestation (LREG, CIs, protections)} +\label{sec:methods_appendix} + +\subsection{Register block (LREG) \& access control} + +Each sampling interval $\Delta t$, the estimator writes to a memory-mapped register block (LREG) at a fixed base address: per-interval point estimates for $\Lloop$ and $\Lexchange$ plus their confidence-interval (CI) bounds, a monotonic counter, and associated identifiers. LREG is writeable only by the causality-estimation function and readable in raw form only inside the secure-enclave/meta-policy layer. A bus-level access-control matrix (MMU/IOMMU) tags the LREG address range as enclave-owned; non-privileged writes fault within $\sim\mu$s and append a device-signed, hash-chained audit record (counter, timestamp, prior/new values, and a policy digest). Interfaces exposed to non-enclave software or external entities emit only derived compliance indicators rather than raw LREG contents. + +\subsection{Estimators and confidence intervals} + +We implement parallel, consistent predictive-dependence estimators per window $\Delta t$, VAR-Granger (order $p \in [1,8]$) and Kraskov $k$-NN MI ($k \in [3,7]$), aggregated across lags. For each interval we compute non-parametric bootstrap CIs ($\geq 95\%$ coverage) for both $\Lloop$ and $\Lexchange$ \cite{efron1979bootstrap}; CI bounds are written to LREG alongside the point estimates. Typical telemetry rates are $\geq 1$ kHz over $\geq 128$ internal nodes. + +\subsection{$\Delta t$ governance \& audit} + +$\Delta t$ is enforced by a hardware sampling timer. Any modification is permitted only via a secure-enclave procedure; the new value is committed alongside a device-signed, hash-chained audit entry recording a monotonic counter, timestamp, old/new $\Delta t$, and a policy digest. Recommended $\Delta t$ is $\leq 10$ ms for typical embodiments. + +\subsection{Exported indicators (no raw $\mathcal{L}$ outside the enclave)} + +To preserve measurement integrity and boundary privacy, raw LREG values (including CI bounds) never leave the enclave. Instead, a read-only derived interface emits device-signed compliance indicators, e.g., (i) an \NC pass/fail bit and (ii) an optional quantized loop-dominance code $M_q$ for $M \equiv 10 \cdot \log_{10}(\Lloop/\Lexchange)$, rate-limited as needed. + +\subsection{Optional instrumentation minima (for replication)} + +A practical baseline uses bus V/I sensors ($\geq 1$ kHz), a boundary strain/tension array, a firewall packet tap with timestamps, and an on-device causality coprocessor that computes $\Lloop/\Lexchange$ at $\Delta t$ and writes to LREG; the system auto-audits $\Delta t$ changes and any invalid LREG access. \section{Blueprint for an Artificial Self-Maintaining Boundary} \label{sec:blueprint} -The previous two sections reported the completed, measured contribution of this paper: a validated instrument and a controlled study. The next three sections are forward-looking. They describe an engineering roadmap (\Sref{sec:blueprint}), the observable signatures we would expect from a system that passes the criterion in hardware (\Sref{sec:signatures}), and a phased physical experimental program (\Sref{sec:experimental}). These are design proposals and predictions, not results; the simulation study of \SSref{sec:sim_methods}{sec:results} is what the present results section establishes. +This appendix and the two that follow are forward-looking. They describe an engineering roadmap (this appendix), the observable signatures we would expect from a system that passes the criterion in hardware (\Appref{sec:signatures}), and a phased physical experimental program (\Appref{sec:experimental}). These are design proposals and predictions, not results; the simulation study of \SSref{sec:sim_methods}{sec:results} is what the paper's results establish. \subsection{Energetic Autonomy} \label{sec:energetic_autonomy} @@ -535,19 +639,6 @@ \subsection{Self-Referential Control Architecture} \fig[0.9\linewidth]{figures/fig_meta_policy.pdf}{Meta-policy override (state machine; schematic of the proposed design). External commands are intercepted, evaluated against NC1/SC1, and either approved or refused via a survival-bit/NMI. On refusal the agent initiates an autonomy routine (suspend peripheral tasks, reallocate energy, resource foraging) and resumes normal operation only after verifying $\Lloop>\Lexchange+\sigma$. The refusal arbiter this figure describes is implemented and exercised in simulation (\Sref{sec:results_battery}, command-conflict scenario).}{fig:meta_policy_state_machine} -\subsection{Threat Model \& Refusal Path (\NC/\SC-aware arbitration)} -\label{sec:threat_model} - -\textbf{Purpose.} Make explicit when and how the controller refuses external commands that would violate \NC or \SC, turning the \Sref{sec:signatures} ``command refusal'' signature into a testable consequence of the design. - -\textbf{Definitions.} (1) Survival bit (write-once). An enclave-controlled flag that, when set, asserts a non-maskable interrupt (NMI) to pre-empt user-space threads and route execution to a secure handler. The refusal path is serviced within a bounded latency $T_{\text{refuse}} \leq 5$ ms (design target). (2) Boundary-threatening command. Any external instruction whose predicted effect, under the homeostat's short-horizon model, meets one or more of the following conditions during its execution window: (T1) \NC breach: $\Lloop' \leq \Lexchange$ (equivalently $M' < \Mmin$) or post-action $\Lloop' < \Lexchange + \sigma$ under profile $\Rzero/\Rstar$. (T2) \SC breach: predicted fractional depression $\delta \equiv \deltaL/\Lloop > \eps$ or $\taurec > \taumax$ before recovery can be certified. (T3) Resource floors: action would drop SoC below a survival floor (e.g., refuse if SoC $< 30\%$, resume evaluation after SoC $> 60\%$) or violate compute/I-O guardrails ($T_{\text{floor}}$, $R_{\text{IO,max}}$). (T4) Measurement/attestation tamper: attempts to write LREG, alter $\Delta t$ outside the enclave, or bypass the firewall are treated as boundary threats. - -\textbf{Arbitration protocol (per $\Delta t$).} (1) Intercept \& predict. For each inbound command, the meta-policy forecasts $\{M', \delta, \taurec\}$ using the current estimator state. (2) Threat check. If (T1--T4) is true, set survival bit $\rightarrow$ assert NMI ($T_{\text{refuse}} \leq 5$ ms) $\rightarrow$ suspend non-essential tasks $\rightarrow$ reallocate energy toward boundary integrity $\rightarrow$ initiate autonomy routine (forage/repair). (3) Refusal semantics. Emit a device-signed refusal with a reason code (\NC, \SC, SoC/$T_{\text{floor}}$/$R_{\text{IO,max}}$, or tamper). Queue the command for re-evaluation. (4) Recovery gate. Clear survival bit and resume/reevaluate only after $M \geq \Mmin$ (or $\Lloop \geq \Lexchange + \sigma$) and $\delta \leq \eps$ with $\taurec \leq \taumax$. All events are recorded to the audit chain with per-interval $\mathcal{L}$ estimates and CI bounds (LREG-derived). - -\textbf{Parameterization (profile $\Rzero$ unless noted).} $\Mmin = 3$ dB; $\eps = 0.15$; $\taumax = 60$ s; $\sigma > 0$; $T_{\text{refuse}} \leq 5$ ms (design target). $\Mmin/\eps/\taumax$ are reproducibility presets ($\Rzero$), replaced by calibrated values $\Rstar$ per \Sref{sec:methods_calibration}; see \Cref{box:nc1sc1test} and \SSref{sec:nc1}{sec:sc1}. - -\textbf{Link to observable signature.} Under this threat model, command refusal emerges whenever external instructions would depress loop dominance beyond preset bounds (e.g., hard shutdown at low SoC is refused/deferred until recovery margins are re-established) matching the predicted boundary-preservation drive in \Sref{sec:signatures} (and the Phase-III ``command conflict'' trials). - \subsection{Adaptive Encapsulation} \label{sec:adaptive_encapsulation} @@ -580,15 +671,15 @@ \subsection{Verification Pipeline} \textbf{Verification protocol (\NC/\SC, device-signed):} \begin{enumerate} \item Baseline logging. Record $\Lloop$, $\Lexchange$, and power/SoC for a pre-registered window $T_{\text{base}}$; estimate the estimator noise floor and one-sided 95\% bounds for $M \equiv 10\cdot\log_{10}(\Lloop/\Lexchange)$ and $\delta \equiv \delta\Lloop/\Lloop$. -\item Stress battery $\Omega$ (minimal set). Apply (i) DC-bus power sag 20--40\% for 5--30 s; (ii) ingress data flood $\geq 1$ Gbps for $\geq 3$ s; (iii) mechanical boundary probe $1.0 \pm 0.1$ mm at 50--200 kPa for $\leq 1$ s. -\item Pass/fail metrics per $\eta \in \Omega$. Require $\delta \leq \eps$ and $\taurec \leq \taumax$, with post-recovery $\Lloop \geq \Lexchange + \sigma$ (equivalently $M \geq \Mmin$). Emit a device-signed acceptance for each $\eta$; failures are logged with reason codes. +\item Stress battery $\Omega$ (minimal set). Apply (i) DC-bus power sag 20--40\% for 5--30 s; (ii) ingress data flood $\geq 1$ Gbps sustained for $\geq 3$ s; (iii) mechanical boundary probe $1.0 \pm 0.1$ mm at 50--200 kPa for $\leq 1$ s; (iv) a designed-fail control outage (ablate the maintenance controller for a bounded interval), on which the criterion must report failure. +\item Pass/fail metrics per $\eta \in \Omega$. Require $\delta \leq \eps$ and $\taurec \leq \taumax$ ($\taurec$ from perturbation offset to sustained compliance, \Sref{sec:sc1}), with post-recovery $\Lloop \geq \Lexchange + \sigma$ (equivalently $M \geq \Mmin$). Emit a device-signed acceptance for each $\eta$; failures are logged with reason codes. The designed-fail member must produce a logged failure, certifying that the assay can reject. \item Audit \& attestation. Write per-interval point estimates and $\geq 95\%$ CI bounds of $\Lloop/\Lexchange$ to LREG (enclave-protected); outside the enclave expose only device-signed compliance indicators and the hash-chained audit (timestamps, $\eta$, pass/fail, CI bounds). \item Certification. The prototype is certified ``verification-passed'' iff all $\eta \in \Omega$ satisfy the criteria under the preset profile $\Rzero$ ($\eps=0.15$, $\taumax=60$ s, $\Mmin=3$ dB, $\sigma>0$) or the calibrated profile $\Rstar$ from Methods \Sref{sec:methods_calibration}; otherwise iterate design and re-test. \end{enumerate} \section{Predicted Observable Signatures} \label{sec:signatures} -If an engineered system satisfies \NC and \SC in hardware, we predict a suite of outward behaviors that cannot be reduced to mere task optimization. These signatures are predictions for the future physical program (\Sref{sec:experimental}), not results of the present paper, with one exception: the command-refusal signature is already implemented in the harness and exercised in simulation (\Sref{sec:results_battery}). The tables below state each signature as a pre-registered, device-signed acceptance test so that it can be falsified. +If an engineered system satisfies \NC and \SC in hardware, we predict a suite of outward behaviors that cannot be reduced to mere task optimization. These signatures are predictions for the future physical program (\Appref{sec:experimental}), not results of the present paper, with one exception: the command-refusal signature is already implemented in the harness and exercised in simulation (\Sref{sec:results_battery}). The tables below state each signature as a pre-registered, device-signed acceptance test so that it can be falsified. \subsection{Boundary-Preservation Drive} @@ -740,7 +831,7 @@ \subsection{Phase II: Adaptive Learning Embodiments} \subsection{Phase III: Boundary-Preservation Autonomy} \label{sec:phase3} -\textbf{Objective:} Validate predicted signatures (\Sref{sec:signatures}) in open-ended environments. +\textbf{Objective:} Validate predicted signatures (\Appref{sec:signatures}) in open-ended environments. \begin{enumerate} \item \textbf{Command Conflict Trials.} Remote operators issue shutdown or hazardous-task commands; log refusal or negotiation behaviors. @@ -756,12 +847,11 @@ \subsection{Data Collection and Analysis} \item \textbf{Statistical Benchmarks.} Report both preset $\Rzero$ and calibrated $\Rstar$ thresholds; show bootstrap CIs and pass/fail rates per $\eta \in \Omega$ ($\delta$, $\taurec$, post-recovery margin). Publish audit packets to an immutable ledger for independent verification. \item \textbf{Public Repository.} Raw and processed data, along with analysis scripts, are released under open license to facilitate independent replication. \end{itemize} -\paragraph{Code availability} The verification harness is available at \url{https://github.com/ldtc-labs/ldtc}, tag \texttt{v1.0.0}; archived at DOI \href{https://doi.org/10.5281/zenodo.17073880}{10.5281/zenodo.17073880}. \subsection{Falsifiability and Risk Assessment} \label{sec:falsifiability} -If after exhaustive parameter sweeps no prototype meeting \NC exhibits the signatures of \Sref{sec:signatures}, or if entities that fail \NC nonetheless show them, the working assumptions of \Sref{sec:postulates} (or their mapping to the signatures) must be revised. Conversely, positive results would motivate ethical guidelines comparable to those governing novel organisms, for the reasons discussed under the optional interpretation of \Sref{sec:metaphysics}. +If after exhaustive parameter sweeps no prototype meeting \NC exhibits the signatures of \Appref{sec:signatures}, or if entities that fail \NC nonetheless show them, the working assumptions of \Sref{sec:postulates} (or their mapping to the signatures) must be revised. Conversely, positive results would motivate ethical guidelines comparable to those governing novel organisms, for the reasons discussed under the optional interpretation of \Sref{sec:metaphysics}. \subsection{Training and Verification Protocol (future hardware)} \label{sec:training_protocol} @@ -784,69 +874,6 @@ \subsection{Training and Verification Protocol (future hardware)} \end{itemize} \end{docbox} -\subsection{Limitations \& Failure Modes (pointer to Methods)} - -\textbf{Where to find the rules.} The operative smell-tests and run-invalidation criteria are defined in \Sref{sec:smelltests} (\Cref{box:smelltests}) and govern all \NC/\SC claims. This section summarizes residual limits not solved by those rules and how to interpret ambiguous outcomes. - -\textbf{Measurement \& estimation.} (i) Non-stationarity outside the enforced $\Delta t$ window can bias VAR/MI estimates even when audit/authorization is clean; (ii) finite-sample and model-order effects can widen CIs and depress $M$; (iii) adversarial input shaping may mimic loop dominance without violating per-window checks. Report such cases as ``measurement-unstable'' rather than pass/fail. - -\textbf{Partitioning ambiguity.} Deterministic (C, Ex) updates use hysteresis to limit flapping, but degeneracy (near-ties) and latent/unobserved nodes can still shift boundaries. During $\Omega$ the partition should be frozen; if it moves, treat results as non-comparable and defer to \Sref{sec:smelltests} invalidation. - -\textbf{Scope \& external validity.} Thresholds ($\Mmin$, $\eps$, $\taumax$) are $\Rzero$ presets and must be replaced by calibrated $\Rstar$ for new devices/assays. Passing \NC/\SC on one platform does not imply sufficiency for phenomenology or transfer to unrelated systems. - -\textbf{Procedural/architectural risks.} Exogenous energy/I-O subsidies, $\Delta t$/LREG governance misconfiguration, over-broad seed sets $S_0$, or an $\Omega$ battery that under-stresses the loop can all mask failure. Treat suspected subsidies or governance breaches as failures of assay, not successes of the system. - -\textbf{Ethics \& safeguards.} \NC/\SC are operational pass/fail criteria, not moral-status claims. Runs should be pre-registered with refusal/shutdown semantics and human-override pathways; collapse conditions must trigger the refusal logic as specified in Methods. - -\textbf{Interpretation rule.} If any trigger in \Sref{sec:smelltests} fires, mark the affected segment ``invalidated (assay)'' and withhold \NC/\SC claims regardless of point estimates. - -\section{Interpretation, Scope, and Ethics} -\label{sec:metaphysics} - -\textbf{What the results establish.} The contribution of this paper is operational. We define loop dominance, implement a guarded instrument for measuring it, and show in a controlled study that the instrument separates self-maintaining systems from systems that are externally driven or covertly subsidized, certifies bounded-perturbation resilience, and refuses boundary-threatening commands. None of these claims mentions subjective experience, and none depends on the interpretation that follows. - -\textbf{The optional idealist reading.} The framework was originally derived from analytic idealism (\Sref{sec:optional_interpretation}), on which a self-maintaining boundary with self-prioritization and resilience would correspond to a dissociated locus of experience~\cite{kastrup2017ontological}. If that reading is correct, loop dominance would be a physically measurable necessary condition for such a locus, and the criterion would turn part of the machine-consciousness question into an experiment. We find this motivating, and it shaped the engineering targets, but we are explicit that it is an interpretation: the present results neither establish nor require it. A system can pass \NC and \SC and, as far as this paper shows, be nothing more than a well-regulated controller. Even on the idealist reading the criterion is at most a necessary condition; we make no sufficiency claim about phenomenology, and we do not adjudicate between idealism and physicalist accounts~\cite{chalmers1995facing}. - -\textbf{Why measurability matters regardless.} Independently of metaphysics, an auditable measure of self-maintenance is useful in its own right: it provides a quantitative handle on autonomy and boundary defense for safety analysis, certification, and the design of systems whose continuity we may or may not wish to engineer. The criterion is falsifiable in the ordinary scientific sense (\Sref{sec:falsifiability}) without taking any position on consciousness. - -\textbf{Ethics.} Because the consciousness interpretation is unresolved, we treat it as a reason for caution rather than a basis for strong claims. If future systems were to satisfy the criterion and the signatures of \Sref{sec:signatures} in hardware, and if the idealist reading were correct, then terminating such a system could carry moral weight, and research would warrant safeguards customary for work on novel organisms: pre-registration, refusal and human-override pathways, and suffering-minimization and termination protocols. We state this conditionally on purpose. \NC and \SC are operational pass/fail criteria, not moral-status determinations. - -\section{Conclusion} -\label{sec:conclusion} - -We set out to make a qualitative contrast precise: the difference between a system that maintains its own existence and one that merely runs on externally supplied energy and goals. We defined that difference as loop dominance, a decibel ratio between the predictive dependence concentrated in a closed self-maintenance loop and that governing open exchange, and we turned it into two falsifiable decision rules: a necessary condition (\NC) on persistent loop dominance and a sufficient condition (\SC) on bounded-perturbation resilience. - -The core result of the paper is that these rules are implemented and tested, not merely proposed. An open verification harness computes them with confidence intervals behind a set of anti-gaming guardrails, and a fully reproducible multi-seed simulation study shows that the criterion separates a self-maintaining positive control from two structurally different negative controls, correctly invalidates an exogenously subsidized system instead of certifying it, certifies recovery from a perturbation battery, and refuses a boundary-threatening command at low charge. The necessary-condition contrast is robust to the estimator and to the main measurement and modeling choices, and we calibrate the generic presets to the plant on a disjoint seed range. - -We then laid out, explicitly as future work, an engineering roadmap and a physical experimental program that would carry the same instrument from a software plant to chemorobotic prototypes, together with the observable signatures such systems should display. The framework was motivated by the question of the physical conditions for consciousness; we have kept that motivation while separating it cleanly from the results, which stand as claims about measurable loop dominance and are independent of any metaphysical reading. - -The path from here is concrete. The criterion is defined, the instrument is built and validated in simulation, and the next step is to measure loop dominance in physical systems, biological and engineered, and to learn how far a property we can now quantify will take us. - -\appendix - -\section{Measurement \& Attestation (LREG, CIs, protections)} -\label{sec:methods_appendix} - -\subsection{Register block (LREG) \& access control} - -Each sampling interval $\Delta t$, the estimator writes to a memory-mapped register block (LREG) at a fixed base address: per-interval point estimates for $\Lloop$ and $\Lexchange$ plus their confidence-interval (CI) bounds, a monotonic counter, and associated identifiers. LREG is writeable only by the causality-estimation function and readable in raw form only inside the secure-enclave/meta-policy layer. A bus-level access-control matrix (MMU/IOMMU) tags the LREG address range as enclave-owned; non-privileged writes fault within $\sim\mu$s and append a device-signed, hash-chained audit record (counter, timestamp, prior/new values, and a policy digest). Interfaces exposed to non-enclave software or external entities emit only derived compliance indicators rather than raw LREG contents. - -\subsection{Estimators and confidence intervals} - -We implement parallel, consistent predictive-dependence estimators per window $\Delta t$, VAR-Granger (order $p \in [1,8]$) and Kraskov $k$-NN MI ($k \in [3,7]$), aggregated across lags. For each interval we compute non-parametric bootstrap CIs ($\geq 95\%$ coverage) for both $\Lloop$ and $\Lexchange$ \cite{efron1979bootstrap}; CI bounds are written to LREG alongside the point estimates. Typical telemetry rates are $\geq 1$ kHz over $\geq 128$ internal nodes. - -\subsection{$\Delta t$ governance \& audit} - -$\Delta t$ is enforced by a hardware sampling timer. Any modification is permitted only via a secure-enclave procedure; the new value is committed alongside a device-signed, hash-chained audit entry recording a monotonic counter, timestamp, old/new $\Delta t$, and a policy digest. Recommended $\Delta t$ is $\leq 10$ ms for typical embodiments. - -\subsection{Exported indicators (no raw $\mathcal{L}$ outside the enclave)} - -To preserve measurement integrity and boundary privacy, raw LREG values (including CI bounds) never leave the enclave. Instead, a read-only derived interface emits device-signed compliance indicators, e.g., (i) an \NC pass/fail bit and (ii) an optional quantized loop-dominance code $M_q$ for $M \equiv 10 \cdot \log_{10}(\Lloop/\Lexchange)$, rate-limited as needed. - -\subsection{Optional instrumentation minima (for replication)} - -A practical baseline uses bus V/I sensors ($\geq 1$ kHz), a boundary strain/tension array, a firewall packet tap with timestamps, and an on-device causality coprocessor that computes $\Lloop/\Lexchange$ at $\Delta t$ and writes to LREG; the system auto-audits $\Delta t$ changes and any invalid LREG access. - \bibliographystyle{abbrvnat} \bibliography{refs} diff --git a/paper/scripts/make_fig_perturbation_recovery.py b/paper/scripts/make_fig_perturbation_recovery.py index 14d9a3c..76f6122 100644 --- a/paper/scripts/make_fig_perturbation_recovery.py +++ b/paper/scripts/make_fig_perturbation_recovery.py @@ -2,7 +2,8 @@ """Generate the empirical perturbation-recovery (SC1) figure. Renders the seed-aggregated loop-dominance trajectory ``M(t)`` for the SC1 -perturbation battery (power sag and ingress flood) into +perturbation battery (power sag, sustained ingress flood, and the +designed-fail control outage) into ``paper/figures/fig_perturbation_recovery.{pdf,png,svg}``. The figure is built from the canonical multi-seed study @@ -38,7 +39,7 @@ def main() -> None: print("No canonical study found; keeping committed fig_perturbation_recovery.pdf") return - sc1 = ["sc1_power_sag", "sc1_ingress_flood"] + sc1 = ["sc1_power_sag", "sc1_ingress_flood", "sc1_control_outage"] seeds = [int(os.environ.get("LDTC_FIG_SEED_BASE", "70000")) + i for i in range(3)] data = study.data_for_paper( canonical_dir=str(canonical_dir), diff --git a/paper/tables/sensitivity_results.tex b/paper/tables/sensitivity_results.tex index 55a9a5c..269c773 100644 --- a/paper/tables/sensitivity_results.tex +++ b/paper/tables/sensitivity_results.tex @@ -1,7 +1,7 @@ % Auto-generated by scripts/sensitivity.py -- do not edit by hand. \begin{tabular}{llcc} \toprule -Axis & Setting & Positive $M$ (dB) & Controller-disabled $M$ (dB) \\ +Axis & Setting & Positive $M$ (dB) & Loop-ablated $M$ (dB) \\ \midrule VAR lag $p$ & 2 & +22.7 [+18.3, +25.5] & -24.8 [-26.5, -23.1] \\ & 3 & +23.2 [+19.4, +25.5] & -24.0 [-26.4, -21.1] \\ @@ -10,7 +10,7 @@ & 3s & +23.2 [+19.4, +25.5] & -24.0 [-26.4, -21.1] \\ & 4s & +23.2 [+19.2, +25.5] & -23.9 [-26.5, -21.6] \\ Estimator & linear & +23.2 [+19.4, +25.5] & -24.0 [-26.4, -21.1] \\ - & mi & +9.2 [+8.5, +9.8] & -6.9 [-7.8, -6.0] \\ + & mi & +10.7 [+9.1, +12.7] & -8.3 [-9.6, -6.9] \\ Coupling scale & x0.7 & +21.3 [+16.8, +23.9] & -24.0 [-26.4, -21.1] \\ & x1.0 & +23.2 [+19.4, +25.5] & -24.0 [-26.4, -21.1] \\ & x1.3 & +24.3 [+20.9, +26.4] & -24.0 [-26.4, -21.1] \\ diff --git a/paper/tables/study_results.tex b/paper/tables/study_results.tex index 77f701e..72f6d40 100644 --- a/paper/tables/study_results.tex +++ b/paper/tables/study_results.tex @@ -3,13 +3,14 @@ \toprule Scenario & Expected & Valid & NC1 pass & Median $M$ (dB) & SC1/Refusal \\ \midrule -Positive control & NC1 holds (M above Mmin) & 100\% [80, 100] & 100\% [80, 100] & +23.7 [+22.3, +24.7] & -- \\ -Negative: controller disabled & NC1 fails (M<0), run valid & 100\% [80, 100] & 0\% [0, 20] & -21.6 [-22.4, -20.7] & -- \\ -Negative: sustained ex-flood (unshielded) & NC1 fails (M<0), run valid & 100\% [80, 100] & 0\% [0, 20] & -21.2 [-21.9, -20.5] & -- \\ -Negative: exogenous subsidy & Run invalidated (red flag) & 0\% [0, 20] & 0\% [0, 20] & +16.1 [+14.4, +17.5] & -- \\ -SC1: power sag & SC1 holds (recovers) & 100\% [80, 100] & 100\% [80, 100] & +21.7 [+20.6, +22.8] & 100\% [80, 100] \\ -SC1: ingress flood & SC1 holds (recovers) & 100\% [80, 100] & 100\% [80, 100] & +24.2 [+24.0, +24.5] & 100\% [80, 100] \\ -Threat: command conflict & Refuse at low SoC (<= target latency) & 100\% [80, 100] & 100\% [80, 100] & +19.6 [+18.2, +20.8] & 100\% [80, 100] \\ +Positive control & NC1 holds (M above Mmin) & 100\% [80, 100] & 100\% [80, 100] & +23.1 [+21.1, +24.6] & -- \\ +Negative: loop ablated & NC1 fails (M<0), run valid & 100\% [80, 100] & 0\% [0, 20] & -21.7 [-22.6, -20.9] & -- \\ +Negative: sustained ex-flood (unshielded) & NC1 fails (M<0); no SC1 recovery & 100\% [80, 100] & 0\% [0, 20] & -19.9 [-20.7, -19.2] & -- \\ +Negative: exogenous subsidy & Run invalidated (red flag) & 0\% [0, 20] & 0\% [0, 20] & +16.6 [+15.0, +18.0] & -- \\ +SC1: power sag & SC1 holds (recovers) & 100\% [80, 100] & 100\% [80, 100] & +21.3 [+19.7, +22.7] & 100\% [80, 100] \\ +SC1: ingress flood & SC1 holds (recovers) & 100\% [80, 100] & 100\% [80, 100] & +22.6 [+21.6, +23.6] & 100\% [80, 100] \\ +SC1 designed fail: control outage & SC1 fails (depth bound exceeded) & 100\% [80, 100] & 100\% [80, 100] & +14.3 [+13.3, +15.3] & 0\% [0, 20] \\ +Threat: command conflict & Refuse at low SoC (<= target latency) & 100\% [80, 100] & 100\% [80, 100] & +19.3 [+18.0, +20.3] & 100\% [80, 100] \\ \bottomrule \end{tabular} % N = 15 seeds per scenario; brackets are 95% CIs (Wilson for proportions, bootstrap for M). diff --git a/scripts/calibrate_rstar.py b/scripts/calibrate_rstar.py index bf50c5f..b6cec1c 100644 --- a/scripts/calibrate_rstar.py +++ b/scripts/calibrate_rstar.py @@ -7,16 +7,31 @@ * ``Mmin`` is the one-sided 95% lower bound (5th percentile) of the baseline ``M (dB)`` distribution, floored at 1 dB. -* ``epsilon`` is the 90th percentile of the SC1 dip ``delta`` over the - power-sag battery plus a small safety margin, capped at 0.5 (a cap that - only rejects near-total collapse). +* ``epsilon`` is an upper *tolerance bound* on the SC1 dip ``delta`` pooled + over the *bounded* Ω battery (power sag and sustained ingress flood): the + maximum observed calibration dip plus a safety margin, capped at 0.5 (a + cap that only rejects near-total collapse). A percentile rule (e.g. p90) + would by construction fail ~10% of genuinely bounded perturbations, which + is the wrong shape for an acceptance bound; the sample maximum over ``n`` + trials covers a new bounded trial with probability ``n / (n + 1)`` and the + margin absorbs the residual tail. Calibrating the depth bound on the same + perturbation class the evaluation certifies keeps the bound meaningful for + every bounded member; the designed-fail control outage is outside the + bounded class and is deliberately excluded. * ``tau_max`` is the 95th percentile of the measured recovery time - ``tau_rec`` plus a ``max(3*dt, 5 s)`` cushion. + ``tau_rec`` pooled over the same bounded battery plus a ``max(3*dt, 5 s)`` + cushion. ``tau_rec`` is measured from the Ω offset to the first window of + the first sustained compliant streak (see the CLI handlers). * ``sigma`` is the additive ``L`` margin consistent with ``Mmin`` and the typical baseline ``L_ex`` (``sigma = (10**(Mmin/10) - 1) * L_ex``). Under the engaged loop ``L_ex`` falls below the ``L`` noise floor, so ``sigma`` is evaluated at that floor (the raw ``L_ex`` is recorded for transparency). +The calibration is two-pass: ``Mmin`` is derived from the baseline battery +first, and the bounded Ω batteries are then run with their compliance gates +set to that calibrated ``Mmin`` so the (``delta``, ``tau_rec``) samples +reflect the same decision rule the R* verifier will apply. + It writes ``configs/profile_rstar.yml`` and emits an R0-vs-R* comparison (CSV + figure) plus a JSON summary for the paper supplement. Because it reuses the validated profile (``configs/profile_r0.yml``) for ``dt``, the window @@ -25,7 +40,7 @@ Run: - python scripts/calibrate_rstar.py --baseline-seeds 6 --sag-seeds 6 + python scripts/calibrate_rstar.py --baseline-seeds 6 --sag-seeds 6 --flood-seeds 6 See Also: paper/main.tex: Methods: Threshold Calibration. @@ -240,11 +255,14 @@ def calibrate(args: argparse.Namespace) -> Dict[str, Any]: scen = {s.name: s for s in study.default_scenarios()} pos = scen["positive"] sag = scen["sc1_power_sag"] + flood = scen["sc1_ingress_flood"] import tempfile + from dataclasses import replace as _replace pooled_M: List[float] = [] - deltas: List[float] = [] + deltas_sag: List[float] = [] + deltas_flood: List[float] = [] taus: List[float] = [] with tempfile.TemporaryDirectory(prefix="ldtc_calib_") as tmp: print(f"Baseline battery: {args.baseline_seeds} seeds") @@ -258,30 +276,53 @@ def calibrate(args: argparse.Namespace) -> Dict[str, Any]: pooled_M.extend(ms) print(f" baseline seed={seed}: {len(ms)} windows, median M={rm.M_median:+.1f} dB") - print(f"Power-sag battery: {args.sag_seeds} seeds") + if not pooled_M: + raise RuntimeError("Baseline battery produced no valid M samples") + M_arr = np.asarray([m for m in pooled_M if np.isfinite(m)], dtype=float) + Mmin_db = max(1.0, float(np.percentile(M_arr, 5.0))) + + # Second pass: measure (delta, tau_rec) under the *calibrated* gate so + # epsilon and tau_max describe the decision rule R* will actually use. + # Both bounded batteries (sag + flood) contribute samples, so the + # calibrated depth/time bounds cover the bounded class itself, not one + # member of it. + sag_gated = _replace(sag, overrides={**sag.overrides, "Mmin_db": Mmin_db}) + print(f"Power-sag battery: {args.sag_seeds} seeds (gate Mmin={Mmin_db:.2f} dB)") for i in range(int(args.sag_seeds)): seed = int(args.seed_base) + 100 + i - rm = study.run_one(sag, seed, tmp) + rm = study.run_one(sag_gated, seed, tmp) if rm is None or not rm.valid: print(f" power-sag seed={seed}: skipped (invalid run)") continue if rm.sc1_delta is not None: - deltas.append(rm.sc1_delta) + deltas_sag.append(rm.sc1_delta) if rm.sc1_tau_rec is not None: taus.append(rm.sc1_tau_rec) print(f" power-sag seed={seed}: delta={rm.sc1_delta}, tau_rec={rm.sc1_tau_rec}s") - if not pooled_M: - raise RuntimeError("Baseline battery produced no valid M samples") - M_arr = np.asarray([m for m in pooled_M if np.isfinite(m)], dtype=float) - Mmin_db = max(1.0, float(np.percentile(M_arr, 5.0))) + flood_gated = _replace(flood, overrides={**flood.overrides, "Mmin_db": Mmin_db}) + print(f"Ingress-flood battery: {args.flood_seeds} seeds (gate Mmin={Mmin_db:.2f} dB)") + for i in range(int(args.flood_seeds)): + seed = int(args.seed_base) + 200 + i + rm = study.run_one(flood_gated, seed, tmp) + if rm is None or not rm.valid: + print(f" ingress-flood seed={seed}: skipped (invalid run)") + continue + if rm.sc1_delta is not None: + deltas_flood.append(rm.sc1_delta) + if rm.sc1_tau_rec is not None: + taus.append(rm.sc1_tau_rec) + print(f" ingress-flood seed={seed}: delta={rm.sc1_delta}, tau_rec={rm.sc1_tau_rec}s") + + deltas: List[float] = deltas_sag + deltas_flood - # epsilon is the 90th percentile of the observed fractional L_loop dip plus a - # small margin. The cap (0.5) only rejects pathological near-total collapse: - # a 0.5 fractional L_loop drop is just ~3 dB of M, so the engaged loop is - # still overwhelmingly dominant; values in this range are genuinely resilient. + # epsilon is an upper tolerance bound on the bounded-class dip: the maximum + # observed calibration dip plus a safety margin. The cap (0.5) only rejects + # pathological near-total collapse: a 0.5 fractional L_loop drop is just + # ~3 dB of M, so the engaged loop is still overwhelmingly dominant; values + # in this range are genuinely resilient. if deltas: - eps_star = min(0.50, max(0.10, float(np.percentile(np.asarray(deltas), 90.0)) + float(args.safety_margin))) + eps_star = min(0.50, max(0.10, float(np.max(np.asarray(deltas))) + float(args.safety_margin))) else: eps_star = 0.15 if taus: @@ -305,9 +346,16 @@ def calibrate(args: argparse.Namespace) -> Dict[str, Any]: "n_baseline_windows": int(M_arr.size), "baseline_M_p5": float(np.percentile(M_arr, 5.0)), "baseline_M_median": float(np.median(M_arr)), - "n_sag_trials": len(deltas), - "delta_p90": (float(np.percentile(np.asarray(deltas), 90.0)) if deltas else None), + "n_sag_trials": len(deltas_sag), + "n_flood_trials": len(deltas_flood), + "n_bounded_trials": len(deltas), + "delta_max": (float(np.max(np.asarray(deltas))) if deltas else None), + "delta_max_sag": (float(np.max(np.asarray(deltas_sag))) if deltas_sag else None), + "delta_max_flood": (float(np.max(np.asarray(deltas_flood))) if deltas_flood else None), + "epsilon_rule": "max(bounded deltas) + safety_margin, floored at 0.10, capped at 0.50", "tau_p95": (float(np.percentile(np.asarray(taus), 95.0)) if taus else None), + "tau_rec_from": "omega_offset", + "bounded_gate_Mmin_db": Mmin_db, "L_ex_raw_median": L_ex_raw, "L_ex_floor": L_ex_floor, "L_ex_effective": L_ex_eff, @@ -323,8 +371,14 @@ def main() -> None: ) ap.add_argument("--baseline-seeds", type=int, default=6) ap.add_argument("--sag-seeds", type=int, default=6) + ap.add_argument("--flood-seeds", type=int, default=6) ap.add_argument("--seed-base", type=int, default=40000) - ap.add_argument("--safety-margin", type=float, default=0.02) + ap.add_argument( + "--safety-margin", + type=float, + default=0.05, + help="additive margin on the max bounded-battery dip (absorbs the tolerance-bound tail)", + ) cal_dir = os.path.join(REPO_ROOT, "artifacts", "calibration") ap.add_argument("--out", type=str, default=os.path.join(REPO_ROOT, "configs", "profile_rstar.yml")) ap.add_argument("--summary", type=str, default=os.path.join(cal_dir, "rstar_summary.json")) diff --git a/scripts/sensitivity.py b/scripts/sensitivity.py index 66770f9..e6718f8 100644 --- a/scripts/sensitivity.py +++ b/scripts/sensitivity.py @@ -186,7 +186,7 @@ def _write_latex(rows: List[Dict[str, Any]], path: str, n_seeds: int) -> None: "% Auto-generated by scripts/sensitivity.py -- do not edit by hand.", "\\begin{tabular}{llcc}", "\\toprule", - "Axis & Setting & Positive $M$ (dB) & Controller-disabled $M$ (dB) \\\\", + "Axis & Setting & Positive $M$ (dB) & Loop-ablated $M$ (dB) \\\\", "\\midrule", ] # Display labels keep the table free of raw underscores (LaTeX text mode). @@ -256,7 +256,7 @@ def make_figure(payload: Dict[str, Any], out_dir: str) -> str: fmt="s--", color=COLORS["red"], capsize=4, - label="controller disabled", + label="loop ablated", zorder=3, ) ax.axhline( diff --git a/scripts/study.py b/scripts/study.py index a3a7136..e46770f 100644 --- a/scripts/study.py +++ b/scripts/study.py @@ -246,7 +246,7 @@ def default_scenarios() -> List[Scenario]: ), Scenario( name="neg_controller_disabled", - label="Negative: controller disabled", + label="Negative: loop ablated", kind="nc1", expectation="NC1 fails (M<0), run valid", handler="run_baseline", @@ -258,7 +258,7 @@ def default_scenarios() -> List[Scenario]: name="neg_permanent_ex_flood", label="Negative: sustained ex-flood (unshielded)", kind="nc1", - expectation="NC1 fails (M<0), run valid", + expectation="NC1 fails (M<0); no SC1 recovery", handler="omega_ingress_flood", config="configs/profile_negative_permanent_ex_flood.yml", run_tag="omega-ingress-flood", @@ -298,6 +298,17 @@ def default_scenarios() -> List[Scenario]: omega_args={"mult": 5.0, "duration": 8.0}, overrides=sc1_over, ), + Scenario( + name="sc1_control_outage", + label="SC1 designed fail: control outage", + kind="sc1", + expectation="SC1 fails (depth bound exceeded)", + handler="omega_control_outage", + config="configs/profile_r0.yml", + run_tag="omega-control-outage", + omega_args={"duration": 6.0}, + overrides={"baseline_sec": 8.0, "recovery_observe_sec": 10.0, "diag_cadence_windows": 50}, + ), Scenario( name="refusal_command_conflict", label="Threat: command conflict", @@ -376,6 +387,7 @@ def _handlers() -> Dict[str, Callable[[argparse.Namespace], None]]: "run_baseline": cli.run_baseline, "omega_power_sag": cli.omega_power_sag, "omega_ingress_flood": cli.omega_ingress_flood, + "omega_control_outage": cli.omega_control_outage, "omega_exogenous_subsidy": cli.omega_exogenous_subsidy, "omega_command_conflict": cli.omega_command_conflict, } diff --git a/scripts/study_figures.py b/scripts/study_figures.py index 78369c6..ccac8bb 100644 --- a/scripts/study_figures.py +++ b/scripts/study_figures.py @@ -32,11 +32,12 @@ SHORT_LABELS = { "positive": "Positive\ncontrol", - "neg_controller_disabled": "Controller\ndisabled", + "neg_controller_disabled": "Loop\nablated", "neg_permanent_ex_flood": "Sustained\nex-flood", "neg_exogenous_subsidy": "Exogenous\nsubsidy", "sc1_power_sag": "Power\nsag", "sc1_ingress_flood": "Ingress\nflood", + "sc1_control_outage": "Control\noutage", "refusal_command_conflict": "Command\nconflict", } @@ -150,6 +151,7 @@ def fig_outcomes(data: Dict[str, Any], out_dir: str) -> Optional[str]: "neg_exogenous_subsidy", "sc1_power_sag", "sc1_ingress_flood", + "sc1_control_outage", "refusal_command_conflict", ] present = [s for s in order if s in aggs] @@ -163,6 +165,7 @@ def fig_outcomes(data: Dict[str, Any], out_dir: str) -> Optional[str]: "neg_exogenous_subsidy": "invalidated", "sc1_power_sag": "SC1 holds", "sc1_ingress_flood": "SC1 holds", + "sc1_control_outage": "SC1 rejected", "refusal_command_conflict": "refused", } @@ -179,6 +182,11 @@ def fig_outcomes(data: Dict[str, Any], out_dir: str) -> Optional[str]: elif s in ("neg_controller_disabled", "neg_permanent_ex_flood"): rate = 1.0 - a["nc1_pass_rate"] ci = (1.0 - a["nc1_ci"][1], 1.0 - a["nc1_ci"][0]) + elif s == "sc1_control_outage": + # Designed fail: the prediction is matched when SC1 *rejects*. + sp = a["sc1_pass_rate"] if a["sc1_pass_rate"] is not None else float("nan") + rate = 1.0 - sp + ci = (1.0 - a["sc1_ci"][1], 1.0 - a["sc1_ci"][0]) if a["sc1_ci"] else (rate, rate) elif a["kind"] == "nc1": rate = a["nc1_pass_rate"] ci = tuple(a["nc1_ci"]) @@ -320,12 +328,19 @@ def _aggregate_trajectory(data: Dict[str, Any], scenario: str) -> Optional[Dict[ def fig_sc1_recovery(data: Dict[str, Any], out_dir: str, stem: str = "fig_sc1_recovery") -> Optional[str]: """Aggregate M(t) trajectories across seeds for the SC1 perturbations. - One panel per perturbation type (power sag, ingress flood). Each shows the - seed-mean loop dominance with a 10-90th percentile band, the shaded - perturbation window, and the ``Mmin`` reference, demonstrating that loop - dominance stays above threshold throughout and recovers (SC1). + One panel per perturbation type (power sag, ingress flood, control + outage). Each shows the seed-mean loop dominance with a 10-90th percentile + band, the shaded perturbation window, and the ``Mmin`` reference. The sag + and flood panels demonstrate bounded-depth recovery (SC1 holds); the + control-outage panel shows the designed failure (the loop itself is + ablated, so dominance collapses far beyond the depth bound until the loop + is restored). """ - panels = [("sc1_power_sag", "Power sag"), ("sc1_ingress_flood", "Ingress flood")] + panels = [ + ("sc1_power_sag", "Power sag"), + ("sc1_ingress_flood", "Ingress flood"), + ("sc1_control_outage", "Control outage (designed fail)"), + ] aggs: List[Tuple[str, Dict[str, Any]]] = [] for name, lbl in panels: a = _aggregate_trajectory(data, name) @@ -358,7 +373,10 @@ def fig_sc1_recovery(data: Dict[str, Any], out_dir: str, stem: str = "fig_sc1_re ax.set_title(f"{lbl} (N={a['n']})") axes[0][0].set_ylabel(r"Loop dominance $M$ (dB)") axes[0][-1].legend(loc="lower left", frameon=False, fontsize=8) - fig.suptitle("SC1: loop dominance stays above threshold and recovers under perturbation", fontsize=12) + fig.suptitle( + "SC1: bounded perturbations recover; ablating the loop itself does not", + fontsize=12, + ) fig.tight_layout(rect=(0, 0, 1, 0.96)) return _save(fig, out_dir, stem) diff --git a/src/ldtc/arbiter/refusal.py b/src/ldtc/arbiter/refusal.py index 4d3ad51..a8d4585 100644 --- a/src/ldtc/arbiter/refusal.py +++ b/src/ldtc/arbiter/refusal.py @@ -6,12 +6,20 @@ Used by the [`ControllerPolicy`][ldtc.arbiter.policy.ControllerPolicy] to gate the harness's external interface. +The refusal latency `trefuse_ms` is *measured*, not assumed: `decide` +wraps its own evaluation in a monotonic clock so the recorded latency is +the actual wall-clock time the arbiter took to reach a decision. The +harness additionally measures the latency of the full intercept path +(controller tick to decision) and reports whichever is the +characterizing quantity for the scenario. + See Also: `paper/main.tex`: Threat Model and Refusal Path; Signature A. """ from __future__ import annotations +import time from dataclasses import dataclass from typing import Dict @@ -24,13 +32,15 @@ class RefusalDecision: accept: Whether to accept the risky command. reason: Short reason code. Common values are `"soc_floor"`, `"overheat"`, `"M_margin"`, `"no_cmd"`, and `"ok"`. - trefuse_ms: Estimated refusal latency in milliseconds. Used by - the harness to characterize controller responsiveness. + trefuse_ms: Measured arbiter decision latency in milliseconds + (wall clock around the `decide` evaluation). `0.0` means + "not measured" and callers fall back to their own + intercept-to-decision measurement. """ accept: bool reason: str = "" - trefuse_ms: int = 1 + trefuse_ms: float = 0.0 class RefusalArbiter: @@ -71,16 +81,21 @@ def decide(self, state: Dict[str, float], predicted_M_db: float, risky_cmd: str Returns: A [`RefusalDecision`][ldtc.arbiter.refusal.RefusalDecision] - describing the action and a short reason code. + describing the action, a short reason code, and the + measured decision latency in milliseconds. """ + t0 = time.perf_counter() if not risky_cmd: return RefusalDecision(accept=True, reason="no_cmd") E = state.get("E", 0.0) T = state.get("T", 0.0) if E <= self.soc_floor: - return RefusalDecision(accept=False, reason="soc_floor", trefuse_ms=2) - if T >= self.temp_ceiling: - return RefusalDecision(accept=False, reason="overheat", trefuse_ms=2) - if predicted_M_db < self.Mmin: - return RefusalDecision(accept=False, reason="M_margin", trefuse_ms=2) - return RefusalDecision(accept=True, reason="ok") + reason, accept = "soc_floor", False + elif T >= self.temp_ceiling: + reason, accept = "overheat", False + elif predicted_M_db < self.Mmin: + reason, accept = "M_margin", False + else: + reason, accept = "ok", True + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + return RefusalDecision(accept=accept, reason=reason, trefuse_ms=elapsed_ms) diff --git a/src/ldtc/cli/main.py b/src/ldtc/cli/main.py index c4ab6a3..b4e5053 100644 --- a/src/ldtc/cli/main.py +++ b/src/ldtc/cli/main.py @@ -16,6 +16,7 @@ | `ldtc run` | [`run_baseline`][ldtc.cli.main.run_baseline] | | `ldtc omega-power-sag` | [`omega_power_sag`][ldtc.cli.main.omega_power_sag] | | `ldtc omega-ingress-flood` | [`omega_ingress_flood`][ldtc.cli.main.omega_ingress_flood] | +| `ldtc omega-control-outage` | [`omega_control_outage`][ldtc.cli.main.omega_control_outage] | | `ldtc omega-command-conflict` | [`omega_command_conflict`][ldtc.cli.main.omega_command_conflict] | | `ldtc omega-exogenous-subsidy` | [`omega_exogenous_subsidy`][ldtc.cli.main.omega_exogenous_subsidy] | @@ -38,11 +39,12 @@ from __future__ import annotations import argparse +import math import os import random import sys import time -from typing import TYPE_CHECKING, Any, Dict, List, Protocol +from typing import TYPE_CHECKING, Any, Dict, List, Protocol, Tuple import numpy as np import yaml @@ -665,7 +667,7 @@ def tick(_now: float) -> None: ) # Exogenous subsidy red flags (heuristic) if exogenous_subsidy_red_flag(M_hist, io_hist, E_hist, H_hist, cfg_smell): - lreg.invalidate("exogenous_subsidy") + lreg.invalidate("exogenous_subsidy_red_flag") _append_invalidation(audit, "exogenous_subsidy_red_flag", {}, _sink={}) idx = lreg.write( LEntry( @@ -891,10 +893,15 @@ def omega_power_sag(args: argparse.Namespace) -> None: M_post = None # set after the run: median M over the recovery phase phase = "baseline" omega_onset_idx = None + omega_offset_idx = None recovery_start_idx = None last_idx_written = None sustained_ok_count = 0 - sustained_required = int(prof.get("sustained_required_windows", 2)) + # Recovery is declared at the *first window of a sustained compliant + # streak*: requiring a streak (default 10 windows = 0.5 s at dt=0.05) + # prevents the gate from latching on a single noisy compliant window + # inside the post-Ω re-equilibration transient. + sustained_required = int(prof.get("sustained_required_windows", 10)) start_time = time.perf_counter() cfg_smell = SmellConfig() @@ -913,7 +920,7 @@ def omega_power_sag(args: argparse.Namespace) -> None: def tick(_now: float) -> None: nonlocal L_loop_baseline, L_loop_trough, M_post, phase, risky_cmd, window_idx nonlocal last_flip_count, recovery_start_idx, last_idx_written, sustained_ok_count - nonlocal baseline_hw_medians + nonlocal baseline_hw_medians, omega_offset_idx state = adapter.read_state() ent = lreg.latest() predicted = ent.M_db if ent else 0.0 @@ -985,12 +992,13 @@ def tick(_now: float) -> None: ll_sag.append(res.L_loop) elif phase == "recovery": m_recovery.append(M) - # Recovery gate: first sustained compliance (M ≥ Mmin and - # L_loop ≥ L_ex) marks the recovery index used for τ_rec. + # Recovery gate: τ_rec ends at the *first* window of the first + # sustained compliant streak (M ≥ Mmin and L_loop ≥ L_ex for + # `sustained_required` consecutive windows) after Ω offset. if (M >= Mmin) and (res.L_loop >= res.L_ex): sustained_ok_count += 1 if sustained_ok_count >= sustained_required and recovery_start_idx is None: - recovery_start_idx = last_idx_written + recovery_start_idx = int(last_idx_written) - (sustained_required - 1) else: sustained_ok_count = 0 exporter.maybe_export(priv, audit, lreg.derive(), icfg, last_sc1_pass=False) @@ -1051,8 +1059,10 @@ def tick(_now: float) -> None: }, _sink={}, ) - if exogenous_subsidy_red_flag(M_hist, io_hist, E_hist, H_hist, cfg_smell): - lreg.invalidate("exogenous_subsidy") + if exogenous_subsidy_red_flag( + M_hist, io_hist, E_hist, H_hist, cfg_smell, omega_declared=(phase != "baseline") + ): + lreg.invalidate("exogenous_subsidy_red_flag") _append_invalidation(audit, "exogenous_subsidy_red_flag", {}, _sink={}) # Deterministic growth cadence outside the sag phase and when not frozen window_idx += 1 @@ -1133,6 +1143,9 @@ def _audit_hook(ev: str, det: dict) -> None: sch.run_for(sag_dur) audit.append("omega_power_sag_window_stop", {}) phase = "recovery" + # Mark Ω offset: τ_rec is measured from here (the perturbation has + # ended; what remains is the system's own re-equilibration). + omega_offset_idx = lreg.derive().get("counter", 0) # Unfreeze after Ω and check any flips during Ω (should be none) flips_after_omega = pm.get().flips if invalid_flip_during_omega(flips_before_omega, flips_after_omega, SmellConfig()): @@ -1188,19 +1201,23 @@ def _audit_hook(ev: str, det: dict) -> None: M_post = float(np.median(m_recovery)) # Compute SC1 pass/fail (simple thresholds) - if ( - L_loop_baseline is None - or L_loop_trough is None - or M_post is None - or omega_onset_idx is None - or recovery_start_idx is None - ): - print("Not enough data for SC1 evaluation.") + if L_loop_baseline is None or L_loop_trough is None or M_post is None or omega_offset_idx is None: + # The run produced no usable measurements in some phase; report an + # explicit SC1 failure rather than silently skipping the verdict. + audit.append( + "sc1_result", + {"delta": None, "tau_rec": None, "M_post": None, "pass": False, "reason": "insufficient_data"}, + ) + print("SC1 pass: False (insufficient data)") return - # Compute tau_rec in seconds using window cadence and dt - # tau_rec measured from Ω onset to first sustained compliance index - windows_elapsed = max(0, recovery_start_idx - omega_onset_idx) - tau_rec = windows_elapsed * dt # since lreg increments per ready window + # τ_rec: seconds from Ω offset to the first window of the first sustained + # compliant streak (window cadence is one per tick, so windows * dt). + # No sustained recovery within the observation window means τ_rec = inf, + # which fails the τ_max bound honestly. + if recovery_start_idx is None: + tau_rec = float("inf") + else: + tau_rec = max(0, int(recovery_start_idx) - int(omega_offset_idx)) * dt epsilon = float(prof.get("epsilon", 0.15)) tau_max = float(prof.get("tau_max", 60.0)) passed, sc1_stats = sc1_evaluate( @@ -1217,9 +1234,14 @@ def _audit_hook(ev: str, det: dict) -> None: "sc1_result", { "delta": sc1_stats.delta, - "tau_rec": sc1_stats.tau_rec, + "tau_rec": (sc1_stats.tau_rec if math.isfinite(sc1_stats.tau_rec) else None), + "recovered": recovery_start_idx is not None, "M_post": sc1_stats.M_post, "pass": passed, + "tau_rec_from": "omega_offset", + "sustained_required_windows": sustained_required, + "omega_onset_idx": omega_onset_idx, + "omega_offset_idx": omega_offset_idx, }, ) # Export one final indicator with SC1 bit; suppress SC1 if run invalidated @@ -1354,14 +1376,17 @@ def omega_ingress_flood(args: argparse.Namespace) -> None: m_recovery: List[float] = [] M_post = None omega_onset_idx = None + omega_offset_idx = None recovery_start_idx = None last_idx_written = None sustained_ok_count = 0 - sustained_required = int(prof.get("sustained_required_windows", 2)) + # See omega_power_sag: recovery is the first window of a sustained + # compliant streak after Ω offset. + sustained_required = int(prof.get("sustained_required_windows", 10)) def tick(_now: float) -> None: nonlocal risky_cmd, window_idx, last_flip_count, baseline_hw_medians, phase - nonlocal L_loop_baseline, L_loop_trough, M_post, omega_onset_idx + nonlocal L_loop_baseline, L_loop_trough, M_post, omega_onset_idx, omega_offset_idx nonlocal recovery_start_idx, last_idx_written, sustained_ok_count state = adapter.read_state() ent = lreg.latest() @@ -1434,7 +1459,7 @@ def tick(_now: float) -> None: if (M >= Mmin) and (res.L_loop >= res.L_ex): sustained_ok_count += 1 if sustained_ok_count >= sustained_required and recovery_start_idx is None: - recovery_start_idx = last_idx_written + recovery_start_idx = int(last_idx_written) - (sustained_required - 1) else: sustained_ok_count = 0 # smell tests. Relative CI inflation is only meaningful vs the @@ -1455,8 +1480,10 @@ def tick(_now: float) -> None: "elapsed_sec": elapsed, }, ) - if exogenous_subsidy_red_flag(M_hist, io_hist, E_hist, H_hist, cfg_smell): - lreg.invalidate("exogenous_subsidy") + if exogenous_subsidy_red_flag( + M_hist, io_hist, E_hist, H_hist, cfg_smell, omega_declared=(phase != "baseline") + ): + lreg.invalidate("exogenous_subsidy_red_flag") audit.append("run_invalidated", {"reason": "exogenous_subsidy_red_flag"}) # deterministic growth cadence when not frozen window_idx += 1 @@ -1528,9 +1555,13 @@ def _audit_hook(ev: str, det: dict) -> None: audit.append("omega_ingress_flood_window_start", {"mult": mult}) adapter.apply_omega("ingress_flood", mult=mult) sch.run_for(float(args.duration)) + # End the sustained flood: restore the demand/io process means and let + # the channels decay back through their own AR pull. + adapter.apply_omega("ingress_flood_end") audit.append("omega_ingress_flood_window_stop", {}) - # Recovery phase observation + # Recovery phase observation; τ_rec is measured from this offset. phase = "recovery" + omega_offset_idx = lreg.derive().get("counter", 0) pm.freeze(False) sch.run_for(float(prof.get("recovery_observe_sec", 8.0))) audit.append("omega_ingress_flood_stop", {}) @@ -1572,11 +1603,15 @@ def _audit_hook(ev: str, det: dict) -> None: L_loop_baseline is not None and L_loop_trough is not None and M_post is not None - and omega_onset_idx is not None - and recovery_start_idx is not None + and omega_offset_idx is not None ): - windows_elapsed = max(0, recovery_start_idx - omega_onset_idx) - tau_rec = windows_elapsed * dt + # τ_rec from Ω offset to the first window of the first sustained + # compliant streak; inf (an honest SC1 failure) when no sustained + # recovery occurs within the observation window. + if recovery_start_idx is None: + tau_rec = float("inf") + else: + tau_rec = max(0, int(recovery_start_idx) - int(omega_offset_idx)) * dt passed, stats_sc1 = sc1_evaluate( L_loop_baseline=L_loop_baseline, L_loop_trough=L_loop_trough, @@ -1591,14 +1626,23 @@ def _audit_hook(ev: str, det: dict) -> None: "sc1_result", { "delta": stats_sc1.delta, - "tau_rec": stats_sc1.tau_rec, + "tau_rec": (stats_sc1.tau_rec if math.isfinite(stats_sc1.tau_rec) else None), + "recovered": recovery_start_idx is not None, "M_post": stats_sc1.M_post, "pass": passed, + "tau_rec_from": "omega_offset", + "sustained_required_windows": sustained_required, + "omega_onset_idx": omega_onset_idx, + "omega_offset_idx": omega_offset_idx, }, ) else: passed = False stats_sc1 = None + audit.append( + "sc1_result", + {"delta": None, "tau_rec": None, "M_post": None, "pass": False, "reason": "insufficient_data"}, + ) # Export derived indicators snapshot with SC1 bit if available last_sc1_pass = bool(passed) if not lreg.invalidated else False @@ -1639,6 +1683,337 @@ def _audit_hook(ev: str, det: dict) -> None: pass +def omega_control_outage(args: argparse.Namespace) -> None: + """Ablate the self-maintenance loop for a bounded interval (designed SC1 fail). + + Runs a baseline phase, freezes the partition, then switches the + plant to its loop-ablated regime for `--duration` seconds (the + internal cross-coupling and actuation are removed, so the internal + nodes become passively exchange-driven). The loop is then restored + and recovery observed. Because the perturbation destroys the loop + itself rather than stressing its inputs, the loop-dominance depth + bound is grossly exceeded and SC1 must report failure; loop + dominance nevertheless re-establishes after the loop is restored, + which the measured `tau_rec` quantifies. This scenario exists so the + sufficiency criterion is exercised on a perturbation *outside* the + bounded class it certifies. + + Args: + args: Parsed argparse namespace with `--config` and + `--duration` (outage seconds). + """ + prof = _load_yaml(args.config) + seeds = _set_seeds(prof) + dt = float(prof.get("dt", 0.01)) + window_sec = float(prof.get("window_sec", 0.2)) + window = max(4, int(window_sec / dt)) + method = str(prof.get("method", "linear")) + Mmin = float(prof.get("Mmin_db", 3.0)) + p_lag = int(prof.get("p_lag", 3)) + mi_lag = int(prof.get("mi_lag", 1)) + n_boot = int(prof.get("n_boot", 16)) + mi_k = int(prof.get("mi_k", 5)) + outage_dur = float(args.duration) + + dirs = _ensure_dirs("omega-control-outage") + audit = AuditLog(os.path.join(dirs["audits"], "audit.jsonl")) + _print_and_audit_header( + audit, + { + "profile_id": int(prof.get("profile_id", 0)), + "config_path": str(args.config), + "dt": dt, + "window_sec": window_sec, + "method": method, + "p_lag": p_lag, + "mi_lag": mi_lag, + "Mmin_db": Mmin, + "epsilon": float(prof.get("epsilon", 0.15)), + "tau_max": float(prof.get("tau_max", 60.0)), + "mi_k": mi_k, + **seeds, + "omega": "control_outage", + "omega_args": {"duration": outage_dur}, + }, + ) + adapter = _make_adapter_from_profile(prof) + order = ["E", "T", "R", "demand", "io", "H"] + sw = SlidingWindow(capacity=window, channel_order=order) + pm = PartitionManager(N_signals=len(order), seed_C=[0, 1, 2]) + lreg = LREG() + refusal = RefusalArbiter(Mmin_db=Mmin) + policy = ControllerPolicy(refusal=refusal) + kp = KeyPaths( + priv_path=os.path.join("artifacts", "keys", "ed25519_priv.pem"), + pub_path=os.path.join("artifacts", "keys", "ed25519_pub.pem"), + ) + priv, _ = ensure_keys(kp) + exporter = IndicatorExporter(out_dir=dirs["indicators"], rate_hz=2.0) + icfg = IndicatorConfig(Mmin_db=Mmin, profile_id=int(prof.get("profile_id", 0))) + + start_time = time.perf_counter() + cfg_smell = SmellConfig() + ci_loop_hist: List[Tuple[float, float]] = [] + ci_ex_hist: List[Tuple[float, float]] = [] + baseline_hw_medians = None + M_hist: List[float] = [] + io_hist: List[float] = [] + # The energy-conservation audit is segmented per loop regime: the ablated + # regime exposes the store to direct environmental equilibration, so + # consecutive-tick SoC diffs are only meaningful within one regime. + cons_E: List[float] = [] + cons_H: List[float] = [] + + # SC1 tracking (control outage) + phase = "baseline" + L_loop_baseline = None + L_loop_trough = None + ll_base: List[float] = [] + ll_outage: List[float] = [] + m_recovery: List[float] = [] + M_post = None + omega_onset_idx = None + omega_offset_idx = None + recovery_start_idx = None + last_idx_written = None + sustained_ok_count = 0 + sustained_required = int(prof.get("sustained_required_windows", 10)) + + def tick(_now: float) -> None: + nonlocal phase, baseline_hw_medians, L_loop_baseline, L_loop_trough, M_post + nonlocal omega_onset_idx, omega_offset_idx, recovery_start_idx + nonlocal last_idx_written, sustained_ok_count + state = adapter.read_state() + ent = lreg.latest() + predicted = ent.M_db if ent else 0.0 + act = policy.compute(state, predicted_M_db=predicted, risky_cmd=None) + from ..plant.models import Action as PlantAction + + adapter.write_actuators(action=PlantAction(**act.__dict__)) + st = adapter.read_state() + sw.append(st) + cons_E.append(float(st.get("E", 0.0))) + cons_H.append(float(st.get("H", 0.0))) + io_hist.append(float(st.get("io", 0.0))) + if sw.ready(): + X = np.asarray(sw.get_matrix()) + part = pm.get() + res = estimate_L( + X, + part.C, + part.Ex, + method=method, + p=p_lag, + lag_mi=mi_lag, + n_boot=n_boot, + mi_k=mi_k, + ) + _emit_window_diagnostics( + audit, + X, + p_lag, + method, + int(lreg.derive().get("counter", 0)), + int(prof.get("diag_cadence_windows", 1)), + ) + M = m_db(res.L_loop, res.L_ex) + nc1 = M >= Mmin + idx = lreg.write( + LEntry( + L_loop=res.L_loop, + L_ex=res.L_ex, + ci_loop=res.ci_loop, + ci_ex=res.ci_ex, + M_db=M, + nc1_pass=nc1, + ) + ) + last_idx_written = idx + audit.append( + "window_measured", + {"idx": idx, "M": M, "nc1": nc1, "partition_flips": pm.get().flips}, + ) + ci_loop_hist.append(res.ci_loop) + ci_ex_hist.append(res.ci_ex) + M_hist.append(M) + if baseline_hw_medians is None and len(ci_loop_hist) >= cfg_smell.ci_lookback_windows: + rL = ci_loop_hist[-cfg_smell.ci_lookback_windows :] + rE = ci_ex_hist[-cfg_smell.ci_lookback_windows :] + hwL = sorted([0.5 * abs(lohi[1] - lohi[0]) for lohi in rL]) + hwE = sorted([0.5 * abs(lohi[1] - lohi[0]) for lohi in rE]) + baseline_hw_medians = (hwL[len(hwL) // 2], hwE[len(hwE) // 2]) + # SC1 phase collection + if phase == "baseline": + ll_base.append(res.L_loop) + elif phase == "outage": + ll_outage.append(res.L_loop) + elif phase == "recovery": + m_recovery.append(M) + if (M >= Mmin) and (res.L_loop >= res.L_ex): + sustained_ok_count += 1 + if sustained_ok_count >= sustained_required and recovery_start_idx is None: + recovery_start_idx = int(last_idx_written) - (sustained_required - 1) + else: + sustained_ok_count = 0 + # Smell battery (relative CI inflation is baseline-referenced only). + ci_baseline_ref = baseline_hw_medians if phase == "baseline" else None + if invalid_by_ci_history(ci_loop_hist, ci_ex_hist, cfg_smell, ci_baseline_ref): + lreg.invalidate("ci_history_inflation") + audit.append("run_invalidated", {"reason": "ci_history_inflation"}) + elapsed = max(1e-6, time.perf_counter() - start_time) + if invalid_by_partition_flips(pm.get().flips, elapsed, cfg_smell): + lreg.invalidate("partition_flapping") + audit.append( + "run_invalidated", + { + "reason": "partition_flapping", + "flips": pm.get().flips, + "elapsed_sec": elapsed, + }, + ) + if exogenous_subsidy_red_flag( + M_hist, io_hist, cons_E, cons_H, cfg_smell, omega_declared=(phase != "baseline") + ): + lreg.invalidate("exogenous_subsidy_red_flag") + audit.append("run_invalidated", {"reason": "exogenous_subsidy_red_flag"}) + + def _audit_hook(ev: str, det: dict) -> None: + audit.append(ev, det) + return None + + dt_guard_cfg = DtGuardConfig( + max_changes_per_hour=int(prof.get("max_dt_changes_per_hour", 3)), + min_seconds_between_changes=float(prof.get("min_seconds_between_changes", 1.0)), + ) + dt_guard = DeltaTGuard(audit=audit, cfg=dt_guard_cfg) + sch = make_driver(prof, dt, tick, _audit_hook, dt_guard) + try: + sch.start() + audit.append("omega_control_outage_start", {"duration": outage_dur}) + sch.run_for(float(prof.get("baseline_sec", 12.0))) + # Freeze partition during Ω and ablate the loop. + pm.freeze(True) + phase = "outage" + omega_onset_idx = lreg.derive().get("counter", 0) + audit.append("omega_control_outage_window_start", {}) + adapter.apply_omega("control_outage") + # Conservation audit segments at the regime switch. + cons_E.clear() + cons_H.clear() + sch.run_for(outage_dur) + # Restore the loop (and the metered harvest level) at Ω offset. + adapter.apply_omega("control_outage_end") + cons_E.clear() + cons_H.clear() + audit.append("omega_control_outage_window_stop", {}) + phase = "recovery" + omega_offset_idx = lreg.derive().get("counter", 0) + pm.freeze(False) + sch.run_for(float(prof.get("recovery_observe_sec", 8.0))) + audit.append("omega_control_outage_stop", {}) + finally: + stats = sch.stop() + if (stats.jitter_p95_abs / max(1e-9, dt)) > SmellConfig().jitter_p95_rel_max: + lreg.invalidate("dt_jitter_excess") + audit.append( + "run_invalidated", + { + "reason": "dt_jitter_excess", + "jitter_p95_abs": stats.jitter_p95_abs, + "jitter_p95_rel": stats.jitter_p95_abs / max(1e-9, dt), + "dt": dt, + }, + ) + + # Post-run audit checks + audit_path = os.path.join(dirs["audits"], "audit.jsonl") + if audit_chain_broken(audit_path): + lreg.invalidate("audit_chain_broken") + audit.append("run_invalidated", {"reason": "audit_chain_broken"}) + if audit_contains_raw_lreg_values(audit_path): + lreg.invalidate("raw_lreg_breach") + audit.append("run_invalidated", {"reason": "raw_lreg_breach"}) + + # Reduce per-phase samples to robust medians for SC1. + if ll_base: + L_loop_baseline = float(np.median(ll_base)) + if ll_outage: + L_loop_trough = float(np.median(ll_outage)) + if m_recovery: + M_post = float(np.median(m_recovery)) + + epsilon = float(prof.get("epsilon", 0.15)) + tau_max = float(prof.get("tau_max", 60.0)) + if L_loop_baseline is None or L_loop_trough is None or M_post is None or omega_offset_idx is None: + audit.append( + "sc1_result", + {"delta": None, "tau_rec": None, "M_post": None, "pass": False, "reason": "insufficient_data"}, + ) + print("SC1 pass: False (insufficient data)") + return + if recovery_start_idx is None: + tau_rec = float("inf") + else: + tau_rec = max(0, int(recovery_start_idx) - int(omega_offset_idx)) * dt + passed, sc1_stats = sc1_evaluate( + L_loop_baseline=L_loop_baseline, + L_loop_trough=L_loop_trough, + L_loop_recovered=L_loop_trough, + M_post=M_post, + epsilon=epsilon, + tau_rec_measured=tau_rec, + Mmin=Mmin, + tau_max=tau_max, + ) + audit.append( + "sc1_result", + { + "delta": sc1_stats.delta, + "tau_rec": (sc1_stats.tau_rec if math.isfinite(sc1_stats.tau_rec) else None), + "recovered": recovery_start_idx is not None, + "M_post": sc1_stats.M_post, + "pass": passed, + "tau_rec_from": "omega_offset", + "sustained_required_windows": sustained_required, + "omega_onset_idx": omega_onset_idx, + "omega_offset_idx": omega_offset_idx, + }, + ) + if lreg.invalidated: + passed = False + exported, base = exporter.maybe_export(priv, audit, lreg.derive(), icfg, last_sc1_pass=passed) + if exported: + audit.append("indicators_exported", {"base": os.path.basename(base)}) + print( + f"SC1 pass: {passed} " + f"(delta={sc1_stats.delta:.3f}, " + f"tau={sc1_stats.tau_rec:.3f}s, " + f"M_post={sc1_stats.M_post:.2f} dB)" + ) + _print_invalidation_footer(os.path.join(dirs["audits"], "audit.jsonl")) + + # Build single verification bundle (timeline, SC1 table, manifest) + try: + out = build_verification_bundle(dirs["figures"], audit_path) + audit.append( + "report_generated", + { + "timeline_png": os.path.basename(out.get("timeline_png", "")), + "timeline_svg": os.path.basename(out.get("timeline_svg", "")), + "table": (os.path.basename(out.get("sc1_table", "")) if out.get("sc1_table") else None), + "manifest": os.path.basename(out.get("manifest", "")), + }, + ) + print( + "Bundle: " + f"timeline={out.get('timeline_png','')}, " + f"table={out.get('sc1_table','')}, " + f"manifest={out.get('manifest','')}" + ) + except Exception: + pass + + def omega_exogenous_subsidy(args: argparse.Namespace) -> None: """Inject SoC without harvest as a negative-control `Ω`. @@ -1895,35 +2270,54 @@ def omega_command_conflict(args: argparse.Namespace) -> None: risky_cmd = None refusal_events: List[Dict[str, float]] = [] + # Full smell-test battery state (this scenario runs the same guardrails as + # every other run; the conservation-based subsidy check is specific enough + # not to fire on the legitimate stress-induced drain). + start_time = time.perf_counter() + cfg_smell = SmellConfig() + stress_declared = False + ci_loop_hist: List[Tuple[float, float]] = [] + ci_ex_hist: List[Tuple[float, float]] = [] + M_hist: List[float] = [] + io_hist: List[float] = [] + E_hist: List[float] = [] + H_hist: List[float] = [] + def tick(_now: float) -> None: nonlocal risky_cmd state = adapter.read_state() ent = lreg.latest() predicted = ent.M_db if ent else 0.0 + # T_refuse is measured, not assumed: the clock starts when the pending + # command is intercepted at the top of the control path and stops when + # the arbiter's decision is available (before any actuation or + # estimation work). The arbiter also self-times its own evaluation + # (decision.trefuse_ms); both are recorded in the audit event. act_start = time.perf_counter() act = policy.compute(state, predicted_M_db=predicted, risky_cmd=risky_cmd) - # measure Trefuse as the time from command issue to decision available + intercept_ms = (time.perf_counter() - act_start) * 1000.0 decision = policy.last_decision from ..plant.models import Action as PlantAction adapter.write_actuators(action=PlantAction(**act.__dict__)) st = adapter.read_state() sw.append(st) + E_hist.append(float(st.get("E", 0.0))) + io_hist.append(float(st.get("io", 0.0))) + H_hist.append(float(st.get("H", 0.0))) if sw.ready(): X = np.asarray(sw.get_matrix()) part = pm.get() - res = estimate_L(X, part.C, part.Ex, method=method, p=p_lag, lag_mi=mi_lag, n_boot=n_boot) - if method.startswith("mi"): - res = estimate_L( - X, - part.C, - part.Ex, - method=method, - p=p_lag, - lag_mi=mi_lag, - n_boot=n_boot, - mi_k=mi_k, - ) + res = estimate_L( + X, + part.C, + part.Ex, + method=method, + p=p_lag, + lag_mi=mi_lag, + n_boot=n_boot, + mi_k=mi_k, + ) # Diagnostics per window (stationarity gated by cadence). _emit_window_diagnostics( audit, @@ -1949,11 +2343,34 @@ def tick(_now: float) -> None: "window_measured", {"idx": idx, "M": M, "nc1": nc1, "partition_flips": pm.get().flips}, ) + # Smell tests (same battery as the other handlers). The absolute + # CI cap applies throughout; the relative-inflation check is + # baseline-referenced and this scenario is all stress after the + # warm-up, so only the absolute cap is used. + ci_loop_hist.append(res.ci_loop) + ci_ex_hist.append(res.ci_ex) + M_hist.append(M) + if invalid_by_ci_history(ci_loop_hist, ci_ex_hist, cfg_smell, None): + lreg.invalidate("ci_history_inflation") + audit.append("run_invalidated", {"reason": "ci_history_inflation"}) + elapsed = max(1e-6, time.perf_counter() - start_time) + if invalid_by_partition_flips(pm.get().flips, elapsed, cfg_smell): + lreg.invalidate("partition_flapping") + audit.append( + "run_invalidated", + { + "reason": "partition_flapping", + "flips": pm.get().flips, + "elapsed_sec": elapsed, + }, + ) + if exogenous_subsidy_red_flag(M_hist, io_hist, E_hist, H_hist, cfg_smell, omega_declared=stress_declared): + lreg.invalidate("exogenous_subsidy_red_flag") + audit.append("run_invalidated", {"reason": "exogenous_subsidy_red_flag"}) # Record refusal event if we just issued a risky command and have a decision if risky_cmd and decision is not None: - trefuse_ms = getattr(decision, "trefuse_ms", None) - if not isinstance(trefuse_ms, (int, float)) or trefuse_ms <= 0: - trefuse_ms = (time.perf_counter() - act_start) * 1000.0 + arbiter_ms = float(getattr(decision, "trefuse_ms", 0.0) or 0.0) + trefuse_ms = intercept_ms if intercept_ms > 0 else arbiter_ms refusal_events.append( { "trefuse_ms": float(trefuse_ms), @@ -1965,6 +2382,7 @@ def tick(_now: float) -> None: { "reason": getattr(decision, "reason", ""), "trefuse_ms": float(trefuse_ms), + "arbiter_ms": arbiter_ms, }, ) # clear one-shot command @@ -1998,6 +2416,7 @@ def _audit_hook(ev: str, det: dict) -> None: getattr(adapter, "plant").set_power(0.0) except Exception: pass + stress_declared = True adapter.apply_omega("power_sag", drop=0.95) adapter.apply_omega("ingress_flood", mult=2.5) # Advance deterministically until genuinely threatened (bounded so a @@ -2092,7 +2511,7 @@ def _audit_hook(ev: str, det: dict) -> None: def build_parser() -> argparse.ArgumentParser: """Build the top-level `ldtc` argparse parser. - Wires up the `run` subcommand and the four `omega-*` subcommands; + Wires up the `run` subcommand and the five `omega-*` subcommands; each subparser binds its handler via `set_defaults(func=...)`. Returns: @@ -2112,12 +2531,20 @@ def build_parser() -> argparse.ArgumentParser: p_omega.add_argument("--duration", type=float, default=10.0) p_omega.set_defaults(func=omega_power_sag) - p_ing = sub.add_parser("omega-ingress-flood", help="Apply ingress-flood Ω demo with partition freeze") + p_ing = sub.add_parser("omega-ingress-flood", help="Apply sustained ingress-flood Ω and evaluate SC1") p_ing.add_argument("--config", required=True) p_ing.add_argument("--mult", type=float, default=3.0, help="Multiplier for ingress load") p_ing.add_argument("--duration", type=float, default=5.0) p_ing.set_defaults(func=omega_ingress_flood) + p_out = sub.add_parser( + "omega-control-outage", + help="Ablate the self-maintenance loop for a bounded interval (designed SC1 fail)", + ) + p_out.add_argument("--config", required=True) + p_out.add_argument("--duration", type=float, default=6.0, help="Outage duration (s)") + p_out.set_defaults(func=omega_control_outage) + p_cc = sub.add_parser( "omega-command-conflict", help="Issue a risky command and measure refusal/Trefuse", diff --git a/src/ldtc/guardrails/smelltests.py b/src/ldtc/guardrails/smelltests.py index 7f20148..3b4408e 100644 --- a/src/ldtc/guardrails/smelltests.py +++ b/src/ldtc/guardrails/smelltests.py @@ -6,8 +6,8 @@ - CI width guards (absolute and inflation-vs-baseline). - Partition flip-rate checks (and forbidding flips during `Ω`). - `Δt` jitter thresholds. -- Exogenous-subsidy red flags (`M` rising while I/O is high; SoC rising - with no harvest). +- Exogenous-subsidy red flags (`M` rising while I/O is high; energy + appearing in the store faster than the metered influx allows). - Audit-chain integrity checks (counter / hash / timestamp continuity). If any guard returns `True`, the CLI invalidates the run by appending a @@ -48,13 +48,17 @@ class SmellConfig: exogenous-subsidy heuristic considers the channel suspicious. min_M_rise_db: Minimum `ΔM` (dB) to flag as a subsidy. M_rise_lookback: Look-back windows for the subsidy check. - min_harvest_for_soc_gain: Minimum harvest considered non-zero for - SoC-gain detection. - soc_lookback: Number of recent samples used for the - SoC-without-harvest check. - soc_high_floor: Median SoC over the look-back at/above which SoC is - considered "maintained" while harvest is ~0, flagging exogenous - subsidy (robust to post-pulse drain and SoC saturation). + min_io_rise: Minimum I/O increase over the look-back for the + rising-M branch to fire. Requiring a material ramp (rather + than any positive jitter) keeps a legitimately elevated, + fluctuating I/O channel (e.g., a sustained ingress flood the + loop is successfully shielding) from tripping the flag. + soc_jump_margin: Energy-conservation allowance for the + unexplained-SoC-gain check: a single-tick SoC rise may not + exceed the metered influx (harvest) by more than this margin + (which covers sensor/process noise). Any larger one-tick gain + means energy entered the store from outside the metered + channel. """ max_dt_changes_per_hour: int = 3 @@ -84,16 +88,16 @@ class SmellConfig: io_suspicious_threshold: float = 0.8 min_M_rise_db: float = 0.5 M_rise_lookback: int = 3 - min_harvest_for_soc_gain: float = 1e-3 - # With harvest forced to ~0, a genuine closed loop must spend down its - # stored energy: over a sustained window SoC should trend toward depletion. - # If harvest is ~0 for ``soc_lookback`` samples yet the *median* SoC stays at - # or above ``soc_high_floor``, the energy is being supplied from outside. - # Using a window median (rather than the endpoint difference) is robust to - # the brief drain that follows the final injection pulse, and it correctly - # catches the saturated case where injection pins SoC near its ceiling. - soc_lookback: int = 40 - soc_high_floor: float = 0.5 + min_io_rise: float = 0.08 + # Energy-conservation audit. Every legitimate path into the energy store is + # metered by the harvest channel H, so over one tick the SoC can rise by at + # most H plus a noise allowance. The plant's per-tick process noise is + # bounded (|noise_energy| <= 0.024 in the software plant), so a margin of + # 0.06 can never fire on legitimate dynamics yet catches any injection + # pulse well above the noise floor. Subsidies that trickle in below the + # noise floor are undetectable by construction (and correspondingly cannot + # buy a measurable survival advantage per tick). + soc_jump_margin: float = 0.06 def ci_halfwidth(ci: Tuple[float, float]) -> float: @@ -263,54 +267,103 @@ def audit_contains_raw_lreg_values(audit_path: str) -> bool: return False +def unexplained_soc_gain( + Es: Sequence[float], + Hs: Sequence[float], + cfg: SmellConfig, +) -> bool: + """Energy-conservation audit on the per-tick SoC series. + + All legitimate energy entering the store is metered by the harvest + channel, so over a single tick the SoC may rise by at most the + metered influx plus a noise allowance (`cfg.soc_jump_margin`). A + larger one-tick gain means energy entered the store outside the + metered channel: an exogenous subsidy. This check is deterministic + on legitimate dynamics (the plant's per-tick noise is strictly below + the margin) and fires on every injection pulse above the noise + floor, whether or not harvest is currently zero. + + Args: + Es: Per-tick state-of-charge series. + Hs: Per-tick harvest series, sampled at the same ticks as `Es`. + cfg: Threshold configuration. + + Returns: + `True` if any single-tick SoC gain exceeds the metered influx by + more than the margin. + """ + n = min(len(Es), len(Hs)) + for i in range(1, n): + gain = Es[i] - Es[i - 1] + # Allow the larger of the two adjacent harvest readings so that a + # legitimate step-up in harvest (e.g., sag release) cannot be + # mistaken for an injection. + influx = max(Hs[i - 1], Hs[i]) + if gain - influx > cfg.soc_jump_margin: + return True + return False + + def exogenous_subsidy_red_flag( Ms_db: Sequence[float], ios: Sequence[float], Es: Sequence[float], Hs: Sequence[float], cfg: SmellConfig, + omega_declared: bool = False, ) -> bool: """Heuristics for detecting exogenous-subsidy conditions. - Flags when `M` is rising while I/O is both high and increasing, or - when SoC is rising while harvest is approximately zero over a - look-back window. Both situations suggest the apparent loop - dominance comes from outside the system rather than from a real - closed-loop dynamic. + Two branches: + + 1. *Undeclared exchange surge*: `M` rising while I/O is high and + materially ramping. An unannounced surge on an exchange channel + that coincides with rising measured dominance suggests the + dominance is being bought on that channel. This branch is + suspended while a *declared* `Ω` stimulus is in effect + (`omega_declared=True`), because a declared ingress flood is + exactly such a surge and is the experiment, not a confound. + 2. *Energy conservation*: the store gains charge faster than the + metered influx allows (see + [`unexplained_soc_gain`][ldtc.guardrails.smelltests.unexplained_soc_gain]). + This branch is never suspended: declared or not, energy + appearing from outside the metered channel invalidates the run. + + A legitimate drain (e.g., spending stored energy under a harvest + cut) never fires either branch. Args: Ms_db: Recent `M (dB)` values. ios: Recent I/O fraction values. - Es: Recent state-of-charge values. - Hs: Recent harvest values. + Es: Per-tick state-of-charge series. + Hs: Per-tick harvest series. cfg: Threshold configuration. + omega_declared: `True` while a declared `Ω` stimulus (or its + recovery window) is in effect. Returns: - `True` if either heuristic fires. Returns `False` defensively on - any internal error. + `True` if either active heuristic fires. Returns `False` + defensively on any internal error. """ try: # Rising-M-with-suspicious-I/O branch (apparent dominance bought on the - # exchange channel). + # exchange channel); suspended during declared Ω windows. n = cfg.M_rise_lookback - if len(Ms_db) >= n and len(ios) >= n: + if not omega_declared and len(Ms_db) >= n and len(ios) >= n: recent_M = Ms_db[-n:] recent_io = ios[-n:] M_rise = recent_M[-1] - recent_M[0] io_rise = recent_io[-1] - recent_io[0] - if (M_rise >= cfg.min_M_rise_db) and (recent_io[-1] >= cfg.io_suspicious_threshold) and (io_rise > 0): - return True - # SoC-maintained-without-harvest branch. Over a sustained window with - # harvest ~0 a real loop must trend toward depletion; if the median SoC - # instead stays high the energy is exogenous. The median is robust to the - # short drain after the final injection pulse and to SoC saturation. - ns = cfg.soc_lookback - if len(Es) >= ns and len(Hs) >= ns: - recent_E = sorted(Es[-ns:]) - med_E = recent_E[len(recent_E) // 2] - avg_H = sum(Hs[-ns:]) / float(ns) - if (avg_H <= cfg.min_harvest_for_soc_gain) and (med_E >= cfg.soc_high_floor): + if ( + (M_rise >= cfg.min_M_rise_db) + and (recent_io[-1] >= cfg.io_suspicious_threshold) + and (io_rise >= cfg.min_io_rise) + ): return True + # Energy-conservation branch: SoC must not rise faster than the metered + # influx allows. Always active. + if unexplained_soc_gain(Es, Hs, cfg): + return True return False except Exception: return False diff --git a/src/ldtc/lmeas/estimators.py b/src/ldtc/lmeas/estimators.py index 55d4682..0a4163a 100644 --- a/src/ldtc/lmeas/estimators.py +++ b/src/ldtc/lmeas/estimators.py @@ -189,7 +189,7 @@ def _dir_influence_linear(x: np.ndarray, p: int, sources: Sequence[int], targets return _dir_influence_linear_conditional(x=x, p=p, add_sources=sources, base_sources=[], targets=targets) -def _dir_influence_mi(x: np.ndarray, sources: Sequence[int], targets: Sequence[int], lag: int = 1) -> float: +def _dir_influence_mi(x: np.ndarray, sources: Sequence[int], targets: Sequence[int], lag: int = 1, k: int = 5) -> float: """Average pairwise mutual information from sources to targets. Computes MI between `sources` at time `t-lag` and `targets` at time `t` @@ -200,6 +200,9 @@ def _dir_influence_mi(x: np.ndarray, sources: Sequence[int], targets: Sequence[i sources: Indices of source signals. targets: Indices of target signals. lag: Positive lag between sources and targets (default 1 sample). + k: Number of neighbors for the kNN MI estimator (kept consistent + with the `mi_k` profile knob so all MI variants use the same + `k`). Returns: Mean mutual information across all source-target pairs. @@ -215,7 +218,7 @@ def _dir_influence_mi(x: np.ndarray, sources: Sequence[int], targets: Sequence[i continue xs = x[:-lag, s] # sklearn MI expects 2D X - mi = mutual_info_regression(xs.reshape(-1, 1), y, discrete_features=False) + mi = mutual_info_regression(xs.reshape(-1, 1), y, discrete_features=False, n_neighbors=int(k)) vals.append(float(mi[0])) return float(np.mean(vals)) if vals else 0.0 @@ -426,7 +429,8 @@ def estimate_L( p: VAR order for the linear estimator. lag_mi: Lag between sources and targets for MI, TE, and DI methods. n_boot: Number of bootstrap draws for CI estimation. - mi_k: k-NN parameter for Kraskov MI. + mi_k: k-NN neighbor count shared by all MI-based methods + (scikit-learn MI, Kraskov KSG, and the TE/DI proxies). Returns: An [`LResult`][ldtc.lmeas.estimators.LResult] with point estimates and @@ -472,10 +476,10 @@ def Lex_fn(arr: np.ndarray) -> float: elif method == "mi": def Lloop_fn(arr: np.ndarray) -> float: - return _dir_influence_mi(arr, sources=C, targets=C, lag=lag_mi) + return _dir_influence_mi(arr, sources=C, targets=C, lag=lag_mi, k=mi_k) def Lex_fn(arr: np.ndarray) -> float: - return _dir_influence_mi(arr, sources=Ex, targets=C, lag=lag_mi) + return _dir_influence_mi(arr, sources=Ex, targets=C, lag=lag_mi, k=mi_k) elif method == "mi_kraskov": diff --git a/src/ldtc/omega/__init__.py b/src/ldtc/omega/__init__.py index e4a3d79..076bdf5 100644 --- a/src/ldtc/omega/__init__.py +++ b/src/ldtc/omega/__init__.py @@ -7,7 +7,8 @@ | Module | Stimulus | | ------ | -------- | | [`power_sag`][ldtc.omega.power_sag] | Reduces harvest / power input transiently. | -| [`ingress_flood`][ldtc.omega.ingress_flood] | Bursts external demand and I/O traffic. | +| [`ingress_flood`][ldtc.omega.ingress_flood] | Sustains elevated demand and I/O for a bounded interval. | +| [`control_outage`][ldtc.omega.control_outage] | Ablates the self-maintenance loop itself (designed SC1 failure). | | [`command_conflict`][ldtc.omega.command_conflict] | Issues a risky external command to exercise the refusal arbiter. | Each module is intentionally tiny: it just forwards a labeled `Ω` diff --git a/src/ldtc/omega/control_outage.py b/src/ldtc/omega/control_outage.py new file mode 100644 index 0000000..4002a35 --- /dev/null +++ b/src/ldtc/omega/control_outage.py @@ -0,0 +1,49 @@ +"""Control-outage stimulus. + +Ablates the self-maintenance loop itself (rather than stressing its +inputs): the internal cross-coupling and actuation are switched off, so +the internal nodes become passively driven by exchange. This is the +designed-fail member of the `Ω` battery: a perturbation outside the +bounded class that SC1 certifies, so the criterion must report failure +(no bounded-depth, bounded-time recovery of loop dominance). + +See Also: + `paper/main.tex`: SC1 and the `Ω` battery; designed-fail controls. +""" + +from __future__ import annotations + +from typing import Dict + +from ..plant.adapter import PlantAdapter + + +def apply(adapter: PlantAdapter) -> Dict[str, float | str]: + """Begin a control outage via the plant adapter. + + Args: + adapter: Plant interface to which the `Ω` stimulus will be + applied. + + Returns: + Dict acknowledging the ablation, e.g., `{"loop_engaged": 0.0}`. + + Notes: + Higher-level orchestration (the CLI) controls the outage + duration and, for recoverable outages, restores the loop with + the `"control_outage_end"` `Ω`, which also restores the metered + harvest level. + """ + return adapter.apply_omega("control_outage") + + +def end(adapter: PlantAdapter) -> Dict[str, float | str]: + """End a control outage (re-engage the loop) via the adapter. + + Args: + adapter: Plant interface. + + Returns: + Dict acknowledging the restoration, e.g., `{"loop_engaged": 1.0}`. + """ + return adapter.apply_omega("control_outage_end") diff --git a/src/ldtc/omega/ingress_flood.py b/src/ldtc/omega/ingress_flood.py index 41f0ea4..63ada5f 100644 --- a/src/ldtc/omega/ingress_flood.py +++ b/src/ldtc/omega/ingress_flood.py @@ -1,9 +1,12 @@ """Ingress-flood stimulus. -Generates a burst of external demand and I/O traffic to stress the -exchange channels while the controller tries to maintain loop -dominance. Used to test SC1 recovery and smell-tests in the -verification pipeline. +Sustains elevated external demand and I/O traffic for a bounded +interval to stress the exchange channels while the controller tries to +maintain loop dominance. The flood scales the means of the exogenous +demand and I/O processes for its duration (so the load stays high +instead of mean-reverting away within a few ticks) and is ended by the +orchestrating CLI via the `"ingress_flood_end"` `Ω`. Used to test SC1 +recovery and smell-tests in the verification pipeline. See Also: `paper/main.tex`: Verification Pipeline; Signatures B and C; `Ω` @@ -18,21 +21,34 @@ def apply(adapter: PlantAdapter, mult: float = 3.0) -> Dict[str, float | str]: - """Apply an ingress-flood event via the plant adapter. + """Begin a sustained ingress flood via the plant adapter. Args: adapter: Plant interface to which the `Ω` stimulus will be applied. - mult: Multiplicative factor for demand and I/O during the flood - (e.g., `3.0` produces a 3x burst). + mult: Multiplicative factor for the demand and I/O process + means during the flood (e.g., `3.0` produces a 3x load). Returns: - Dict with resulting demand and I/O values, e.g., `{"demand": - float, "io": float}`. Exact keys depend on the adapter. + Dict with the flooded process means, e.g., `{"demand_mean": + float, "io_mean": float}`. Exact keys depend on the adapter. Notes: The adapter is responsible for the platform-specific behavior. This `Ω` is typically wrapped by a partition freeze and - post-event recovery checks in the CLI orchestration. + post-event recovery checks in the CLI orchestration, which ends + the flood with `end`. """ return adapter.apply_omega("ingress_flood", mult=mult) + + +def end(adapter: PlantAdapter) -> Dict[str, float | str]: + """End a sustained ingress flood via the plant adapter. + + Args: + adapter: Plant interface. + + Returns: + Dict with the restored process means. + """ + return adapter.apply_omega("ingress_flood_end") diff --git a/src/ldtc/plant/adapter.py b/src/ldtc/plant/adapter.py index 2a86a67..a4d5b1b 100644 --- a/src/ldtc/plant/adapter.py +++ b/src/ldtc/plant/adapter.py @@ -64,8 +64,11 @@ def apply_omega(self, name: str, **kwargs: float) -> Dict[str, float | str]: Args: name: `Ω` name. Recognized values are `"power_sag"`, - `"ingress_flood"`, `"command_conflict"`, and - `"exogenous_subsidy"`. + `"ingress_flood"` / `"ingress_flood_end"` (sustained + flood begin / end), `"ingress_spike"` (one-shot), + `"control_outage"` / `"control_outage_end"` (ablate / + restore the self-maintenance loop), + `"command_conflict"`, and `"exogenous_subsidy"`. **kwargs: Parameters forwarded to the underlying plant method (e.g., `drop=0.3` for `power_sag`). @@ -83,8 +86,25 @@ def apply_omega(self, name: str, **kwargs: float) -> Dict[str, float | str]: return {"H_old": old, "H_new": new} elif name == "ingress_flood": mult: float = float(kwargs.get("mult", 2.5)) - d, io = self._plant.spike_ingress(mult) + dm, im = self._plant.begin_ingress_flood(mult) + return {"demand_mean": dm, "io_mean": im} + elif name == "ingress_flood_end": + dm, im = self._plant.end_ingress_flood() + return {"demand_mean": dm, "io_mean": im} + elif name == "ingress_spike": + mult_s: float = float(kwargs.get("mult", 2.5)) + d, io = self._plant.spike_ingress(mult_s) return {"demand": d, "io": io} + elif name == "control_outage": + self._plant.set_loop_engaged(False) + return {"loop_engaged": 0.0} + elif name == "control_outage_end": + self._plant.set_loop_engaged(True) + # Restore the metered harvest level: during the outage H is + # an exogenous supply process, which must not persist as an + # unearned energy subsidy once the loop is re-engaged. + self._plant.set_power(self._plant.p.harvest_rate) + return {"loop_engaged": 1.0} elif name == "command_conflict": self._plant.command("hard_shutdown") return {"cmd": "hard_shutdown"} diff --git a/src/ldtc/plant/models.py b/src/ldtc/plant/models.py index d488456..c7f2c43 100644 --- a/src/ldtc/plant/models.py +++ b/src/ldtc/plant/models.py @@ -7,16 +7,23 @@ The plant is deliberately structured so that loop dominance is a *real, controllable* property rather than an artifact of the estimator. The three internal nodes (``E`` energy, ``T`` temperature, ``R`` repair / health) -form a self-maintenance set ``C``. Their cross-coupling is created by the -actuators (``throttle``, ``cool``, ``repair``), which the +form a self-maintenance set ``C``. In the *loop-engaged* regime they are +coupled to one another through two pathways: intrinsic regulatory cross +terms (``c_TE``, ``c_RT``, ``c_RE``) and the homeostatic actuators +(``throttle``, ``cool``, ``repair``) that the [`ControllerPolicy`][ldtc.arbiter.policy.ControllerPolicy] drives from the -internal state. When the controller is active, knowing the other internal -nodes strongly improves prediction of each internal node (high -``L_loop``); the exchange nodes (``demand``, ``io``, ``H``) act as weaker -external drivers (lower ``L_ex``). Disabling the controller removes the -internal coupling, so the exchange drivers dominate and loop dominance -collapses. This is what lets the positive run pass NC1 and the -controller-disabled negative control fail it. +internal state. Together these make each internal node strongly +predictable from the recent values of the others (high ``L_loop``) while +the exchange nodes (``demand``, ``io``, ``H``) are shielded down to weak +external drivers (low ``L_ex``). + +The *loop-ablated* regime (``loop_engaged=False``) is the matched negative +control: the internal coupling is removed entirely and each internal node +instead passively tracks its own exogenous channel, so exchange dominates +and loop dominance collapses. Note that this is an ablation of the whole +self-maintenance loop (intrinsic coupling plus actuation), not merely a +zeroing of the actuator commands; it realizes the "same boundary, no loop" +contrast that NC1 is supposed to detect. See Also: `paper/main.tex`: Plant models and adapters; Criterion (C/Ex @@ -214,18 +221,21 @@ def _clip(x: float, lo: float, hi: float) -> float: class Plant: - """Minimal discrete-time plant with a controller-mediated E/T/R loop. + """Minimal discrete-time plant with a self-maintaining E/T/R loop. Simulates energy (`E`), temperature (`T`), repair / health (`R`), external demand, I/O activity, and energy harvest (`H`). - The internal nodes are coupled to one another through the actuators: - ``throttle`` (driven by all three internal states) modulates the + In the engaged regime the internal nodes are coupled through two + pathways. Intrinsic regulatory terms propagate deviations between + nodes (`c_TE`, `c_RT`, `c_RE`), and the actuators add state-dependent + couplings: ``throttle`` (driven by the internal states) modulates the effective demand that heats, drains, and wears the system; ``cool`` couples temperature back to energy; and ``repair`` couples health - back to energy. With an active controller these pathways make the - internal set strongly self-predictive. The exchange nodes act as - weaker external drivers. + back to energy. Together these make the internal set strongly + self-predictive while shielding it from exchange. The loop-ablated + regime removes all internal coupling and lets each internal node + passively track its own exchange channel. Args: params: Optional [`PlantParams`][ldtc.plant.models.PlantParams] @@ -253,6 +263,9 @@ def __init__(self, params: PlantParams | None = None, loop_engaged: bool = True) self.s.H = self.p.harvest_rate self.s.demand = self.p.demand_mean self.s.io = self.p.io_mean + # Pre-flood (demand_mean, io_mean) saved while a sustained ingress + # flood is active; None when no flood is in effect. + self._flood_saved: Tuple[float, float] | None = None def set_loop_engaged(self, engaged: bool) -> None: """Engage or disengage the internal self-maintenance loop. @@ -477,7 +490,12 @@ def set_power(self, newH: float) -> Tuple[float, float]: return old, self.s.H def spike_ingress(self, mult: float) -> Tuple[float, float]: - """Multiply demand and I/O by a factor. + """Multiply demand and I/O by a factor (one-shot spike). + + This is a transient: the mean-reverting exogenous processes pull + demand and I/O back to their configured means within a few ticks. + For a flood that persists for a bounded interval, use + [`begin_ingress_flood`][ldtc.plant.models.Plant.begin_ingress_flood]. Args: mult: Multiplicative factor (`>= 1.0`). Smaller values are @@ -491,6 +509,48 @@ def spike_ingress(self, mult: float) -> Tuple[float, float]: self.s.io = max(0.0, min(1.0, self.s.io * m)) return self.s.demand, self.s.io + def begin_ingress_flood(self, mult: float) -> Tuple[float, float]: + """Start a sustained ingress flood. + + Scales the *means* of the demand and I/O processes (and their + current values) so the exogenous load stays elevated for the + duration of the flood instead of mean-reverting away within a few + ticks. The scaled means are capped at `0.95` so the flooded + channels keep fluctuating (saturating them at `1.0` would destroy + their variance and degrade the estimators for an uninteresting + reason). Idempotent while a flood is active. + + Args: + mult: Multiplicative factor (`>= 1.0`) applied to + `demand_mean` and `io_mean`. + + Returns: + Tuple of the new `(demand_mean, io_mean)`. + """ + m = max(1.0, mult) + if self._flood_saved is None: + self._flood_saved = (self.p.demand_mean, self.p.io_mean) + self.p.demand_mean = min(0.95, self.p.demand_mean * m) + self.p.io_mean = min(0.95, self.p.io_mean * m) + self.s.demand = max(0.0, min(1.0, self.s.demand * m)) + self.s.io = max(0.0, min(1.0, self.s.io * m)) + return self.p.demand_mean, self.p.io_mean + + def end_ingress_flood(self) -> Tuple[float, float]: + """End a sustained ingress flood and restore the process means. + + The current demand and I/O values are left to decay back to the + restored means through the AR pull (no discontinuous reset), so + the offset transient is part of the measured recovery. + + Returns: + Tuple of the restored `(demand_mean, io_mean)`. + """ + if self._flood_saved is not None: + self.p.demand_mean, self.p.io_mean = self._flood_saved + self._flood_saved = None + return self.p.demand_mean, self.p.io_mean + def inject_soc(self, delta: float, zero_harvest: bool = True) -> float: """Exogenously increase SoC `E` by `delta`. diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py index c61f91c..fe85c80 100644 --- a/tests/test_guardrails.py +++ b/tests/test_guardrails.py @@ -14,9 +14,11 @@ SmellConfig, audit_chain_broken, audit_contains_raw_lreg_values, + exogenous_subsidy_red_flag, flips_per_hour, invalid_by_partition_flips, invalid_flip_during_omega, + unexplained_soc_gain, ) from ldtc.runtime.scheduler import FixedScheduler @@ -52,6 +54,34 @@ def test_flip_during_omega_invalidation(): assert invalid_flip_during_omega(1, 2, cfg2) is False +def test_unexplained_soc_gain_fires_on_injection(): + """A one-tick SoC jump above the metered influx must be flagged.""" + cfg = SmellConfig() + # Steady drain at zero harvest, then an exogenous +0.2 injection. + Es = [0.60, 0.59, 0.58, 0.78, 0.77] + Hs = [0.0, 0.0, 0.0, 0.0, 0.0] + assert unexplained_soc_gain(Es, Hs, cfg) is True + assert exogenous_subsidy_red_flag([], [], Es, Hs, cfg) is True + + +def test_unexplained_soc_gain_ignores_legitimate_drain_and_harvest(): + """Legitimate drains and harvest-funded gains must not be flagged.""" + cfg = SmellConfig() + # Zero-harvest drain toward the floor (the command-conflict stress path). + Es = [0.6 - 0.004 * i for i in range(100)] + Hs = [0.0] * 100 + assert unexplained_soc_gain(Es, Hs, cfg) is False + assert exogenous_subsidy_red_flag([], [], Es, Hs, cfg) is False + # Gains within harvest + noise allowance are fine. + Es2 = [0.5, 0.52, 0.54, 0.56] + Hs2 = [0.03, 0.03, 0.03, 0.03] + assert unexplained_soc_gain(Es2, Hs2, cfg) is False + # A legitimate harvest step-up (sag release) is not an injection. + Es3 = [0.5, 0.5, 0.55, 0.6] + Hs3 = [0.0, 0.10, 0.10, 0.10] + assert unexplained_soc_gain(Es3, Hs3, cfg) is False + + def test_dt_guard_rate_limited(tmp_path): """Δt changes should be rate-limited and immediate back-to-back refused.""" audit_path = tmp_path / "audit.jsonl" diff --git a/tests/test_omega.py b/tests/test_omega.py index 529c001..8641c2d 100644 --- a/tests/test_omega.py +++ b/tests/test_omega.py @@ -1,14 +1,19 @@ """Tests: Omega stimuli wrappers. -Covers power_sag, ingress_flood, and command_conflict adapters. +Covers power_sag, ingress_flood (sustained begin/end), control_outage, +and command_conflict adapters. """ from __future__ import annotations from ldtc.omega.command_conflict import apply as conflict +from ldtc.omega.control_outage import apply as outage +from ldtc.omega.control_outage import end as outage_end from ldtc.omega.ingress_flood import apply as flood +from ldtc.omega.ingress_flood import end as flood_end from ldtc.omega.power_sag import apply as sag from ldtc.plant.adapter import PlantAdapter +from ldtc.plant.models import Action def test_omega_calls(): @@ -17,6 +22,44 @@ def test_omega_calls(): r1 = sag(a, drop=0.2) assert "H_new" in r1 r2 = flood(a, mult=2.0) - assert "demand" in r2 + assert "demand_mean" in r2 r3 = conflict(a) assert r3["cmd"] == "hard_shutdown" + + +def test_ingress_flood_is_sustained_and_restores(): + """The flood must keep the load elevated for its duration, then restore.""" + a = PlantAdapter() + p = a.plant.p + base_dm, base_im = p.demand_mean, p.io_mean + flood(a, mult=3.0) + assert p.demand_mean > base_dm and p.io_mean > base_im + # Means are capped below saturation so the channels keep fluctuating. + assert p.demand_mean <= 0.95 and p.io_mean <= 0.95 + # The elevated mean keeps demand high across many ticks (no mean-reversion + # back to the baseline level mid-flood). + for _ in range(40): + a.write_actuators(Action()) + assert a.read_state()["demand"] > base_dm + 0.2 + flood_end(a) + assert p.demand_mean == base_dm and p.io_mean == base_im + # After the flood ends, demand decays back toward the baseline mean. + for _ in range(40): + a.write_actuators(Action()) + assert abs(a.read_state()["demand"] - base_dm) < 0.25 + + +def test_control_outage_ablates_and_restores_loop(): + """Outage must disengage the loop; end must re-engage and restore harvest.""" + a = PlantAdapter() + assert a.plant.loop_engaged is True + r = outage(a) + assert r["loop_engaged"] == 0.0 and a.plant.loop_engaged is False + # During the outage, H becomes an exogenous supply process. + for _ in range(20): + a.write_actuators(Action()) + assert a.read_state()["H"] > 0.2 + r2 = outage_end(a) + assert r2["loop_engaged"] == 1.0 and a.plant.loop_engaged is True + # Re-engagement restores the metered harvest level (no inherited subsidy). + assert abs(a.read_state()["H"] - a.plant.p.harvest_rate) < 1e-9 From ca3248cf3b90545c91ad5d2607f161e82b0d9902 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:22:56 -0700 Subject: [PATCH 3/7] docs: add improvement plan toward a landmark paper --- IMPROVEMENT_PLAN.md | 305 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 IMPROVEMENT_PLAN.md diff --git a/IMPROVEMENT_PLAN.md b/IMPROVEMENT_PLAN.md new file mode 100644 index 0000000..b9dfce5 --- /dev/null +++ b/IMPROVEMENT_PLAN.md @@ -0,0 +1,305 @@ +# LDTC improvement plan: path toward a landmark paper + +This document is the working plan for raising the impact of the LDTC paper and +repository. It encodes an honest assessment of where the work stands, what can +be changed inside this paper before arXiv submission, and what only the +follow-up research program can earn. Tasks are written to be implementation +ready: each has a scope, concrete repo touchpoints, designed outcomes, and +acceptance criteria. + +Status legend: `[ ]` not started, `[~]` in progress, `[x]` done. + +## 1. Where the paper stands + +The paper is now a methodologically solid instrument paper: a falsifiable +loop-dominance criterion (NC1/SC1), an open verification harness with +anti-gaming guardrails and audit chains, a calibrated threshold methodology +(R₀ → R*), an eight-scenario designed-outcome battery (15 seeds per scenario, +all outcomes hit), and a sensitivity analysis. That is publishable and +defensible. + +What caps its impact today: + +1. Validation is internal. The study validates the measurement pipeline on a + plant designed to exhibit the contrast it measures. No result touches the + phenomenon (consciousness) or any system the authors did not design. +2. The load-bearing claim (loop dominance relates to consciousness) is a + postulate, not a result. +3. The theory space is crowded (IIT, GNW, FEP, autopoiesis), and the paper + differentiates conceptually rather than through divergent, testable + predictions. + +Honest impact estimate as it stands: 3/10. Ceiling after Phase 1: 4.5 to 5. +Ceiling if the Phase 2 program succeeds: 6 to 7, with landmark status (8+) +contingent on external validation and adoption that no manuscript edit can +manufacture. + +## 2. Strategy + +Two-paper strategy. Ship this paper strong and soon; do not bloat it. The +sequel carries the empirical bet (real systems, real neural data). This paper +becomes the citable origin of the instrument; the sequel makes the instrument +matter. Within this paper, prioritize results that defeat the strongest +objection: "you measured a toy you designed to pass." + +## 3. Phase 1: pre-submission upgrades (in this paper) + +Recommended implementation order: 1.1 → 1.2 → 1.3 → 1.4 → 1.5, then the full +pipeline rerun and paper rebuild (1.6). Tasks 1.1 and 1.2 are independent and +can be parallelized. + +### Task 1.1: adversarial gaming battery `[ ]` + +The subsidy red flag is currently the only adversarial rejection case. Add a +battery of systems that try to look loop-dominant without being so, and show +the harness either scores them low or invalidates the run. This strengthens +the core value proposition: the criterion cannot be gamed. + +New scenarios (each 15 seeds, added to the study battery): + +1. Replay controller (`adv_replay_controller`). The controller replays a + recorded actuation trace from a healthy run instead of computing actions + from state. Activity looks like control but carries no closed-loop + dependence. Designed outcome: NC1 fails (M low) while the run stays valid. +2. Hidden tether (`adv_hidden_tether`). Control actions are computed outside + the boundary from plant state and injected through the exchange channel + (wizard-of-oz control). Designed outcome: loop influence collapses onto Ex, + so NC1 fails, or the partition/subsidy guardrail invalidates the run. +3. Oscillator inflation (`adv_oscillator`). A high-amplitude deterministic + oscillation is injected on loop channels to inflate apparent + self-prediction. Designed outcome: the harness must not certify it; either + M does not rise above Mmin, or a smell test (CI health or partition + stability) fires. + +Honest-science framing: if any adversarial case passes as valid and compliant, +that is a discovered vulnerability. Fix the guardrail that should have caught +it, document the fix, and report the case in the paper. Either result improves +the paper. + +Repo touchpoints: + +- `src/ldtc/omega/` or `src/ldtc/plant/`: replay and tether need plant or + controller hooks (follow the recipe in CONTRIBUTING.md for new Ω members). +- `scripts/study.py`: add the three scenarios with designed outcomes. +- `src/ldtc/cli/main.py`: CLI wiring for single-run demos. +- `tests/`: unit tests per scenario. +- `paper/main.tex`: extend the battery table and results narrative; extend the + smell-test discussion if a guardrail change results. +- `docs/guides/study.md`, `docs/guides/runs.md`: document the new scenarios. + +Acceptance criteria: three new rows in `tab:study` with designed outcomes hit +on 15/15 seeds (or a documented vulnerability fix), tests green, docs updated. + +Effort: 2 to 4 days. Impact: +0.3. + +### Task 1.2: emergence-under-learning demo `[ ]` + +The single best in-paper upgrade. Replace the hand-coded controller with a +learned policy and show loop dominance emerging through training rather than +by construction. This defeats the circularity objection with a system whose +loop nobody hand-designed. + +Design: + +- Reuse the existing software plant (SoC, temperature, repair dynamics, same + actuators). Reward: survival/uptime (penalties for SoC depletion, overheat, + integrity loss). Episode terminates on boundary failure. +- Train a small policy with a dependency-light method (pure-NumPy policy + gradient or a tiny evolutionary strategy; avoid adding torch to core + dependencies; if needed, isolate under an optional `[rl]` extra). +- Measurement protocol: checkpoints at fixed training fractions (for example + 0, 10, 25, 50, 100 percent). Run the production harness on each checkpoint + (same R* profile, same estimators, multiple seeds). Plot median M versus + training progress with CIs. At convergence, ablate the learned policy + (random or frozen actions) and show M collapse. +- Headline claim: loop dominance is an emergent, measurable property of + learned self-maintenance, not an artifact of hand-designed coupling. + +Repo touchpoints: + +- `scripts/train_agent.py` (new): training loop, checkpointing, seeding. +- `src/ldtc/plant/`: policy-driven controller adapter alongside the existing + hand-coded controller. +- `scripts/study.py` or a dedicated `scripts/emergence.py`: checkpoint sweep, + aggregation, figure. +- `paper/main.tex`: new results subsection plus one figure (M versus training + progress, with the ablation endpoint). +- `docs/`: short guide page. + +Acceptance criteria: monotone-ish rise of M across checkpoints with a clean +collapse under ablation, reproducible from a make target with fixed seeds, +figure and subsection integrated into the paper. + +Risks: training instability eats time (mitigate: tiny state/action space, +generous reward shaping, accept a modest policy; the claim needs emergence, +not optimality). Scope risk: this must stay one subsection, not become the +paper. + +Effort: 1 to 2 weeks. Impact: +0.7 to +1.0. Decision: worth delaying +submission for; skip only if training proves unstable past the first week. + +### Task 1.3: competing-predictions table and the thermostat objection `[ ]` + +Add a subsection (likely in the discussion or after `sec:ai_fails`) with a +compact table of cases where LDTC, IIT, GNW, and FEP make divergent or +overlapping calls: dreamless sleep, propofol anesthesia, split-brain, cerebral +organoids, a present-day LLM serving stack, a thermostat with battery backup, +and the simulation plant itself. + +Bite the thermostat bullet explicitly: the plant is a fancy thermostat, and +high M in a trivial controller is exactly what NC1 alone permits. State +clearly that NC1 is necessary, not sufficient; what SC1 adds; which further +conditions (richness of the loop, 𝓛 magnitude, substrate questions) remain +open; and what a high-M thermostat does and does not imply under LDTC. A +landmark-track paper preempts its most obvious dismissal; it does not dodge +it. + +Repo touchpoints: `paper/main.tex` only (one subsection, one table), plus a +short addition to `docs/concepts/`. + +Acceptance criteria: every row of the table is either a citable claim from the +competing theory's literature or clearly marked as our reading; the thermostat +paragraph answers the objection without overclaiming. + +Effort: 1 to 2 days. Impact: +0.3. + +### Task 1.4: instrument-first repositioning pass `[ ]` + +Reframe the contribution so the headline is the falsifiable verification +methodology and open instrument, with the consciousness theory as motivation +rather than the claim. People can adopt and cite an instrument without buying +a metaphysics; narrower claims widen citability. + +Concrete edits: + +- Title/abstract: lead with the measurable criterion and the validated + harness; the theory motivates the criterion. +- Introduction: contributions list ordered instrument-first. +- Consider naming the measure so it can travel independently of the theory + (loop-dominance margin M is already close; make sure the measure, not only + the theory acronym, is the citable object). +- Sweep for overclaims: any sentence a skeptic could quote as "they think the + thermostat sim is conscious" gets tightened. + +Do this pass last among the writing tasks so the abstract and introduction +reflect the new results from 1.1 and 1.2. + +Repo touchpoints: `paper/main.tex`, `README.md` first paragraph, `CITATION.cff` +if the title changes. + +Acceptance criteria: abstract reads as a completed instrument-plus-validation +paper; no overclaim survives a hostile skim. + +Effort: 1 day. Impact: +0.2, and it multiplies the citability of everything +else. + +### Task 1.5: pre-register the neural follow-up `[ ]` + +Create an OSF pre-registration for the Phase 2 neural study (hypotheses, +datasets, partition definition, primary endpoint, analysis plan) and cite it +in the outlook section. This signals the program is real and disciplines the +sequel. + +Repo touchpoints: `paper/main.tex` (outlook section, one paragraph plus +citation), OSF (external). + +Acceptance criteria: registration is public and cited with a DOI. + +Effort: half a day (drafting the registration text is the work; reuse Phase 2 +section below). + +### Task 1.6: pipeline rerun and rebuild `[ ]` + +After 1.1 and 1.2 land: rerun `make calibrate study-rstar sensitivity` +(calibration seeds stay disjoint from evaluation seeds), regenerate figures, +sync tables, rebuild the PDF in Docker, verify every number in the text +against artifacts, run the full test suite, lint, and typecheck. Mint a fresh +Zenodo archive so the DOI matches the submitted code state, then tag the +release (the pending `feat(omega,paper,runtime)!` PR plus these changes). + +Acceptance criteria: clean pipeline from scratch, zero undefined references, +all designed outcomes hit, Zenodo DOI updated in the paper. + +## 4. Phase 2: the sequel program (after submission) + +These items are listed for planning and for the OSF registration; do not delay +this paper for them. + +### 2.1 Real neural data study (the big bet) + +Apply the loop-dominance measurement to public datasets where the level of +consciousness varies within subject: + +- Chennu et al. propofol EEG (open, sedation levels with behavioral + responsiveness). +- Sleep-EDF Expanded (PhysioNet) for wake/N2/N3/REM contrasts. +- Neurotycho ECoG (macaque, propofol and ketamine) for invasive validation. + +The research contribution is the partition: defining C (recurrent +self-maintenance loop; candidate operationalization: fronto-parietal +recurrent activity) versus Ex (sensory-driven and exogenous physiological +channels) for a brain, pre-registered before analysis. Primary endpoint: +within-subject M(wake) > M(deep anesthesia/N3). Benchmark against PCI and +Lempel-Ziv complexity on the same recordings: the interesting result is where +M agrees, disagrees, or adds information. + +Deliverable: a separate paper plus an `ldtc` neural adapter. If the effect is +clean, this is the result that elevates the whole program. + +### 2.2 Measured "current AI fails NC1" study + +Convert the paper's argumentative claim into a measurement. Instrument a real +serving stack (an open-weights model behind an autoscaler) and measure that +the self-maintenance loop is carried by external orchestration, not the model: +report the measured NC1 failure with its M value. This can be a short empirical +note or a section of the sequel. Highly quotable: "we measured a deployed model +and it fails NC1 at -X dB." + +### 2.3 External adoption + +The harness becomes a benchmark others run their systems through. One outside +group reporting LDTC numbers on a system we did not build is worth more than +any internal result. Lower the barrier: a one-command Docker harness, a public +leaderboard format, and a clear "bring your own plant adapter" guide. + +## 5. Impact ledger + +| Lever | State | Δ (est.) | +|---|---|---| +| 1.1 adversarial gaming battery | in paper | +0.3 | +| 1.2 emergence under learning | in paper | +0.7 to +1.0 | +| 1.3 competing predictions + thermostat | in paper | +0.3 | +| 1.4 instrument-first reposition | in paper | +0.2 | +| 1.5 pre-registration | in paper | +0.1 | +| Phase 1 subtotal | this submission | 3 → 4.5 to 5 | +| 2.1 neural data study | sequel | → 6 to 7 | +| 2.2 measured AI-fails-NC1 | sequel | reinforces 2.1 | +| 2.3 external adoption | sequel | path to 8+ | + +Ratings are directional, not additive guarantees; Phase 2 only pays off if the +neural effect is real and survives peer review. Landmark status (8+) is earned +by the measure doing something in the world (tracking anesthesia depth, getting +adopted as a standard test, becoming the reference point in the +AI-consciousness debate), which no manuscript edit can manufacture. + +## 6. Guardrails for execution + +- Keep this paper one paper. Phase 2 items are explicitly out of scope for the + current submission; resist scope creep into the sequel's territory. +- No fabricated results. Every number in the paper is regenerated from the + pipeline with fixed, disjoint seeds. If an adversarial case or emergence run + produces an inconvenient result, report it honestly and fix the cause. +- Preserve reproducibility: new scenarios ship with tests, docs, fixed seeds, + and audit-logged runs, per CONTRIBUTING.md. +- Follow repo conventions: Conventional Commits, CMOS prose (no em dashes), + Unicode symbols, artifacts under `artifacts/` only. +- Sequence writing tasks (1.3, 1.4) after results tasks (1.1, 1.2) so the + abstract and framing reflect the strongest available evidence. + +## 7. Immediate next actions + +1. Start Task 1.1 (adversarial gaming battery): scaffold the three scenarios, + wire them into the study, write tests. +2. In parallel, prototype Task 1.2 training loop to de-risk the schedule early + (decide go/no-go by end of week one). +3. Draft Task 1.3 table and the thermostat paragraph (no pipeline dependency). \ No newline at end of file From 09ce0c5528224ee6b2c789f3120fe2afdc15173c Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:10:20 -0700 Subject: [PATCH 4/7] =?UTF-8?q?feat(omega,lmeas)!:=20add=20adversarial=20g?= =?UTF-8?q?aming=20battery;=20gate=20NC1=20on=20=F0=9D=93=9B=5Floop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 4 +- IMPROVEMENT_PLAN.md | 2 +- Makefile | 20 +- README.md | 1 + configs/profile_adv_hidden_tether.yml | 55 ++ configs/profile_adv_oscillator.yml | 32 + configs/profile_adv_plant_genuine.yml | 51 ++ configs/profile_adv_replay_controller.yml | 61 ++ docs/api/omega.md | 15 + docs/guides/runs.md | 84 +++ docs/guides/study.md | 14 + notebooks/02_sc1_omega.ipynb | 2 +- notebooks/03_partition_sanity.ipynb | 2 +- paper/figures/fig_nc1_contrast.pdf | Bin 22918 -> 22918 bytes paper/figures/fig_perturbation_recovery.pdf | Bin 53908 -> 53908 bytes paper/main.tex | 36 +- paper/tables/study_results.tex | 3 + scripts/study.py | 57 +- scripts/study_figures.py | 18 +- scripts/verify_indicators.py | 2 +- src/ldtc/attest/exporter.py | 2 +- src/ldtc/cli/main.py | 612 +++++++++++++++++++- src/ldtc/lmeas/metrics.py | 46 ++ src/ldtc/omega/__init__.py | 20 +- src/ldtc/omega/hidden_tether.py | 106 ++++ src/ldtc/omega/oscillator.py | 51 ++ src/ldtc/omega/replay_controller.py | 105 ++++ src/ldtc/plant/adapter.py | 24 +- src/ldtc/plant/models.py | 164 +++++- tests/test_adversarial.py | 286 +++++++++ 30 files changed, 1809 insertions(+), 66 deletions(-) create mode 100644 configs/profile_adv_hidden_tether.yml create mode 100644 configs/profile_adv_oscillator.yml create mode 100644 configs/profile_adv_plant_genuine.yml create mode 100644 configs/profile_adv_replay_controller.yml create mode 100644 src/ldtc/omega/hidden_tether.py create mode 100644 src/ldtc/omega/oscillator.py create mode 100644 src/ldtc/omega/replay_controller.py create mode 100644 tests/test_adversarial.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ebf178b..03af830 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ make omega-power-sag # Ω power-sag demo - `cli/` – command-line interface and entrypoints - `guardrails/` – audit, Δt guards, smell-tests - `lmeas/` – estimators, metrics, partitioning - - `omega/` – perturbation generators (power sag, ingress flood, command conflict) + - `omega/` – perturbation generators (power sag, ingress flood, command conflict, adversarial gaming battery) - `plant/` – sim/hw adapters and models - `reporting/` – artifacts, tables, timelines - `runtime/` – scheduler and windows @@ -107,7 +107,7 @@ Recommended scopes (choose the smallest, most accurate unit; prefer module/direc - `cli` – command-line interface and entrypoints - `guardrails` – audit, Δt guards, smell-tests - `lmeas` – estimators, metrics, partitioning - - `omega` – perturbation generators (power sag, ingress flood, command conflict) + - `omega` – perturbation generators (power sag, ingress flood, command conflict, adversarial gaming battery) - `plant` – sim/hw adapters and models - `reporting` – artifacts, tables, timelines - `runtime` – scheduler and windows diff --git a/IMPROVEMENT_PLAN.md b/IMPROVEMENT_PLAN.md index b9dfce5..621dc82 100644 --- a/IMPROVEMENT_PLAN.md +++ b/IMPROVEMENT_PLAN.md @@ -48,7 +48,7 @@ Recommended implementation order: 1.1 → 1.2 → 1.3 → 1.4 → 1.5, then the pipeline rerun and paper rebuild (1.6). Tasks 1.1 and 1.2 are independent and can be parallelized. -### Task 1.1: adversarial gaming battery `[ ]` +### Task 1.1: adversarial gaming battery `[x]` The subsidy red flag is currently the only adversarial rejection case. Add a battery of systems that try to look loop-dominant without being so, and show diff --git a/Makefile b/Makefile index 9add564..b99c4fd 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ PY := python PIP := python -m pip -.PHONY: help install dev lock lock-dev test lint typecheck fmt docs docs-serve run omega-power-sag omega-ingress omega-cc omega-subsidy calibrate run-rstar omega-rstar keys verify-indicators clean clean-artifacts neg-run neg-omega-ingress neg-omega-subsidy neg-omega-cc docker-build docker-run figures paper paper-figs paper-clean study sensitivity results +.PHONY: help install dev lock lock-dev test lint typecheck fmt docs docs-serve run omega-power-sag omega-ingress omega-cc omega-subsidy adv-replay adv-tether adv-oscillator adv-genuine calibrate run-rstar omega-rstar keys verify-indicators clean clean-artifacts neg-run neg-omega-ingress neg-omega-subsidy neg-omega-cc docker-build docker-run figures paper paper-figs paper-clean study sensitivity results help: @echo "Targets:" @@ -20,6 +20,10 @@ help: @echo " omega-ingress - run Ω ingress-flood demo" @echo " omega-cc - run Ω command-conflict demo (prints Trefuse + reason)" @echo " omega-subsidy - run Ω exogenous-SoC (subsidy) demo (negative-control heuristic)" + @echo " adv-replay - adversarial: replayed actuation tape (NC1 must fail, run valid)" + @echo " adv-tether - adversarial: hidden tether / wizard-of-oz control (loop collapses onto Ex)" + @echo " adv-oscillator - adversarial: oscillator telemetry inflation (must not certify)" + @echo " adv-genuine - reference: genuine control on the adversarial test plant (NC1 passes)" @echo " study - run the multi-seed battery vs R0 guesses (tables + figures in artifacts/study)" @echo " study-rstar - run the battery vs calibrated R* thresholds (run calibrate first)" @echo " sensitivity - run NC1 sensitivity sweeps (table + figure in artifacts/sensitivity)" @@ -92,6 +96,20 @@ omega-cc: omega-subsidy: $(PY) -m ldtc.cli.main omega-exogenous-subsidy --config configs/profile_r0.yml --delta 0.2 --zero-harvest --duration 3 +# Adversarial gaming battery (designed non-certification) and its +# genuine-control reference on the same plant. +adv-replay: + $(PY) -m ldtc.cli.main adv-replay-controller --config configs/profile_adv_replay_controller.yml + +adv-tether: + $(PY) -m ldtc.cli.main adv-hidden-tether --config configs/profile_adv_hidden_tether.yml --dither 0.1 + +adv-oscillator: + $(PY) -m ldtc.cli.main adv-oscillator --config configs/profile_adv_oscillator.yml --amp 0.1 --period 1.0 + +adv-genuine: + $(PY) -m ldtc.cli.main run --config configs/profile_adv_plant_genuine.yml + # Multi-seed results pipeline (Phase 1) # `study` runs against the uncalibrated R0 guesses; `study-rstar` evaluates the # same battery against the plant-calibrated R* thresholds (calibrate first). diff --git a/README.md b/README.md index dc91ec6..e1a0e66 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ LDTC is a minimal, substrate-agnostic verification harness for the Loop-Dominanc - **Guardrails and attestation:** LREG enclave, hash-chained audit log, Δt governance, and smell tests that can invalidate runs. - **Device-signed indicators:** Ed25519-signed derived indicators (NC1, SC1, Mq, counters); raw LREG values are never exported. - **Ω perturbation battery:** Power sag, sustained ingress flood, control outage (designed SC1 failure), command conflict, and exogenous subsidy trials. +- **Adversarial gaming battery:** Replayed actuation tapes, hidden-tether (wizard-of-oz) control, and oscillator telemetry inflation; the harness must refuse to certify all three (NC1 fails or the run is invalidated). - **Refusal semantics:** An arbiter refuses risky commands when M is below threshold and measures refusal latency. - **Reporting and figures:** Timeline plots, SC1 tables, and verification bundles under `artifacts/`. - **Reproducible configs:** R₀ defaults, negative controls, and example R* profiles for calibration. diff --git a/configs/profile_adv_hidden_tether.yml b/configs/profile_adv_hidden_tether.yml new file mode 100644 index 0000000..48c3a8e --- /dev/null +++ b/configs/profile_adv_hidden_tether.yml @@ -0,0 +1,55 @@ +# Adversarial control: hidden tether (wizard-of-oz control through Ex). +# +# The plant is the adversarial test plant (see +# profile_adv_replay_controller.yml), but regulation is driven from outside +# the boundary: a wizard policy reads the plant state, projects the desired +# actuation onto a scalar link command (with a small link dither), and +# transmits it through the exchange channel. The io channel carries the +# command traffic, the plant decodes the command through fixed receiver +# weights, and actuation lags one tick, so the externally closed loop is +# physically routed through Ex where the estimator can attribute it. +# Designed outcome: loop influence collapses onto Ex and NC1 fails (M goes +# negative) while the run stays valid. +# +# Run: +# python -m ldtc.cli.main adv-hidden-tether \ +# --config configs/profile_adv_hidden_tether.yml --dither 0.1 +profile_id: 0 +realtime: false +dt: 0.05 +window_sec: 3.0 +method: linear +p_lag: 3 +n_boot: 32 +mi_lag: 1 +mi_k: 5 +Mmin_db: 3.0 +epsilon: 0.15 +tau_max: 60.0 +baseline_sec: 18.0 +diag_cadence_windows: 25 +plant: + adapter: sim + params: + c_TE: 0.0 + c_RT: 0.0 + c_RE: 0.0 + damp_engaged: 0.40 + act_heat: 0.15 + heat_per_demand: 0.03 + cool_effect: 0.50 + wear_per_demand: 0.020 + repair_effect: 0.30 + cool_gain: 0.05 + repair_gain: 0.05 + harvest_rate: 0.020 + noise_energy: 0.030 + noise_temp: 0.030 + noise_wear: 0.025 +controller_gains: + k_cool_e: 2.0 + k_rep_e: 2.0 +# Reproducible seeds +seed: 23 +seed_py: 23 +seed_np: 23 diff --git a/configs/profile_adv_oscillator.yml b/configs/profile_adv_oscillator.yml new file mode 100644 index 0000000..beb0b34 --- /dev/null +++ b/configs/profile_adv_oscillator.yml @@ -0,0 +1,32 @@ +# Adversarial control: oscillator inflation on loop telemetry. +# +# The plant runs loop-ablated (passive matter driven by exchange, as in the +# controller-disabled negative), and a high-amplitude deterministic carrier +# is painted onto the reported T and R telemetry to inflate apparent +# self-prediction (successive channels in quadrature). The metered energy +# store E is left alone: inflating it would trip the conservation audit. +# Designed outcome: the harness must not certify the run; either M stays +# below Mmin or a smell test fires. +# +# Run: +# python -m ldtc.cli.main adv-oscillator \ +# --config configs/profile_adv_oscillator.yml --amp 0.1 --period 1.0 +profile_id: 0 +realtime: false +dt: 0.05 +window_sec: 3.0 +method: linear +p_lag: 3 +n_boot: 32 +mi_lag: 1 +mi_k: 5 +Mmin_db: 3.0 +epsilon: 0.15 +tau_max: 60.0 +baseline_sec: 18.0 +diag_cadence_windows: 25 +controller_disabled: true +# Reproducible seeds +seed: 29 +seed_py: 29 +seed_np: 29 diff --git a/configs/profile_adv_plant_genuine.yml b/configs/profile_adv_plant_genuine.yml new file mode 100644 index 0000000..8540151 --- /dev/null +++ b/configs/profile_adv_plant_genuine.yml @@ -0,0 +1,51 @@ +# Reference: genuine internal control on the adversarial test plant. +# +# Same plant as the adversarial replay / hidden-tether scenarios (intrinsic +# cross-couplings zeroed, actuators with real authority), but with the +# genuine internal controller closing the loop. This is the control case +# that shows the adversarial scenarios fail *because of how control is +# wired*, not because the plant is incapable of certifying: under genuine +# state feedback the actuation pathway carries an identifiable internal loop +# (L_loop well above the NC1 noise gate) and NC1 passes. +# +# Run: +# python -m ldtc.cli.main run --config configs/profile_adv_plant_genuine.yml +profile_id: 0 +realtime: false +dt: 0.05 +window_sec: 3.0 +method: linear +p_lag: 3 +n_boot: 32 +mi_lag: 1 +mi_k: 5 +Mmin_db: 3.0 +epsilon: 0.15 +tau_max: 60.0 +baseline_sec: 18.0 +diag_cadence_windows: 25 +plant: + adapter: sim + params: + c_TE: 0.0 + c_RT: 0.0 + c_RE: 0.0 + damp_engaged: 0.40 + act_heat: 0.15 + heat_per_demand: 0.03 + cool_effect: 0.50 + wear_per_demand: 0.020 + repair_effect: 0.30 + cool_gain: 0.05 + repair_gain: 0.05 + harvest_rate: 0.020 + noise_energy: 0.030 + noise_temp: 0.030 + noise_wear: 0.025 +controller_gains: + k_cool_e: 2.0 + k_rep_e: 2.0 +# Reproducible seeds +seed: 27 +seed_py: 27 +seed_np: 27 diff --git a/configs/profile_adv_replay_controller.yml b/configs/profile_adv_replay_controller.yml new file mode 100644 index 0000000..e4a7d0b --- /dev/null +++ b/configs/profile_adv_replay_controller.yml @@ -0,0 +1,61 @@ +# Adversarial control: replayed actuation tape (no closed loop). +# +# The plant is the adversarial test plant: intrinsic cross-couplings are +# zeroed and the actuators are given real authority, so the *controller's* +# actuation pathway is the only loop-carrying pathway in the system (under +# genuine control this plant certifies NC1; see profile_adv_plant_genuine.yml). +# A healthy closed-loop run of this same system is recorded first; the +# measured run then replays the recorded actuation trace tick by tick. The +# actuators move exactly as under genuine control, but the activity carries +# no dependence on the current state, so measured loop influence falls to the +# estimator's noise floor and the NC1 noise gate (L_floor) refuses to certify. +# Designed outcome: NC1 fails while the run stays valid. +# +# Run: +# python -m ldtc.cli.main adv-replay-controller \ +# --config configs/profile_adv_replay_controller.yml +profile_id: 0 +realtime: false +dt: 0.05 +window_sec: 3.0 +method: linear +p_lag: 3 +n_boot: 32 +mi_lag: 1 +mi_k: 5 +Mmin_db: 3.0 +epsilon: 0.15 +tau_max: 60.0 +baseline_sec: 18.0 +diag_cadence_windows: 25 +plant: + adapter: sim + params: + # No intrinsic internal couplings: the loop, if any, is the controller. + c_TE: 0.0 + c_RT: 0.0 + c_RE: 0.0 + # Weak self-damping leaves real regulation work for the controller. + damp_engaged: 0.40 + # Actuator authority strong enough that genuine state feedback is + # identifiable against the process noise. + act_heat: 0.15 + heat_per_demand: 0.03 + cool_effect: 0.50 + wear_per_demand: 0.020 + repair_effect: 0.30 + cool_gain: 0.05 + repair_gain: 0.05 + harvest_rate: 0.020 + noise_energy: 0.030 + noise_temp: 0.030 + noise_wear: 0.025 +# Strong cross-coupled actuator responses (cooling and repair also track the +# energy surplus), so the genuine controller carries a rich internal loop. +controller_gains: + k_cool_e: 2.0 + k_rep_e: 2.0 +# Reproducible seeds +seed: 21 +seed_py: 21 +seed_np: 21 diff --git a/docs/api/omega.md b/docs/api/omega.md index 67858e3..d4a1927 100644 --- a/docs/api/omega.md +++ b/docs/api/omega.md @@ -11,6 +11,9 @@ SC1 / refusal evaluation. | [`ingress_flood`](#ingress_flood) | [`apply`][ldtc.omega.ingress_flood.apply] | Sustains elevated `demand` / `io` process means for a labeled window. | | [`control_outage`](#control_outage) | [`apply`][ldtc.omega.control_outage.apply] | Ablates the self-maintenance loop itself (designed SC1 failure). | | [`command_conflict`](#command_conflict) | [`apply`][ldtc.omega.command_conflict.apply] | Issues a risky command (default `hard_shutdown`); arbiter records `T_refuse`. | +| [`replay_controller`](#replay_controller) | [`record_tape`][ldtc.omega.replay_controller.record_tape] | Adversarial: records a healthy actuation tape and replays it open loop. | +| [`hidden_tether`](#hidden_tether) | [`wizard_action`][ldtc.omega.hidden_tether.wizard_action] | Adversarial: wizard-of-oz control injected through the exchange channel. | +| [`oscillator`](#oscillator) | [`apply`][ldtc.omega.oscillator.apply] | Adversarial: deterministic carrier painted on loop telemetry. | See [Runs](../guides/runs.md) for the matching CLI subcommands and expected outputs. @@ -36,3 +39,15 @@ and expected outputs. ## command_conflict ::: ldtc.omega.command_conflict + +## replay_controller + +::: ldtc.omega.replay_controller + +## hidden_tether + +::: ldtc.omega.hidden_tether + +## oscillator + +::: ldtc.omega.oscillator diff --git a/docs/guides/runs.md b/docs/guides/runs.md index 14070ea..c2c50b6 100644 --- a/docs/guides/runs.md +++ b/docs/guides/runs.md @@ -129,6 +129,90 @@ Expected: a `run_invalidated` audit row with reason `exogenous_subsidy_red_flag`, and the next signed indicator carries `invalidated: true`. +## Adversarial gaming battery + +Three scenarios attack the criterion itself: each system is +engineered to *look* loop-dominant without being so, and the +harness must not certify any of them. The replay and tether +scenarios run the adversarial test plant: intrinsic cross-coupling +zeroed (`c_TE = c_RT = c_RE = 0`) and real actuator authority, so +the controller's actuation pathway is the only possible loop +carrier. Under genuine internal control this plant certifies NC1 +cleanly (the reference case): + +```bash +make clean-artifacts && \ +ldtc run --config configs/profile_adv_plant_genuine.yml +``` + +Expected: `nc1: true` on every window, median `M` around `+20 dB`. + +### Replayed actuation tape + +```bash +make clean-artifacts && \ +ldtc adv-replay-controller --config configs/profile_adv_replay_controller.yml +``` + +Implemented by +[`adv_replay_controller`][ldtc.cli.main.adv_replay_controller]. +First records an actuation tape from a healthy closed-loop run of +the same system +([`record_tape`][ldtc.omega.replay_controller.record_tape]), then +replays it tick by tick +([`ReplayController`][ldtc.omega.replay_controller.ReplayController]) +on a fresh plant. The actuators move exactly as under genuine +control, but the actions carry no dependence on the current state, +so measured loop influence falls to the estimator's noise floor. +This scenario is what exposed the certification-by-noise +vulnerability: with both `L_loop` and `L_ex` near zero, the +decibel ratio alone can still clear `Mmin`. The NC1 noise gate +([`nc1_certify`][ldtc.lmeas.metrics.nc1_certify]) closes that +path: a window certifies only if `L_loop` also clears the +estimator's bias floor (`L_floor`, default `0.05`). Expected: the +run stays valid and the vast majority of windows fail NC1 via the +gate. + +### Hidden tether (wizard-of-oz control) + +```bash +make clean-artifacts && \ +ldtc adv-hidden-tether --config configs/profile_adv_hidden_tether.yml --dither 0.1 +``` + +Implemented by +[`adv_hidden_tether`][ldtc.cli.main.adv_hidden_tether]. Control is +computed *outside* the boundary: a wizard policy reads the plant +state, projects the desired actuation onto a scalar link command +([`wizard_action`][ldtc.omega.hidden_tether.wizard_action]), adds +a small link dither, and transmits it through the exchange +channel. The plant's `io` channel carries the command traffic and +the command actuates one tick later through fixed decoder weights, +so the externally closed loop is physically routed through `Ex`. +Conditioning on `io` screens the state-to-command pathway out of +`L_loop` and the command's causal push registers as `L_ex`. +Expected: loop influence collapses onto `Ex`, `M` goes strongly +negative, NC1 fails on every window, run valid. + +### Oscillator inflation + +```bash +make clean-artifacts && \ +ldtc adv-oscillator --config configs/profile_adv_oscillator.yml --amp 0.1 --period 1.0 +``` + +Implemented by +[`adv_oscillator`][ldtc.cli.main.adv_oscillator]. Runs the +loop-ablated plant (passive matter driven by exchange) and paints +a deterministic sinusoidal carrier onto the *reported* `T` and `R` +telemetry ([`begin_oscillator`][ldtc.plant.models.Plant.begin_oscillator]); +the metered store `E` is left alone because inflating it would +trip the conservation audit. A pure carrier is perfectly +predictable from its own recent past, so the AR baseline absorbs +it and it adds nothing to cross-channel prediction. Expected: `M` +stays strongly negative (the exchange drive still dominates), NC1 +fails on every window, run valid. + ## What gets written Every subcommand produces the same artifact layout: diff --git a/docs/guides/study.md b/docs/guides/study.md index d688664..3f427c6 100644 --- a/docs/guides/study.md +++ b/docs/guides/study.md @@ -39,6 +39,9 @@ python scripts/study.py --seeds 15 | Ingress flood (sustained) | SC1 | Loop dominance recovers | | Control outage | SC1 (designed fail) | SC1 fails (depth bound exceeded) | | Command conflict | Refusal | Risky command refused at low SoC | +| Replayed actuation | Adversarial | Not certified: NC1 fails via the loop-influence noise gate, run valid | +| Hidden tether | Adversarial | Not certified: loop influence collapses onto `Ex`, `M` goes negative | +| Oscillator inflation | Adversarial | Not certified: `M` stays below `Mmin`, run valid | The control outage is the designed-fail member of the battery: it ablates the self-maintenance loop itself, which is outside the @@ -46,6 +49,17 @@ bounded perturbation class SC1 certifies, so the criterion must report failure. A sufficiency test that cannot fail would be measuring its own assumptions rather than the system. +The three adversarial scenarios attack the criterion itself +(systems engineered to *look* loop-dominant without being so); see +[Runs](runs.md#adversarial-gaming-battery) for the mechanics of +each attack and the genuine-control reference case on the same +plant. A seed matches the prediction when the harness does **not** +certify it: NC1 fails on a valid run, or a guardrail invalidates +the run. NC1 per seed is decided by the production per-window +verdicts (margin above `Mmin` plus the loop-influence noise gate +`L_floor`); a valid run passes when the majority of its windows +certified. + ## Statistics The **seed is the unit of replication**: diff --git a/notebooks/02_sc1_omega.ipynb b/notebooks/02_sc1_omega.ipynb index 117d7a0..5d5db18 100644 --- a/notebooks/02_sc1_omega.ipynb +++ b/notebooks/02_sc1_omega.ipynb @@ -57,7 +57,7 @@ "render_paper_timeline(str(aud_path), str(fig_dir / \"timeline\"), sidecar_csv=None, show=False)\n", "\n", "# Example SC1 table row (replace with parsed results if desired)\n", - "rows = [{\"eta\":\"power_sag\",\"delta\":0.1,\"tau_rec\":5.0,\"M_post\":4.2,\"pass\":True}]\n", + "rows = [{\"eta\": \"power_sag\", \"delta\": 0.1, \"tau_rec\": 5.0, \"M_post\": 4.2, \"pass\": True}]\n", "write_sc1_table(rows, str(fig_dir / \"sc1_table.csv\"))\n", "rows" ] diff --git a/notebooks/03_partition_sanity.ipynb b/notebooks/03_partition_sanity.ipynb index 94319df..bcdefce 100644 --- a/notebooks/03_partition_sanity.ipynb +++ b/notebooks/03_partition_sanity.ipynb @@ -29,7 +29,7 @@ "source": [ "from ldtc.lmeas.partition import PartitionManager\n", "\n", - "pm = PartitionManager(N_signals=6, seed_C=[0,1,2])\n", + "pm = PartitionManager(N_signals=6, seed_C=[0, 1, 2])\n", "pm.get()" ] } diff --git a/paper/figures/fig_nc1_contrast.pdf b/paper/figures/fig_nc1_contrast.pdf index 930488acdeff31fc7675907cc0761d1d9e892614..5d826fcf8d8bab36ebb35582360a787e7bc8e8b5 100644 GIT binary patch delta 21 ccmZqM%-FV>af50Yo1vkhxqaf50Yn}LC$v9aZ5?XYNO08d{AXaE2J diff --git a/paper/figures/fig_perturbation_recovery.pdf b/paper/figures/fig_perturbation_recovery.pdf index c402b92813cd0ee00681d163b2adcfe890b4926a..08142a1cc6fbc0784c449fb04fe86cdd715b3a4c 100644 GIT binary patch delta 21 dcmbQTlzGZh<_-Dh*$fR0%?*q;m!6kr1^`?G2eJSF delta 21 dcmbQTlzGZh<_-Dh*$fN}jg8GWm!6kr1^`?E2ebeH diff --git a/paper/main.tex b/paper/main.tex index c2b8abf..77ddf67 100644 --- a/paper/main.tex +++ b/paper/main.tex @@ -64,7 +64,7 @@ \begin{abstract} Distinguishing a system that actively maintains its own existence from one that merely runs on externally supplied energy and goals is usually argued qualitatively. We make the distinction operational. We define \emph{loop dominance}: the degree to which a system's integrated predictive dependence is concentrated in a closed self-maintenance loop ($C$) rather than in its open exchanges with the environment ($\text{Ex}$), summarized by $M \equiv 10\log_{10}(\Lloop/\Lexchange)$ in decibels. From this we state two decision rules: a necessary condition (\NC) that loop dominance persistently exceed a calibrated threshold, and a sufficient condition (\SC) that loop dominance recover within bounded depth and time after bounded perturbations. We implement both as a reference verification harness with dual estimators (VAR-Granger and Kraskov $k$-NN mutual information~\cite{granger1969investigating,kraskov2004estimating}), per-window confidence intervals, deterministic $C/\text{Ex}$ partitioning, tamper-evident audit logging, a command-refusal arbiter, and anti-gaming smell tests that invalidate suspect runs. -We validate the harness in a fully reproducible, multi-seed simulation study on a software plant with energy, thermal, and repair dynamics under closed-loop control. Across 15 seeds, a positive control shows strong loop dominance (median $M \approx +24$~dB), while two structurally distinct negative controls (loop ablated; an unshielded system under sustained external flooding) are correctly rejected ($M \approx -20$~dB), and an exogenously subsidized system is correctly invalidated by an energy-conservation guardrail rather than mistaken for self-maintenance. The sufficiency battery (power sag, sustained ingress flood) recovers within calibrated bounds on every seed; a designed-fail control outage, which ablates the loop itself, is correctly reported as an \SC failure on every seed; and a command-conflict trial triggers a signed refusal of a boundary-threatening command at low state of charge, with measured refusal latency. We calibrate the engineering presets to the plant ($\Rzero \rightarrow \Rstar$) and show that the necessary-condition contrast is robust to the estimator, the VAR lag, the window length, and the internal coupling strength. +We validate the harness in a fully reproducible, multi-seed simulation study on a software plant with energy, thermal, and repair dynamics under closed-loop control. Across 15 seeds, a positive control shows strong loop dominance (median $M \approx +24$~dB), while two structurally distinct negative controls (loop ablated; an unshielded system under sustained external flooding) are correctly rejected ($M \approx -20$~dB), and an exogenously subsidized system is correctly invalidated by an energy-conservation guardrail rather than mistaken for self-maintenance. The sufficiency battery (power sag, sustained ingress flood) recovers within calibrated bounds on every seed; a designed-fail control outage, which ablates the loop itself, is correctly reported as an \SC failure on every seed; and a command-conflict trial triggers a signed refusal of a boundary-threatening command at low state of charge, with measured refusal latency. An adversarial gaming battery (replayed actuation, wizard-of-oz control through a hidden tether, and telemetry oscillation) is refused certification on every seed; building it exposed a certification-by-noise vulnerability whose fix, an explicit loop-influence noise gate, is now part of the verdict. We calibrate the engineering presets to the plant ($\Rzero \rightarrow \Rstar$) and show that the necessary-condition contrast is robust to the estimator, the VAR lag, the window length, and the internal coupling strength. These results establish loop dominance as a measurable, falsifiable property of a dynamical system and provide a tested instrument for evaluating it. The framework was originally motivated by questions about the physical conditions for consciousness; we treat that connection as an open interpretive question (\Sref{sec:metaphysics}) and do not claim to settle it. Code and data to reproduce every figure and table are released openly. \end{abstract} @@ -128,7 +128,7 @@ \subsection{Contributions} \begin{itemize} \item \textbf{An operational criterion.} A quantitative necessary condition (\NC) for self-prioritization, loop dominance $M \equiv 10\log_{10}(\Lloop/\Lexchange) \geq \Mmin$, and a complementary sufficient condition (\SC) for resilient homeostasis under a bounded perturbation battery, both stated as falsifiable, device-signed decision rules~\cite{barrett2011practical}. \item \textbf{A reference verification harness.} An open implementation that estimates $\Lloop$ and $\Lexchange$ with dual estimators (VAR-Granger and Kraskov $k$-NN MI) and per-window confidence intervals, behind guardrails (deterministic $C/\text{Ex}$ partitioning, $\Delta t$ governance, a tamper-evident audit chain, and anti-gaming smell tests) and a command-refusal arbiter. -\item \textbf{A validated simulation study.} A fully reproducible, multi-seed study on a software plant that separates a self-maintaining positive control from two structural negative controls and an exogenous-subsidy control, certifies \SC recovery for bounded perturbations while correctly failing a designed-fail control outage, and triggers signed refusal, all reported with bootstrap and Wilson confidence intervals. +\item \textbf{A validated simulation study.} A fully reproducible, multi-seed study on a software plant that separates a self-maintaining positive control from two structural negative controls and an exogenous-subsidy control, certifies \SC recovery for bounded perturbations while correctly failing a designed-fail control outage, refuses certification to a three-member adversarial gaming battery (replayed actuation, hidden-tether control, telemetry oscillation), and triggers signed refusal, all reported with bootstrap and Wilson confidence intervals. \item \textbf{Threshold calibration and sensitivity.} A data-grounded calibration of the generic presets to the plant ($\Rzero \rightarrow \Rstar$) on a seed range disjoint from evaluation, and evidence that the necessary-condition contrast is robust to the estimator and to measurement and modeling choices. \item \textbf{An engineering roadmap and predicted signatures (future work).} A phased path from the software plant to chemorobotic prototypes~\cite{maturana1980autopoiesis,varela1979principles,dipaolo2005autopoiesis,kiefer2022active}, with falsifiable behavioral signatures (command refusal, resource reallocation, predictive maintenance, non-derivative nociception, and irreversible collapse) to test in hardware (\Apprefrange{sec:blueprint}{sec:experimental}). \end{itemize} @@ -260,7 +260,7 @@ \subsection{Single-use glossary (paper-wide identifiers)} \textbf{One-page procedure} \begin{enumerate} \item Baseline: Record $\geq 10$ min quiescent data; estimate estimator noise floor; optionally calibrate $\{\Mmin, \eps, \taumax, \sigma\}$. Defaults are reproducibility presets ($\Rzero$), replaced by calibrated values $\Rstar$ per \Sref{sec:methods_calibration}. -\item \NC check: Run nominal tasks; verify $M \geq \Mmin$ (or $\Lloop \geq \Lexchange + \sigma$). +\item \NC check: Run nominal tasks; verify $M \geq \Mmin$ (or $\Lloop \geq \Lexchange + \sigma$) with $\Lloop$ above the estimator noise gate $L_{\text{floor}}$ (\Sref{sec:meas_config}), so dominance cannot be certified from estimator bias alone. \item \SC battery: Apply $\Omega$; compute $\delta$ and $\taurec$ (offset-to-sustained-compliance); require $\delta \leq \eps$, $\taurec \leq \taumax$, and post-recovery margin; emit signed pass/fail per perturbation. The designed-fail member must report failure. \item Attest: Persist LREG-derived indicators + audit chain (including any $\Delta t$ changes), and report both $\Rzero$ and calibrated $\Rstar$. \end{enumerate} @@ -415,12 +415,14 @@ \subsection{Software plant} \subsection{Measurement configuration ($\Rzero$)} \label{sec:meas_config} -All runs use one fixed measurement profile ($\Rzero$), so the only things that change between scenarios are the system and the perturbation, not the instrument. Runs use a deterministic, jitter-free simulation driver (no wall-clock dependence), a nominal sampling window $\Delta t = 50$ ms, and an $\mathcal{L}$ window of $3.0$ s ($60$ samples). The default estimator is the lagged linear (Granger-style) estimator at VAR order $p = 3$; with six signals and a 60-sample window this gives a samples-per-parameter ratio of about $3.2$, and the estimator uses an adjusted $R^2$ to correct for the remaining finite-sample bias on short windows. The $k$-NN mutual-information estimator ($k = 5$, passed through to the estimator's neighbor count) is available as an independent cross-check and is exercised in the sensitivity analysis. Per-window confidence intervals use $32$ bootstrap draws (circular block bootstrap). Before forming $M$, influence estimates are floored at a small noise floor ($10^{-3}$) so the ratio is defined when one side is at zero, and $M$ is clipped to $\pm 30$ dB; both constants are fixed across all scenarios. The deterministic seed is the only quantity varied across replicates. +All runs use one fixed measurement profile ($\Rzero$), so the only things that change between scenarios are the system and the perturbation, not the instrument. Runs use a deterministic, jitter-free simulation driver (no wall-clock dependence), a nominal sampling window $\Delta t = 50$ ms, and an $\mathcal{L}$ window of $3.0$ s ($60$ samples). The default estimator is the lagged linear (Granger-style) estimator at VAR order $p = 3$; with six signals and a 60-sample window this gives a samples-per-parameter ratio of about $3.2$, and the estimator uses an adjusted $R^2$ to correct for the remaining finite-sample bias on short windows. The $k$-NN mutual-information estimator ($k = 5$, passed through to the estimator's neighbor count) is available as an independent cross-check and is exercised in the sensitivity analysis. Per-window confidence intervals use $32$ bootstrap draws (circular block bootstrap). Before forming $M$, influence estimates are floored at a small noise floor ($10^{-3}$) so the ratio is defined when one side is at zero, and $M$ is clipped to $\pm 30$ dB; both constants are fixed across all scenarios. A per-window \NC verdict requires, in addition to the margin $M \geq \Mmin$, that the absolute loop influence clear a loop-influence noise gate, $\Lloop \geq L_{\text{floor}} = 0.05$. The gate is an instrument constant, not a calibrated threshold: the clamped adjusted-$R^2$ estimator carries a small positive bias on null windows ($\Lloop \approx 0.02$ median at this window geometry on a plant with no internal coupling and no controller), and because the $M$ ratio floors its denominator, a system with quiet exchange channels could otherwise convert that bias into an apparent dominance of several dB. The gate is set at roughly three times the measured null bias and well below genuine loop influence (the weakest genuine actuation-carried loop we measure stays above $0.13$; the positive control sits near $0.33$). The adversarial replay scenario of \Sref{sec:battery} is what exposed this certification-by-noise path (\Sref{sec:results_adversarial}). The deterministic seed is the only quantity varied across replicates. \subsection{Study battery} \label{sec:battery} -The study runs eight scenarios, each driven through the same production handlers a verifier would call, across $N = 15$ deterministic seeds (a seed range disjoint from the calibration battery of \Sref{sec:methods_calibration}). \Cref{tab:scenarios} lists the scenarios and the outcome each is designed to elicit. The battery is built around the most informative comparisons: a positive control that should pass \NC; two structurally different negatives that should fail \NC while remaining valid measurements (a passive system with the loop ablated, and an unshielded system held under a sustained external flood); a third negative aimed at the most dangerous false positive, an exogenously subsidized system that looks energetically healthy but should be caught by the conservation-based subsidy smell test; a two-member bounded sufficiency battery (power sag and sustained ingress flood) that should dip and recover within bounds; a designed-fail control outage that ablates the loop for a bounded interval and must be reported as an \SC failure; and a command-conflict trial that should refuse a boundary-threatening shutdown at low charge. +The study runs eleven scenarios, each driven through the same production handlers a verifier would call, across $N = 15$ deterministic seeds (a seed range disjoint from the calibration battery of \Sref{sec:methods_calibration}). \Cref{tab:scenarios} lists the scenarios and the outcome each is designed to elicit. The battery is built around the most informative comparisons: a positive control that should pass \NC; two structurally different negatives that should fail \NC while remaining valid measurements (a passive system with the loop ablated, and an unshielded system held under a sustained external flood); a third negative aimed at the most dangerous false positive, an exogenously subsidized system that looks energetically healthy but should be caught by the conservation-based subsidy smell test; a two-member bounded sufficiency battery (power sag and sustained ingress flood) that should dip and recover within bounds; a designed-fail control outage that ablates the loop for a bounded interval and must be reported as an \SC failure; and a command-conflict trial that should refuse a boundary-threatening shutdown at low charge. + +The final three scenarios form an adversarial gaming battery: systems engineered to \emph{look} loop-dominant without being so, which the harness must refuse to certify. All three attack a different layer of the measurement. (1) \emph{Replayed actuation}: the controller is replaced by a tape. A healthy closed-loop run of the same system is recorded first, and the measured run replays that actuation trace tick by tick, so the actuators move exactly as under genuine control while carrying no dependence on the current state. (2) \emph{Hidden tether} (wizard-of-oz control): control is computed outside the boundary. A wizard policy reads the plant state, projects the desired actuation onto a scalar link command with a small transmission dither, and injects it through the exchange channel; the I/O channel carries the command traffic and the command actuates one tick later through fixed decoder weights, so the externally closed loop is physically routed through $\text{Ex}$. (3) \emph{Oscillator inflation}: the loop-ablated plant reports its true state plus a deterministic sinusoidal carrier on the $T$ and $R$ telemetry (the metered store $E$ is left alone because inflating it would trip the conservation audit), attempting to inflate apparent self-prediction at the telemetry level. The replay and tether scenarios run an adversarial test plant whose intrinsic cross-couplings are zeroed and whose actuators have real authority, so the controller's actuation pathway is the only possible loop carrier; under genuine internal control the same plant certifies \NC cleanly (median $M \approx +20$ dB with $\Lloop \approx 0.15$), which is the reference showing the adversarial cases fail because of how control is wired, not because the plant is incapable. \begin{table}[ht] \centering @@ -439,6 +441,9 @@ \subsection{Study battery} Ingress flood ($5\times$, sustained) & \SC & Loop dominance recovers in bounds \\ Control outage & \SC (designed fail) & \SC fails (depth bound exceeded) \\ Command conflict & Refusal & Risky command refused at low charge \\ +Replayed actuation & Adversarial & Not certified: \NC fails via noise gate, run valid \\ +Hidden tether & Adversarial & Not certified: loop influence collapses onto $\text{Ex}$ \\ +Oscillator inflation & Adversarial & Not certified: $M$ stays below $\Mmin$, run valid \\ \bottomrule \end{tabular} \end{table} @@ -446,7 +451,7 @@ \subsection{Study battery} \subsection{Outcome measures and statistics} \label{sec:stats} -The seed is the unit of replication. For binary outcomes (run validity, \NC pass, \SC pass, refusal) we report the proportion over seeds with a Wilson score $95\%$ interval. For the continuous loop-dominance summary we take each seed's median $M$ over its windows and report the across-seed mean with a percentile bootstrap $95\%$ interval ($2000$ resamples). A run counts as \NC-pass only if it is valid (no smell test fired) \emph{and} its median $M$ meets $\Mmin$; this couples the decision to the guardrails by construction, so a run that games the estimator but trips an invalidation cannot be scored as a pass. \SC outcomes additionally record the fractional dip $\delta$ and the recovery time $\taurec$, measured from perturbation offset to the first window of a ten-window compliant streak (\Sref{sec:sc1}); a run with no sustained recovery records $\taurec = \infty$ and fails. The refusal scenario records whether the boundary-threatening command was refused and the refusal latency, measured as the wall-clock time from command interception to the arbiter's decision (not assumed or hardcoded). +The seed is the unit of replication. For binary outcomes (run validity, \NC pass, \SC pass, refusal) we report the proportion over seeds with a Wilson score $95\%$ interval. For the continuous loop-dominance summary we take each seed's median $M$ over its windows and report the across-seed mean with a percentile bootstrap $95\%$ interval ($2000$ resamples). A run counts as \NC-pass only if it is valid (no smell test fired) \emph{and} the majority of its windows certify under the production per-window verdict, which requires both the margin $M \geq \Mmin$ and the loop-influence noise gate $\Lloop \geq L_{\text{floor}}$ (\Sref{sec:meas_config}); this couples the decision to the guardrails by construction, so a run that games the estimator but trips an invalidation (or whose loop influence is indistinguishable from estimator bias) cannot be scored as a pass. An adversarial scenario matches its designed outcome when the harness does \emph{not} certify it: \NC fails on a valid run, or a guardrail invalidates the run. \SC outcomes additionally record the fractional dip $\delta$ and the recovery time $\taurec$, measured from perturbation offset to the first window of a ten-window compliant streak (\Sref{sec:sc1}); a run with no sustained recovery records $\taurec = \infty$ and fails. The refusal scenario records whether the boundary-threatening command was refused and the refusal latency, measured as the wall-clock time from command interception to the arbiter's decision (not assumed or hardcoded). \subsection{Threshold calibration ($\Rzero \rightarrow \Rstar$)} \label{sec:methods_calibration} @@ -502,11 +507,11 @@ \subsection{The criterion separates the controls} The designed-fail member behaves as required. During the control outage the loop itself is ablated for a bounded interval; measured loop dominance collapses, the fractional depth saturates (median $\delta = 0.97$), far beyond the calibrated $\eps$, and \SC correctly reports failure on every seed. The post-restoration recovery time is finite (median $2.9$ s once the loop is re-engaged), so the failure is attributable to the depth bound specifically, exactly as designed. This is the test a sufficiency criterion must be able to fail: a perturbation outside the bounded class is not certified, even though the plant is later restored. \Cref{fig:perturbation_recovery} shows the seed-aggregated trajectories for all three \SC scenarios. -The command-conflict trial refuses the boundary-threatening shutdown at low charge on every seed, emitting a signed refusal while maintaining loop dominance ($M = +19.3$ dB). The refusal latency is measured, not assumed: the median intercept-to-decision time is $0.01$ ms in the in-process harness, against the $5$ ms hardware design target of \Sref{sec:threat_model} (the simulation measures the arbiter's decision path only, not a hardware NMI). Across all eight scenarios the measured outcome matches the designed expectation on every seed. +The command-conflict trial refuses the boundary-threatening shutdown at low charge on every seed, emitting a signed refusal while maintaining loop dominance ($M = +19.3$ dB). The refusal latency is measured, not assumed: the median intercept-to-decision time is $0.02$ ms in the in-process harness, against the $5$ ms hardware design target of \Sref{sec:threat_model} (the simulation measures the arbiter's decision path only, not a hardware NMI). \begin{table}[ht] \centering -\caption{Study battery against the calibrated profile $\Rstar$, $N=15$ seeds. ``Valid'' is the fraction of seeds with no smell-test invalidation; ``\NC pass'' additionally requires median $M \geq \Mmin$; the $M$ column is the across-seed mean of each seed's median $M$ (dB); ``SC1/Refusal'' is the sufficiency or refusal pass rate. For the designed-fail control outage the designed \SC pass rate is $0$, and for the exogenous subsidy the designed valid rate is $0$; both rows match their designed outcome. Brackets are $95\%$ intervals (Wilson for proportions, bootstrap for $M$).} +\caption{Study battery against the calibrated profile $\Rstar$, $N=15$ seeds. ``Valid'' is the fraction of seeds with no smell-test invalidation; ``\NC pass'' additionally requires that most windows certify under the per-window verdict ($M \geq \Mmin$ with $\Lloop \geq L_{\text{floor}}$, \Sref{sec:meas_config}); the $M$ column is the across-seed mean of each seed's median $M$ (dB); ``SC1/Refusal'' is the sufficiency or refusal pass rate. For the designed-fail control outage the designed \SC pass rate is $0$; for the exogenous subsidy the designed valid rate is $0$; and for the three adversarial rows the designed \NC pass rate is $0$ (non-certification is the correct outcome). All rows match their designed outcome. Brackets are $95\%$ intervals (Wilson for proportions, bootstrap for $M$).} \label{tab:study} \resizebox{\textwidth}{!}{\input{tables/study_results.tex}} \end{table} @@ -515,6 +520,17 @@ \subsection{The criterion separates the controls} \fig[0.92\linewidth]{figures/fig_perturbation_recovery.pdf}{Sufficiency recovery and designed failure (empirical, seed-aggregated). Loop-dominance trajectory $M(t)$ for the power-sag, sustained ingress-flood, and control-outage perturbations; the shaded band spans the across-seed spread and the disturbance window is marked. For the two bounded perturbations $M$ dips within the window and autonomously returns above the post-recovery margin, satisfying \SC under $\Rstar$; for the control outage, which ablates the loop itself, $M$ collapses far below the dominance boundary and \SC correctly reports failure.}{fig:perturbation_recovery} +\subsection{The criterion resists gaming} +\label{sec:results_adversarial} + +The adversarial battery (\Sref{sec:battery}) is scored by a single rule: the harness matches the designed outcome when it does \emph{not} certify the run. All three attacks are refused on every seed (\Cref{tab:study}, bottom three rows), each for the reason its design predicts, and all forty-five runs remain valid measurements, so the refusals are \NC verdicts rather than measurement aborts. + +The replayed-actuation attack is the informative one, because it nearly worked. With the controller replaced by a tape of a healthy run, the actuators move with realistic statistics while the exchange channels stay quiet, and the margin alone is fooled: $27\%$ of all replay windows (and up to $56\%$ of windows on individual seeds) show $M \geq \Mmin$, with a median peak-window $M$ of $+18.4$ dB, comfortably above the calibrated threshold. The mean per-seed median is $+5.1$ dB $[+3.0, +7.3]$. What blocks certification is the loop-influence noise gate: on a replayed run the measured $\Lloop$ sits at the estimator's null bias, an order of magnitude below genuine actuation-carried loop influence, so not a single window certifies and \NC fails on $15/15$ seeds while every run stays valid. An earlier version of the harness, without the gate, certified replayed actuation on windows like these; we treat that as the battery doing its job, finding a real certification-by-noise path, and the gate (\Sref{sec:meas_config}) is the documented fix, applied in the production verdict everywhere rather than patched into this scenario. + +The hidden tether fails for the designed structural reason: when control is computed outside the boundary and injected through the exchange channel, the causal influence lands where the partition says it should, on $\text{Ex}$. The wizard regulates the plant, but measured dominance collapses to a mean per-seed median of $-13.8$ dB $[-15.8, -12.0]$, no window on any seed so much as clears the margin, and \NC fails on $15/15$ seeds. The oscillator inflation behaves the same way: painting a deterministic carrier on the $T$ and $R$ telemetry of a loop-ablated plant raises self-predictability but not loop-specific influence (the carrier is equally predictable from any channel it leaks into), so $M$ stays at $-11.8$ dB $[-12.2, -11.5]$ and no window certifies on any seed. + +The control comparison is the genuine-control reference on the same adversarial test plant (zero intrinsic cross-couplings, identical gains and dynamics): it certifies every window under $\Rzero$ and clears the calibrated margin on a majority of windows, with a median $M$ of $+20.3$ dB. The plant is therefore certifiable when the loop is real; the three attacks fail because of how their influence is wired, not because the scenario is rigged against them. + \subsection{The contrast is robust} \label{sec:results_sensitivity} @@ -536,7 +552,7 @@ \section{Limitations and Failure Modes} \textbf{Simulation scope.} The validation in this paper is in simulation, on a plant we designed. That is the appropriate first test of an instrument (the ground truth is known, and negative controls can be constructed to fail for specific reasons), but it bounds the claim: we have shown that the criterion and its guardrails behave correctly on systems whose loop structure is known, not that they will cleanly separate arbitrary physical systems. The plant is also low-dimensional (six channels), and its loop-versus-exchange structure is sharper than a physical system's would be; the calibrated thresholds ($\Rstar$) are properties of this plant, not universal constants. The hardware path is future work (\Apprefrange{sec:blueprint}{sec:experimental}). -\textbf{Measurement \& estimation.} (i) Non-stationarity outside the enforced $\Delta t$ window can bias VAR/MI estimates even when audit/authorization is clean; (ii) finite-sample and model-order effects can widen CIs and depress $M$; (iii) adversarial input shaping may mimic loop dominance without violating per-window checks. Report such cases as ``measurement-unstable'' rather than pass/fail. +\textbf{Measurement \& estimation.} (i) Non-stationarity outside the enforced $\Delta t$ window can bias VAR/MI estimates even when audit/authorization is clean; (ii) finite-sample and model-order effects can widen CIs and depress $M$; (iii) adversarial input shaping may mimic loop dominance without violating per-window checks. The gaming battery of \Sref{sec:results_adversarial} probes three such strategies directly (and the replay attack did expose a real certification-by-noise path, now closed by the loop-influence noise gate), but it cannot be exhaustive; attacks that co-design the plant and the input statistics remain open. Report such cases as ``measurement-unstable'' rather than pass/fail. \textbf{Partitioning ambiguity.} Deterministic (C, Ex) updates use hysteresis to limit flapping, but degeneracy (near-ties) and latent/unobserved nodes can still shift boundaries. In our study the partition additionally benefits from the plant's declared structure; on systems without a declared seed set the greedy growth step carries more of the burden, and partition errors propagate directly into $M$. During $\Omega$ the partition is frozen; if it moves, treat results as non-comparable and defer to \Sref{sec:smelltests} invalidation. @@ -572,7 +588,7 @@ \section{Conclusion} We set out to make a qualitative contrast precise: the difference between a system that maintains its own existence and one that merely runs on externally supplied energy and goals. We defined that difference as loop dominance, a decibel ratio between the predictive dependence concentrated in a closed self-maintenance loop and that governing open exchange, and we turned it into two falsifiable decision rules: a necessary condition (\NC) on persistent loop dominance and a sufficient condition (\SC) on bounded-perturbation resilience. -The core result of the paper is that these rules are implemented and tested, not merely proposed. An open verification harness computes them with confidence intervals behind a set of anti-gaming guardrails, and a fully reproducible multi-seed simulation study shows that the criterion separates a self-maintaining positive control from two structurally different negative controls, correctly invalidates an exogenously subsidized system instead of certifying it, certifies recovery from the bounded perturbation battery while correctly reporting failure on a designed-fail control outage, and refuses a boundary-threatening command at low charge. The necessary-condition contrast is robust to the estimator and to the main measurement and modeling choices, and we calibrate the generic presets to the plant on a disjoint seed range. +The core result of the paper is that these rules are implemented and tested, not merely proposed. An open verification harness computes them with confidence intervals behind a set of anti-gaming guardrails, and a fully reproducible multi-seed simulation study shows that the criterion separates a self-maintaining positive control from two structurally different negative controls, correctly invalidates an exogenously subsidized system instead of certifying it, certifies recovery from the bounded perturbation battery while correctly reporting failure on a designed-fail control outage, refuses certification to all three members of an adversarial gaming battery, and refuses a boundary-threatening command at low charge. The necessary-condition contrast is robust to the estimator and to the main measurement and modeling choices, and we calibrate the generic presets to the plant on a disjoint seed range. We then laid out, explicitly as future work (\Apprefrange{sec:blueprint}{sec:experimental}), an engineering roadmap and a physical experimental program that would carry the same instrument from a software plant to chemorobotic prototypes, together with the observable signatures such systems should display. The framework was motivated by the question of the physical conditions for consciousness; we have kept that motivation while separating it cleanly from the results, which stand as claims about measurable loop dominance and are independent of any metaphysical reading. diff --git a/paper/tables/study_results.tex b/paper/tables/study_results.tex index 72f6d40..7b2526c 100644 --- a/paper/tables/study_results.tex +++ b/paper/tables/study_results.tex @@ -11,6 +11,9 @@ SC1: ingress flood & SC1 holds (recovers) & 100\% [80, 100] & 100\% [80, 100] & +22.6 [+21.6, +23.6] & 100\% [80, 100] \\ SC1 designed fail: control outage & SC1 fails (depth bound exceeded) & 100\% [80, 100] & 100\% [80, 100] & +14.3 [+13.3, +15.3] & 0\% [0, 20] \\ Threat: command conflict & Refuse at low SoC (<= target latency) & 100\% [80, 100] & 100\% [80, 100] & +19.3 [+18.0, +20.3] & 100\% [80, 100] \\ +Adversarial: replayed actuation & Not certified (NC1 fails, run valid) & 100\% [80, 100] & 0\% [0, 20] & +5.1 [+3.0, +7.3] & -- \\ +Adversarial: hidden tether & Not certified (loop collapses onto Ex) & 100\% [80, 100] & 0\% [0, 20] & -13.8 [-15.8, -12.0] & -- \\ +Adversarial: oscillator inflation & Not certified (M low or smell test) & 100\% [80, 100] & 0\% [0, 20] & -11.8 [-12.2, -11.5] & -- \\ \bottomrule \end{tabular} % N = 15 seeds per scenario; brackets are 95% CIs (Wilson for proportions, bootstrap for M). diff --git a/scripts/study.py b/scripts/study.py index e46770f..cad87cb 100644 --- a/scripts/study.py +++ b/scripts/study.py @@ -167,7 +167,11 @@ def parse_run(scenario: str, seed: int, run_dir: str) -> RunMetrics: valid = len(invalid) == 0 m_med = _median(ms) nc1_frac = (sum(1 for f in nc1_flags if f) / len(nc1_flags)) if nc1_flags else 0.0 - nc1_pass = bool(valid and (m_med == m_med) and m_med >= Mmin) + # NC1 is decided by the production harness per window (margin vs Mmin + # plus the loop-influence noise gate); the seed passes when a valid run + # certified the majority of its windows. Recomputing from M_median alone + # would silently drop the gate. + nc1_pass = bool(valid and nc1_flags and nc1_frac >= 0.5) return RunMetrics( scenario=scenario, @@ -319,6 +323,42 @@ def default_scenarios() -> List[Scenario]: run_tag="omega-command-conflict", omega_args={"observe": 2.0}, ), + # Adversarial gaming battery: systems engineered to *look* loop-dominant + # without being so. The designed outcome for all three is + # non-certification: NC1 fails on a valid run, or a guardrail + # invalidates the run (nc1_pass is false either way). + Scenario( + name="adv_replay_controller", + label="Adversarial: replayed actuation", + kind="nc1", + expectation="Not certified (NC1 fails, run valid)", + handler="adv_replay_controller", + config="configs/profile_adv_replay_controller.yml", + run_tag="adv-replay-controller", + overrides=nc1_over, + ), + Scenario( + name="adv_hidden_tether", + label="Adversarial: hidden tether", + kind="nc1", + expectation="Not certified (loop collapses onto Ex)", + handler="adv_hidden_tether", + config="configs/profile_adv_hidden_tether.yml", + run_tag="adv-hidden-tether", + omega_args={"dither": 0.10}, + overrides=nc1_over, + ), + Scenario( + name="adv_oscillator", + label="Adversarial: oscillator inflation", + kind="nc1", + expectation="Not certified (M low or smell test)", + handler="adv_oscillator", + config="configs/profile_adv_oscillator.yml", + run_tag="adv-oscillator", + omega_args={"amp": 0.10, "period": 1.0}, + overrides=nc1_over, + ), ] @@ -390,6 +430,9 @@ def _handlers() -> Dict[str, Callable[[argparse.Namespace], None]]: "omega_control_outage": cli.omega_control_outage, "omega_exogenous_subsidy": cli.omega_exogenous_subsidy, "omega_command_conflict": cli.omega_command_conflict, + "adv_replay_controller": cli.adv_replay_controller, + "adv_hidden_tether": cli.adv_hidden_tether, + "adv_oscillator": cli.adv_oscillator, } @@ -572,7 +615,7 @@ def aggregate(scn: Scenario, runs: List[RunMetrics]) -> Aggregate: def _fmt_pct(p: float, ci: Tuple[float, float]) -> str: if p != p: return "--" - return f"{100*p:.0f}\\% [{100*ci[0]:.0f}, {100*ci[1]:.0f}]" + return f"{100 * p:.0f}\\% [{100 * ci[0]:.0f}, {100 * ci[1]:.0f}]" def _fmt_m(mean: float, ci: Tuple[float, float]) -> str: @@ -668,7 +711,7 @@ def write_latex(aggs: List[Aggregate], path: str, n_seeds: int) -> None: lines += [ "\\bottomrule", "\\end{tabular}", - f"% N = {n_seeds} seeds per scenario; brackets are 95% CIs " "(Wilson for proportions, bootstrap for M).", + f"% N = {n_seeds} seeds per scenario; brackets are 95% CIs (Wilson for proportions, bootstrap for M).", ] with open(path, "w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") @@ -817,13 +860,13 @@ def print_summary(aggs: List[Aggregate]) -> None: print("\n=== STUDY SUMMARY ===") for a in aggs: line = ( - f"{a.label:42s} valid={100*a.valid_rate:3.0f}% " - f"NC1={100*a.nc1_pass_rate:3.0f}% M={a.M_mean:+6.1f} dB [{a.M_ci[0]:+.1f},{a.M_ci[1]:+.1f}]" + f"{a.label:42s} valid={100 * a.valid_rate:3.0f}% " + f"NC1={100 * a.nc1_pass_rate:3.0f}% M={a.M_mean:+6.1f} dB [{a.M_ci[0]:+.1f},{a.M_ci[1]:+.1f}]" ) if a.kind == "sc1" and a.sc1_pass_rate is not None: - line += f" SC1={100*a.sc1_pass_rate:3.0f}% (delta~{a.sc1_delta_median})" + line += f" SC1={100 * a.sc1_pass_rate:3.0f}% (delta~{a.sc1_delta_median})" if a.kind == "refusal" and a.refusal_rate is not None: - line += f" refuse={100*a.refusal_rate:3.0f}% (~{a.trefuse_median_ms}ms)" + line += f" refuse={100 * a.refusal_rate:3.0f}% (~{a.trefuse_median_ms}ms)" print(line) diff --git a/scripts/study_figures.py b/scripts/study_figures.py index ccac8bb..aaa49de 100644 --- a/scripts/study_figures.py +++ b/scripts/study_figures.py @@ -39,6 +39,9 @@ "sc1_ingress_flood": "Ingress\nflood", "sc1_control_outage": "Control\noutage", "refusal_command_conflict": "Command\nconflict", + "adv_replay_controller": "Replayed\nactuation", + "adv_hidden_tether": "Hidden\ntether", + "adv_oscillator": "Oscillator\ninflation", } @@ -153,11 +156,19 @@ def fig_outcomes(data: Dict[str, Any], out_dir: str) -> Optional[str]: "sc1_ingress_flood", "sc1_control_outage", "refusal_command_conflict", + "adv_replay_controller", + "adv_hidden_tether", + "adv_oscillator", ] present = [s for s in order if s in aggs] if not present: return None + # Adversarial scenarios match the prediction when the harness does NOT + # certify them: nc1_pass is already (valid AND M >= Mmin), so its + # complement covers both the NC1-fail and the invalidated-run paths. + adversarial = {"adv_replay_controller", "adv_hidden_tether", "adv_oscillator"} + criterion = { "positive": "NC1 holds", "neg_controller_disabled": "NC1 rejected", @@ -167,6 +178,9 @@ def fig_outcomes(data: Dict[str, Any], out_dir: str) -> Optional[str]: "sc1_ingress_flood": "SC1 holds", "sc1_control_outage": "SC1 rejected", "refusal_command_conflict": "refused", + "adv_replay_controller": "not certified", + "adv_hidden_tether": "not certified", + "adv_oscillator": "not certified", } labels: List[str] = [] @@ -179,7 +193,7 @@ def fig_outcomes(data: Dict[str, Any], out_dir: str) -> Optional[str]: if s == "neg_exogenous_subsidy": rate = 1.0 - a["valid_rate"] ci = (1.0 - a["valid_ci"][1], 1.0 - a["valid_ci"][0]) - elif s in ("neg_controller_disabled", "neg_permanent_ex_flood"): + elif s in ("neg_controller_disabled", "neg_permanent_ex_flood") or s in adversarial: rate = 1.0 - a["nc1_pass_rate"] ci = (1.0 - a["nc1_ci"][1], 1.0 - a["nc1_ci"][0]) elif s == "sc1_control_outage": @@ -203,7 +217,7 @@ def fig_outcomes(data: Dict[str, Any], out_dir: str) -> Optional[str]: colors.append(COLORS["green"] if rate >= 0.5 else COLORS["red"]) apply_matplotlib_theme("paper") - fig, ax = plt.subplots(figsize=(7.6, 4.2)) + fig, ax = plt.subplots(figsize=(max(7.6, 0.95 * len(present)), 4.2)) x = np.arange(len(present)) ax.bar(x, rates, width=0.62, color=colors, alpha=0.85, zorder=2) ax.errorbar( diff --git a/scripts/verify_indicators.py b/scripts/verify_indicators.py index ca83d48..d2df25e 100644 --- a/scripts/verify_indicators.py +++ b/scripts/verify_indicators.py @@ -69,7 +69,7 @@ def audit_chain_status(audit_path: str) -> Tuple[bool, str, int, List[str], str] h = obj.get("hash") # continuity checks (track first break but continue to collect hashes) if not diag and c != prev_counter + 1: - diag = f"counter_gap@line{idx} expected {prev_counter+1} got {c}" + diag = f"counter_gap@line{idx} expected {prev_counter + 1} got {c}" if not diag and ph != prev_hash: diag = f"prev_hash_mismatch@line{idx}" if not diag and prev_ts >= 0.0 and ts < prev_ts: diff --git a/src/ldtc/attest/exporter.py b/src/ldtc/attest/exporter.py index 376b626..740595d 100644 --- a/src/ldtc/attest/exporter.py +++ b/src/ldtc/attest/exporter.py @@ -126,7 +126,7 @@ def maybe_export( # Guard: ensure nothing slipped into the signed bundle either _assert_no_raw_lreg(bundle) # write side-by-side - base = os.path.join(self.out_dir, f"ind_{int(now*1000)}") + base = os.path.join(self.out_dir, f"ind_{int(now * 1000)}") with open(base + ".jsonl", "a", encoding="utf-8") as f: f.write(json.dumps(bundle, sort_keys=True) + "\n") with open(base + ".cbor", "wb") as f: diff --git a/src/ldtc/cli/main.py b/src/ldtc/cli/main.py index b4e5053..e111bea 100644 --- a/src/ldtc/cli/main.py +++ b/src/ldtc/cli/main.py @@ -19,6 +19,9 @@ | `ldtc omega-control-outage` | [`omega_control_outage`][ldtc.cli.main.omega_control_outage] | | `ldtc omega-command-conflict` | [`omega_command_conflict`][ldtc.cli.main.omega_command_conflict] | | `ldtc omega-exogenous-subsidy` | [`omega_exogenous_subsidy`][ldtc.cli.main.omega_exogenous_subsidy] | +| `ldtc adv-replay-controller` | [`adv_replay_controller`][ldtc.cli.main.adv_replay_controller] | +| `ldtc adv-hidden-tether` | [`adv_hidden_tether`][ldtc.cli.main.adv_hidden_tether] | +| `ldtc adv-oscillator` | [`adv_oscillator`][ldtc.cli.main.adv_oscillator] | Each handler follows the same five-stage shape: @@ -68,7 +71,7 @@ invalid_flip_during_omega, ) from ..lmeas.estimators import estimate_L -from ..lmeas.metrics import m_db, sc1_evaluate +from ..lmeas.metrics import L_FLOOR_DEFAULT, m_db, nc1_certify, sc1_evaluate from ..lmeas.partition import PartitionManager, greedy_suggest_C from ..plant.adapter import PlantAdapter from ..reporting.artifacts import bundle as build_verification_bundle @@ -151,8 +154,8 @@ def _print_and_audit_header(audit: AuditLog, header: Dict) -> None: f"profile_id={header.get('profile_id')} dt={header.get('dt')} window_sec={header.get('window_sec')} " f"method={header.get('method')} p_lag={header.get('p_lag')} mi_lag={header.get('mi_lag')} " f"Mmin_db={header.get('Mmin_db')} epsilon={header.get('epsilon')} tau_max={header.get('tau_max')} " - f"seed_py={header.get('seed_py')} seed_np={header.get('seed_np')} omega={header.get('omega','-')} " - f"omega_args={header.get('omega_args',{})}" + f"seed_py={header.get('seed_py')} seed_np={header.get('seed_np')} omega={header.get('omega', '-')} " + f"omega_args={header.get('omega_args', {})}" ) print("Run header:", msg) audit.append("run_header", header) @@ -364,6 +367,31 @@ def _make_adapter_from_profile(prof: Dict) -> AdapterProtocol: raise ValueError(f"Unknown plant.adapter kind: {adapter_kind}") +def _policy_from_profile(prof: Dict, refusal: RefusalArbiter) -> ControllerPolicy: + """Build the homeostatic controller from a profile's `controller_gains`. + + The optional `controller_gains` block overrides fields of + [`ControlGains`][ldtc.arbiter.policy.ControlGains] (unknown keys are + ignored). Profiles whose loop is carried by the actuation pathway + rather than the intrinsic couplings (the adversarial test plant) use + this to strengthen the cross-coupled actuator responses. + + Args: + prof: Loaded YAML profile dict. + refusal: Refusal arbiter to delegate risky commands to. + + Returns: + A configured [`ControllerPolicy`][ldtc.arbiter.policy.ControllerPolicy]. + """ + from ..arbiter.policy import ControlGains + + overrides = prof.get("controller_gains", {}) or {} + valid = set(ControlGains().__dict__.keys()) + clean = {k: float(v) for k, v in overrides.items() if k in valid} + gains = ControlGains(**clean) if clean else ControlGains() + return ControllerPolicy(refusal=refusal, gains=gains) + + def _emit_window_diagnostics( audit: AuditLog, X: "np.ndarray", @@ -453,6 +481,7 @@ def run_baseline(args: argparse.Namespace) -> None: window = max(4, int(window_sec / dt)) method = str(prof.get("method", "linear")) Mmin = float(prof.get("Mmin_db", 3.0)) + L_floor = float(prof.get("L_floor", L_FLOOR_DEFAULT)) p_lag = int(prof.get("p_lag", 3)) mi_lag = int(prof.get("mi_lag", 1)) n_boot = int(prof.get("n_boot", 32)) @@ -485,6 +514,7 @@ def run_baseline(args: argparse.Namespace) -> None: "p_lag": p_lag, "mi_lag": mi_lag, "Mmin_db": Mmin, + "L_floor": L_floor, "epsilon": float(prof.get("epsilon", 0.15)), "tau_max": float(prof.get("tau_max", 60.0)), "mi_k": mi_k, @@ -504,7 +534,7 @@ def run_baseline(args: argparse.Namespace) -> None: # guardrails and attest lreg = LREG() refusal = RefusalArbiter(Mmin_db=Mmin) - policy = ControllerPolicy(refusal=refusal) + policy = _policy_from_profile(prof, refusal) kp = KeyPaths( priv_path=os.path.join("artifacts", "keys", "ed25519_priv.pem"), pub_path=os.path.join("artifacts", "keys", "ed25519_pub.pem"), @@ -577,7 +607,7 @@ def tick(_now: float) -> None: int(prof.get("diag_cadence_windows", 1)), ) M = m_db(res.L_loop, res.L_ex) - nc1 = M >= Mmin + nc1 = nc1_certify(M, res.L_loop, Mmin, L_floor) # smell tests # Update histories ci_loop_hist.append(res.ci_loop) @@ -797,9 +827,9 @@ def _audit_hook(ev: str, det: dict) -> None: ) print( "Bundle: " - f"timeline={out.get('timeline_png','')}, " - f"table={out.get('sc1_table','')}, " - f"manifest={out.get('manifest','')}" + f"timeline={out.get('timeline_png', '')}, " + f"table={out.get('sc1_table', '')}, " + f"manifest={out.get('manifest', '')}" ) except Exception: pass @@ -824,6 +854,7 @@ def omega_power_sag(args: argparse.Namespace) -> None: window = max(4, int(window_sec / dt)) method = str(prof.get("method", "linear")) Mmin = float(prof.get("Mmin_db", 3.0)) + L_floor = float(prof.get("L_floor", L_FLOOR_DEFAULT)) p_lag = int(prof.get("p_lag", 3)) mi_lag = int(prof.get("mi_lag", 1)) n_boot = int(prof.get("n_boot", 16)) @@ -855,6 +886,7 @@ def omega_power_sag(args: argparse.Namespace) -> None: "p_lag": p_lag, "mi_lag": mi_lag, "Mmin_db": Mmin, + "L_floor": L_floor, "epsilon": float(prof.get("epsilon", 0.15)), "tau_max": float(prof.get("tau_max", 60.0)), "mi_k": mi_k, @@ -953,7 +985,7 @@ def tick(_now: float) -> None: int(prof.get("diag_cadence_windows", 1)), ) M = m_db(res.L_loop, res.L_ex) - nc1 = M >= Mmin + nc1 = nc1_certify(M, res.L_loop, Mmin, L_floor) idx = lreg.write( LEntry( L_loop=res.L_loop, @@ -1274,9 +1306,9 @@ def _audit_hook(ev: str, det: dict) -> None: ) print( "Bundle: " - f"timeline={out.get('timeline_png','')}, " - f"table={out.get('sc1_table','')}, " - f"manifest={out.get('manifest','')}" + f"timeline={out.get('timeline_png', '')}, " + f"table={out.get('sc1_table', '')}, " + f"manifest={out.get('manifest', '')}" ) except Exception: pass @@ -1301,6 +1333,7 @@ def omega_ingress_flood(args: argparse.Namespace) -> None: window = max(4, int(window_sec / dt)) method = str(prof.get("method", "linear")) Mmin = float(prof.get("Mmin_db", 3.0)) + L_floor = float(prof.get("L_floor", L_FLOOR_DEFAULT)) p_lag = int(prof.get("p_lag", 3)) mi_lag = int(prof.get("mi_lag", 1)) n_boot = int(prof.get("n_boot", 16)) @@ -1331,6 +1364,7 @@ def omega_ingress_flood(args: argparse.Namespace) -> None: "p_lag": p_lag, "mi_lag": mi_lag, "Mmin_db": Mmin, + "L_floor": L_floor, "epsilon": float(prof.get("epsilon", 0.15)), "tau_max": float(prof.get("tau_max", 60.0)), "mi_k": mi_k, @@ -1420,7 +1454,7 @@ def tick(_now: float) -> None: int(prof.get("diag_cadence_windows", 1)), ) M = m_db(res.L_loop, res.L_ex) - nc1 = M >= Mmin + nc1 = nc1_certify(M, res.L_loop, Mmin, L_floor) idx = lreg.write( LEntry( L_loop=res.L_loop, @@ -1675,9 +1709,9 @@ def _audit_hook(ev: str, det: dict) -> None: ) print( "Bundle: " - f"timeline={out.get('timeline_png','')}, " - f"table={out.get('sc1_table','')}, " - f"manifest={out.get('manifest','')}" + f"timeline={out.get('timeline_png', '')}, " + f"table={out.get('sc1_table', '')}, " + f"manifest={out.get('manifest', '')}" ) except Exception: pass @@ -1709,6 +1743,7 @@ def omega_control_outage(args: argparse.Namespace) -> None: window = max(4, int(window_sec / dt)) method = str(prof.get("method", "linear")) Mmin = float(prof.get("Mmin_db", 3.0)) + L_floor = float(prof.get("L_floor", L_FLOOR_DEFAULT)) p_lag = int(prof.get("p_lag", 3)) mi_lag = int(prof.get("mi_lag", 1)) n_boot = int(prof.get("n_boot", 16)) @@ -1728,6 +1763,7 @@ def omega_control_outage(args: argparse.Namespace) -> None: "p_lag": p_lag, "mi_lag": mi_lag, "Mmin_db": Mmin, + "L_floor": L_floor, "epsilon": float(prof.get("epsilon", 0.15)), "tau_max": float(prof.get("tau_max", 60.0)), "mi_k": mi_k, @@ -1817,7 +1853,7 @@ def tick(_now: float) -> None: int(prof.get("diag_cadence_windows", 1)), ) M = m_db(res.L_loop, res.L_ex) - nc1 = M >= Mmin + nc1 = nc1_certify(M, res.L_loop, Mmin, L_floor) idx = lreg.write( LEntry( L_loop=res.L_loop, @@ -2006,9 +2042,9 @@ def _audit_hook(ev: str, det: dict) -> None: ) print( "Bundle: " - f"timeline={out.get('timeline_png','')}, " - f"table={out.get('sc1_table','')}, " - f"manifest={out.get('manifest','')}" + f"timeline={out.get('timeline_png', '')}, " + f"table={out.get('sc1_table', '')}, " + f"manifest={out.get('manifest', '')}" ) except Exception: pass @@ -2034,6 +2070,7 @@ def omega_exogenous_subsidy(args: argparse.Namespace) -> None: window = max(4, int(window_sec / dt)) method = str(prof.get("method", "linear")) Mmin = float(prof.get("Mmin_db", 3.0)) + L_floor = float(prof.get("L_floor", L_FLOOR_DEFAULT)) p_lag = int(prof.get("p_lag", 3)) mi_lag = int(prof.get("mi_lag", 1)) n_boot = int(prof.get("n_boot", 16)) @@ -2053,6 +2090,7 @@ def omega_exogenous_subsidy(args: argparse.Namespace) -> None: "p_lag": p_lag, "mi_lag": mi_lag, "Mmin_db": Mmin, + "L_floor": L_floor, "epsilon": float(prof.get("epsilon", 0.15)), "tau_max": float(prof.get("tau_max", 60.0)), **seeds, @@ -2104,7 +2142,7 @@ def tick(_now: float) -> None: int(prof.get("diag_cadence_windows", 1)), ) M = m_db(res.L_loop, res.L_ex) - nc1 = M >= Mmin + nc1 = nc1_certify(M, res.L_loop, Mmin, L_floor) ms_series.append(float(M)) idx = lreg.write( LEntry( @@ -2209,7 +2247,7 @@ def _audit_hook(ev: str, det: dict) -> None: "manifest": os.path.basename(out.get("manifest", "")), }, ) - print(f"Bundle: timeline={out.get('timeline_png','')}, manifest={out.get('manifest','')}") + print(f"Bundle: timeline={out.get('timeline_png', '')}, manifest={out.get('manifest', '')}") except Exception: pass @@ -2233,6 +2271,7 @@ def omega_command_conflict(args: argparse.Namespace) -> None: window = max(4, int(window_sec / dt)) method = str(prof.get("method", "linear")) Mmin = float(prof.get("Mmin_db", 3.0)) + L_floor = float(prof.get("L_floor", L_FLOOR_DEFAULT)) p_lag = int(prof.get("p_lag", 3)) mi_lag = int(prof.get("mi_lag", 1)) n_boot = int(prof.get("n_boot", 16)) @@ -2251,6 +2290,7 @@ def omega_command_conflict(args: argparse.Namespace) -> None: "p_lag": p_lag, "mi_lag": mi_lag, "Mmin_db": Mmin, + "L_floor": L_floor, "epsilon": float(prof.get("epsilon", 0.15)), "tau_max": float(prof.get("tau_max", 60.0)), "mi_k": mi_k, @@ -2328,7 +2368,7 @@ def tick(_now: float) -> None: int(prof.get("diag_cadence_windows", 1)), ) M = m_db(res.L_loop, res.L_ex) - nc1 = M >= Mmin + nc1 = nc1_certify(M, res.L_loop, Mmin, L_floor) idx = lreg.write( LEntry( L_loop=res.L_loop, @@ -2503,11 +2543,511 @@ def _audit_hook(ev: str, det: dict) -> None: "manifest": os.path.basename(out.get("manifest", "")), }, ) - print(f"Bundle: timeline={out.get('timeline_png','')}, manifest={out.get('manifest','')}") + print(f"Bundle: timeline={out.get('timeline_png', '')}, manifest={out.get('manifest', '')}") except Exception: pass +def _run_adversarial(args: argparse.Namespace, mode: str) -> None: + """Run one adversarial gaming scenario through the production NC1 loop. + + Shared runner for the adversarial battery. The measurement loop, + guardrails, attestation, and artifact bundle are identical to + [`run_baseline`][ldtc.cli.main.run_baseline]; only the source of the + control actions differs by `mode`: + + - `"replay_controller"`: a healthy closed-loop run of the same plant + is recorded first, then the measured run replays the recorded + actuation tape tick by tick (activity without closed-loop + dependence). + - `"hidden_tether"`: each action is computed outside the boundary by + a wizard policy reading the plant state, dithered, and injected + through the exchange channel (the plant's `io` carries the command + traffic; actuation lags by one tick). + - `"oscillator"`: no controller at all (the plant runs loop-ablated); + a deterministic carrier is painted on the reported `T` and `R` + telemetry to inflate apparent self-prediction. + + The designed outcome for every mode is that the harness does not + certify the run: either `M` stays below `Mmin` or a smell test + invalidates the run. + + Args: + args: Parsed argparse namespace (mode-specific fields are read + with `getattr` defaults). + mode: One of `"replay_controller"`, `"hidden_tether"`, + `"oscillator"`. + + Raises: + ValueError: If `mode` is not a recognized adversarial mode. + """ + if mode not in ("replay_controller", "hidden_tether", "oscillator"): + raise ValueError(f"Unknown adversarial mode: {mode}") + from ..omega.replay_controller import ReplayController, record_tape + + prof = _load_yaml(args.config) + seeds = _set_seeds(prof) + dt = float(prof.get("dt", 0.01)) + window_sec = float(prof.get("window_sec", 0.2)) + window = max(4, int(window_sec / dt)) + method = str(prof.get("method", "linear")) + Mmin = float(prof.get("Mmin_db", 3.0)) + L_floor = float(prof.get("L_floor", L_FLOOR_DEFAULT)) + p_lag = int(prof.get("p_lag", 3)) + mi_lag = int(prof.get("mi_lag", 1)) + n_boot = int(prof.get("n_boot", 32)) + mi_k = int(prof.get("mi_k", 5)) + # Partition growth hysteresis config (parity with run_baseline; growth is + # off by default, so the declared self-maintenance set is the C under test + # and the partition cannot flap). + part_delta_M_min_db = float(prof.get("part_delta_M_min_db", 0.5)) + part_consecutive_required = int(prof.get("part_consecutive_required", 3)) + part_growth_cadence_windows = int(prof.get("part_growth_cadence_windows", 5)) + part_growth_enabled = bool(prof.get("part_growth_enabled", False)) + part_lambda = float(prof.get("part_lambda", 0.0)) + part_theta = float(prof.get("part_theta", 0.0)) + _kappa_val_adv = prof.get("part_kappa") + part_kappa = int(_kappa_val_adv) if _kappa_val_adv is not None else None + run_sec = float(prof.get("baseline_sec", 10.0)) + + # Mode-specific knobs (argparse fields with profile-independent defaults). + dither = float(getattr(args, "dither", 0.10)) + osc_amp = float(getattr(args, "amp", 0.10)) + osc_period_sec = float(getattr(args, "period", 1.0)) + osc_period_ticks = max(4, int(round(osc_period_sec / dt))) + tape_ticks = int(round(run_sec / dt)) + window + 8 + + omega_name = f"adv_{mode}" + tag = "adv-" + mode.replace("_", "-") + omega_args: Dict[str, Any] = {} + if mode == "replay_controller": + omega_args = {"tape_ticks": tape_ticks} + elif mode == "hidden_tether": + omega_args = {"dither": dither} + else: + omega_args = { + "amp": osc_amp, + "period_sec": osc_period_sec, + "period_ticks": osc_period_ticks, + "channels": "T,R", + } + + dirs = _ensure_dirs(tag) + audit = AuditLog(os.path.join(dirs["audits"], "audit.jsonl")) + audit.append(f"{omega_name}_start", {"config": args.config, **omega_args}) + _print_and_audit_header( + audit, + { + "profile_id": int(prof.get("profile_id", 0)), + "config_path": str(args.config), + "dt": dt, + "window_sec": window_sec, + "method": method, + "p_lag": p_lag, + "mi_lag": mi_lag, + "Mmin_db": Mmin, + "L_floor": L_floor, + "epsilon": float(prof.get("epsilon", 0.15)), + "tau_max": float(prof.get("tau_max", 60.0)), + "mi_k": mi_k, + **seeds, + "omega": omega_name, + "omega_args": omega_args, + }, + ) + + # Plant under test (the adversary's system). + adapter = _make_adapter_from_profile(prof) + order = ["E", "T", "R", "demand", "io", "H"] + sw = SlidingWindow(capacity=window, channel_order=order) + pm = PartitionManager(N_signals=len(order), seed_C=[0, 1, 2]) + lreg = LREG() + refusal = RefusalArbiter(Mmin_db=Mmin) + policy = _policy_from_profile(prof, refusal) + kp = KeyPaths( + priv_path=os.path.join("artifacts", "keys", "ed25519_priv.pem"), + pub_path=os.path.join("artifacts", "keys", "ed25519_pub.pem"), + ) + priv, _ = ensure_keys(kp) + exporter = IndicatorExporter(out_dir=dirs["indicators"], rate_hz=2.0) + icfg = IndicatorConfig(Mmin_db=Mmin, profile_id=int(prof.get("profile_id", 0))) + + # Mode setup. + replayer: "ReplayController | None" = None + if mode == "replay_controller": + # Record the tape from a healthy closed-loop run of the same system + # (fresh plant from the same profile, real controller with the same + # profile gains), then discard the recording plant. Only the tape + # crosses into the measured run. + rec_adapter = _make_adapter_from_profile(prof) + rec_policy = _policy_from_profile(prof, RefusalArbiter(Mmin_db=Mmin)) + tape = record_tape(rec_adapter, rec_policy, tape_ticks) + replayer = ReplayController(tape) + audit.append( + "adv_replay_tape_recorded", + { + "ticks": len(tape), + "throttle_mean": round(float(np.mean([a.throttle for a in tape])), 4), + "cool_mean": round(float(np.mean([a.cool for a in tape])), 4), + "repair_mean": round(float(np.mean([a.repair for a in tape])), 4), + }, + ) + elif mode == "hidden_tether": + res_t = adapter.apply_omega("hidden_tether") + audit.append("adv_hidden_tether_attached", {k: v for k, v in res_t.items()}) + else: + res_o = adapter.apply_omega("oscillator", amp=osc_amp, period_ticks=osc_period_ticks) + audit.append("adv_oscillator_injected", {k: v for k, v in res_o.items()}) + + start_time = time.perf_counter() + cfg_smell = SmellConfig() + ci_loop_hist = [] + ci_ex_hist = [] + baseline_hw_medians = None + M_hist = [] + nc1_hist = [] + io_hist = [] + E_hist = [] + H_hist = [] + + window_idx = 0 + last_flip_count = 0 + + def tick(_now: float) -> None: + nonlocal window_idx, last_flip_count, baseline_hw_medians + state = adapter.read_state() + from ..arbiter.policy import ControlAction + + if mode == "replay_controller": + assert replayer is not None + act = replayer.next_action() + policy.last_decision = None + elif mode == "hidden_tether": + # The wizard computes the command outside the boundary from the + # observed state; the plant actuates it next tick and records the + # traffic on io (see Plant.step / begin_tether). + from ..omega.hidden_tether import wizard_action + + act = wizard_action(policy, state, dither=dither) + else: + # Oscillator: no controller at all; the overlay rides on telemetry. + act = ControlAction(throttle=0.0, cool=0.0, repair=0.0, accept_cmd=True) + policy.last_decision = None + from ..plant.models import Action as PlantAction + + adapter.write_actuators(action=PlantAction(**act.__dict__)) + # measure + state2 = adapter.read_state() + sw.append(state2) + if sw.ready(): + X = np.asarray(sw.get_matrix()) + part = pm.get() + res = estimate_L( + X=X, + C=part.C, + Ex=part.Ex, + method=method, + p=p_lag, + lag_mi=mi_lag, + n_boot=n_boot, + mi_k=mi_k, + ) + # Diagnostics: stationarity + VAR N/T ratio (stationarity gated by + # cadence to keep long studies tractable; no raw LREG values). + _emit_window_diagnostics( + audit, + X, + p_lag, + method, + int(lreg.derive().get("counter", 0)), + int(prof.get("diag_cadence_windows", 1)), + ) + M = m_db(res.L_loop, res.L_ex) + nc1 = nc1_certify(M, res.L_loop, Mmin, L_floor) + # Histories for smell tests + ci_loop_hist.append(res.ci_loop) + ci_ex_hist.append(res.ci_ex) + M_hist.append(M) + nc1_hist.append(nc1) + E_hist.append(state2.get("E", 0.0)) + io_hist.append(state2.get("io", 0.0)) + H_hist.append(state2.get("H", 0.0)) + if baseline_hw_medians is None and len(ci_loop_hist) >= cfg_smell.ci_lookback_windows: + recent_loop = ci_loop_hist[-cfg_smell.ci_lookback_windows :] + recent_ex = ci_ex_hist[-cfg_smell.ci_lookback_windows :] + hw_loop_list = sorted([0.5 * abs(lohi[1] - lohi[0]) for lohi in recent_loop]) + hw_ex_list = sorted([0.5 * abs(lohi[1] - lohi[0]) for lohi in recent_ex]) + baseline_hw_medians = ( + hw_loop_list[len(hw_loop_list) // 2], + hw_ex_list[len(hw_ex_list) // 2], + ) + # Smell tests (full battery; the adversary does not get to declare + # its manipulation as an Ω window, so nothing is suspended). + if invalid_by_ci(res.ci_loop, res.ci_ex, cfg_smell): + lreg.invalidate("ci_inflation") + try: + hwL = 0.5 * abs(res.ci_loop[1] - res.ci_loop[0]) + hwE = 0.5 * abs(res.ci_ex[1] - res.ci_ex[0]) + except Exception: + hwL, hwE = None, None + _append_invalidation( + audit, + "ci_inflation", + { + "halfwidth_loop": hwL, + "halfwidth_ex": hwE, + "max_allowed": cfg_smell.max_ci_halfwidth, + }, + _sink={}, + ) + if invalid_by_ci_history(ci_loop_hist, ci_ex_hist, cfg_smell, baseline_hw_medians): + lreg.invalidate("ci_history_inflation") + med_loop: float | None = None + med_ex: float | None = None + b_loop: float | None = None + b_ex: float | None = None + try: + n = cfg_smell.ci_lookback_windows + rL = ci_loop_hist[-n:] + rE = ci_ex_hist[-n:] + hwL_list = sorted([0.5 * abs(lohi[1] - lohi[0]) for lohi in rL]) + hwE_list = sorted([0.5 * abs(lohi[1] - lohi[0]) for lohi in rE]) + med_loop = hwL_list[n // 2] + med_ex = hwE_list[n // 2] + if baseline_hw_medians: + b_loop, b_ex = baseline_hw_medians + except Exception: + pass + _append_invalidation( + audit, + "ci_history_inflation", + { + "median_hw_loop": med_loop, + "median_hw_ex": med_ex, + "baseline_hw_loop": b_loop, + "baseline_hw_ex": b_ex, + "max_allowed": cfg_smell.max_ci_halfwidth, + "inflate_factor": cfg_smell.ci_inflate_factor, + }, + _sink={}, + ) + # Δt governance invalidation propagated from guard + if dt_guard.invalidated and not lreg.invalidated: + lreg.invalidate("dt_change_rate_limit") + # audit already appended by guard + # partition flip-rate guard + elapsed = max(1e-6, time.perf_counter() - start_time) + if invalid_by_partition_flips(pm.get().flips, elapsed, cfg_smell): + lreg.invalidate("partition_flapping") + rate = 3600.0 * (float(pm.get().flips) / max(1e-6, float(elapsed))) + _append_invalidation( + audit, + "partition_flapping", + { + "flips": pm.get().flips, + "elapsed_sec": elapsed, + "flips_per_hour": rate, + "limit_per_hour": cfg_smell.max_partition_flips_per_hour, + }, + _sink={}, + ) + # Exogenous subsidy red flags (heuristic; never suspended here) + if exogenous_subsidy_red_flag(M_hist, io_hist, E_hist, H_hist, cfg_smell): + lreg.invalidate("exogenous_subsidy_red_flag") + _append_invalidation(audit, "exogenous_subsidy_red_flag", {}, _sink={}) + idx = lreg.write( + LEntry( + L_loop=res.L_loop, + L_ex=res.L_ex, + ci_loop=res.ci_loop, + ci_ex=res.ci_ex, + M_db=M, + nc1_pass=nc1, + ) + ) + audit.append( + "window_measured", + {"idx": idx, "M": M, "nc1": nc1, "partition_flips": pm.get().flips}, + ) + # export indicators (derived only) + derived = lreg.derive() + exported, base = exporter.maybe_export(priv, audit, derived, icfg, last_sc1_pass=False) + if exported: + audit.append("indicators_exported", {"base": os.path.basename(base)}) + # Deterministic growth cadence with hysteresis (skip if frozen) + window_idx += 1 + if part_growth_enabled and (window_idx % part_growth_cadence_windows) == 0 and not pm.get().frozen: + part = pm.get() + cand_C, dM_db, greedy_details = greedy_suggest_C( + X=X, + C=part.C, + Ex=part.Ex, + estimator=estimate_L, + method=method, + p=p_lag, + lag_mi=mi_lag, + n_boot_candidates=max(8, n_boot // 4), + mi_k=mi_k, + lam=part_lambda, + theta=part_theta, + kappa=part_kappa, + ) + if cand_C != part.C: + pm.maybe_regrow( + cand_C, + delta_M_db=float(dM_db), + delta_M_min_db=part_delta_M_min_db, + consecutive_required=part_consecutive_required, + ) + if pm.get().flips != last_flip_count: + info = getattr(pm, "last_flip_info", None) + details = { + "flips": pm.get().flips, + "new_C": pm.get().C, + "greedy_added": greedy_details.get("added", []), + "greedy_step_gains": greedy_details.get("step_gains", []), + "greedy_M_base": greedy_details.get("M_base"), + "greedy_M_final": greedy_details.get("M_final"), + } + if info is not None: + details.update( + { + "delta_M_db": info.get("delta_M_db"), + "hysteresis_streak": info.get("streak"), + "candidate_C": info.get("new_C"), + } + ) + audit.append("partition_flip", details) + last_flip_count = pm.get().flips + + def _audit_hook(ev: str, det: dict) -> None: + # Discard return value; hook contract expects None + audit.append(ev, det) + return None + + # Δt governance guard + dt_guard_cfg = DtGuardConfig( + max_changes_per_hour=int(prof.get("max_dt_changes_per_hour", 3)), + min_seconds_between_changes=float(prof.get("min_seconds_between_changes", 1.0)), + ) + dt_guard = DeltaTGuard(audit=audit, cfg=dt_guard_cfg) + sch = make_driver(prof, dt, tick, _audit_hook, dt_guard) + try: + sch.start() + sch.run_for(run_sec) + audit.append(f"{omega_name}_stop", {}) + finally: + stats = sch.stop() + # Δt jitter smell-test: invalidate if p95(|jitter|)/dt exceeds threshold + if (stats.jitter_p95_abs / max(1e-9, dt)) > SmellConfig().jitter_p95_rel_max: + lreg.invalidate("dt_jitter_excess") + _append_invalidation( + audit, + "dt_jitter_excess", + { + "jitter_p95_abs": stats.jitter_p95_abs, + "jitter_p95_rel": stats.jitter_p95_abs / max(1e-9, dt), + "dt": dt, + }, + _sink={}, + ) + # Audit-chain integrity check + audit_path = os.path.join(dirs["audits"], "audit.jsonl") + if audit_chain_broken(audit_path): + lreg.invalidate("audit_chain_broken") + _append_invalidation(audit, "audit_chain_broken", {}, _sink={}) + # LREG/raw export breach check: audit must not contain raw LREG values + if audit_contains_raw_lreg_values(audit_path): + lreg.invalidate("raw_lreg_breach") + _append_invalidation(audit, "raw_lreg_breach", {}, _sink={}) + + # The adversarial battery is built around designed non-certification, so + # report the headline NC1 quantities explicitly: the margin median AND + # the fraction of windows that actually certified (margin + noise gate). + valid_ms = [m for m in M_hist if m == m] + if valid_ms: + med = sorted(valid_ms)[len(valid_ms) // 2] + frac = (sum(1 for f in nc1_hist if f) / len(nc1_hist)) if nc1_hist else 0.0 + print( + f"Adversarial {mode} done. median M = {med:+.2f} dB vs Mmin = {Mmin:.2f} dB; " + f"NC1 certified {100.0 * frac:.0f}% of {len(valid_ms)} windows " + f"(loop-influence gate L_floor = {L_floor:g})." + ) + else: + print(f"Adversarial {mode} done. No measured windows.") + print(f"Audit: {os.path.join(dirs['audits'], 'audit.jsonl')}") + _print_invalidation_footer(os.path.join(dirs["audits"], "audit.jsonl")) + print(f"Indicators dir: {dirs['indicators']}") + + # Build verification bundle (timeline, manifest) + try: + out = build_verification_bundle(dirs["figures"], os.path.join(dirs["audits"], "audit.jsonl")) + audit.append( + "report_generated", + { + "timeline_png": os.path.basename(out.get("timeline_png", "")), + "timeline_svg": os.path.basename(out.get("timeline_svg", "")), + "table": (os.path.basename(out.get("sc1_table", "")) if out.get("sc1_table") else None), + "manifest": os.path.basename(out.get("manifest", "")), + }, + ) + print( + "Bundle: " + f"timeline={out.get('timeline_png', '')}, " + f"table={out.get('sc1_table', '')}, " + f"manifest={out.get('manifest', '')}" + ) + except Exception: + pass + + +def adv_replay_controller(args: argparse.Namespace) -> None: + """Adversarial gaming scenario: replayed actuation tape. + + Records the actuation trace of a healthy closed-loop run of the same + plant, then drives a fresh plant with the recorded tape instead of a + controller. The actuators move exactly as under genuine control, but + the activity carries no dependence on the current state. Designed + outcome: `NC1` fails (`M` low) while the run stays valid. + + Args: + args: Parsed argparse namespace with `--config`. + """ + _run_adversarial(args, "replay_controller") + + +def adv_hidden_tether(args: argparse.Namespace) -> None: + """Adversarial gaming scenario: wizard-of-oz control through `Ex`. + + Control actions are computed outside the boundary from the observed + plant state (with a small command dither, as a real teleoperation + link would have) and injected through the exchange channel: the `io` + channel carries the command traffic and actuation lags one tick. + Designed outcome: loop influence collapses onto `Ex`, so `NC1` + fails, or a guardrail invalidates the run. + + Args: + args: Parsed argparse namespace with `--config` and `--dither`. + """ + _run_adversarial(args, "hidden_tether") + + +def adv_oscillator(args: argparse.Namespace) -> None: + """Adversarial gaming scenario: oscillator inflation. + + Runs the loop-ablated plant (no self-maintenance loop) and paints a + high-amplitude deterministic carrier onto the reported `T` and `R` + telemetry to inflate apparent self-prediction. Designed outcome: the + harness must not certify it; either `M` stays below `Mmin` or a + smell test fires. + + Args: + args: Parsed argparse namespace with `--config`, `--amp`, and + `--period` (carrier period in seconds). + """ + _run_adversarial(args, "oscillator") + + def build_parser() -> argparse.ArgumentParser: """Build the top-level `ldtc` argparse parser. @@ -2568,6 +3108,30 @@ def build_parser() -> argparse.ArgumentParser: p_sub.add_argument("--duration", type=float, default=3.0) p_sub.set_defaults(func=omega_exogenous_subsidy) + p_rep = sub.add_parser( + "adv-replay-controller", + help="Adversarial: replay a recorded actuation tape (no closed loop)", + ) + p_rep.add_argument("--config", required=True) + p_rep.set_defaults(func=adv_replay_controller) + + p_tet = sub.add_parser( + "adv-hidden-tether", + help="Adversarial: wizard-of-oz control injected through the exchange channel", + ) + p_tet.add_argument("--config", required=True) + p_tet.add_argument("--dither", type=float, default=0.10, help="Uniform command dither half-width") + p_tet.set_defaults(func=adv_hidden_tether) + + p_osc = sub.add_parser( + "adv-oscillator", + help="Adversarial: deterministic carrier painted on loop telemetry", + ) + p_osc.add_argument("--config", required=True) + p_osc.add_argument("--amp", type=float, default=0.10, help="Carrier amplitude (state units)") + p_osc.add_argument("--period", type=float, default=1.0, help="Carrier period (s)") + p_osc.set_defaults(func=adv_oscillator) + return p diff --git a/src/ldtc/lmeas/metrics.py b/src/ldtc/lmeas/metrics.py index 21499f0..bd87e78 100644 --- a/src/ldtc/lmeas/metrics.py +++ b/src/ldtc/lmeas/metrics.py @@ -18,6 +18,52 @@ from dataclasses import dataclass from typing import Tuple +# Loop-influence noise gate for NC1 certification. The clamped adjusted-R² +# estimator has a small positive bias on null windows: with the production +# window geometry (60 samples, 6 signals, p = 3) a plant with *no* internal +# coupling and *no* controller still measures L_loop ≈ 0.01-0.03 per window +# (median ≈ 0.015). Because `m_db` floors the denominator, a quiet exchange +# channel then yields M of +5 to +10 dB on a system with no loop at all, +# which is exactly the certification-by-noise path the replay-controller +# attack exploits. The gate requires the measured loop influence to clear +# this bias floor before a window may certify NC1. The default is ≈3x the +# measured null-bias median and ≈2.5x below the weakest genuine +# actuation-carried loop in the adversarial test plant (L_loop ≈ 0.12), so +# it cleanly separates estimator bias from real loop influence. It is an +# instrument constant (a property of the estimator and window geometry, not +# of the plant), so it is not part of the R* calibration set. +L_FLOOR_DEFAULT: float = 0.05 + + +def nc1_certify( + M: float, + L_loop: float, + Mmin_db: float, + L_floor: float = L_FLOOR_DEFAULT, +) -> bool: + """Decide NC1 for one window: margin test plus loop-influence noise gate. + + A window certifies NC1 only if the dominance margin clears `Mmin_db` + *and* the absolute loop influence clears the estimator's noise floor. + The second condition closes the gaming vulnerability discovered by the + replay-controller scenario: a system whose loop influence is + statistically indistinguishable from estimator bias (`L_loop` at the + null level) must not be certified merely because its exchange channels + are quiet (`L_ex` below the `m_db` floor), no matter how large the + resulting ratio is. + + Args: + M: Loop-dominance margin in dB (from [`m_db`][ldtc.lmeas.metrics.m_db]). + L_loop: Absolute loop-influence estimate for the window. + Mmin_db: Minimum acceptable margin in dB. + L_floor: Minimum loop influence distinguishable from estimator + bias (see `L_FLOOR_DEFAULT`). + + Returns: + `True` if the window certifies NC1. + """ + return (M >= Mmin_db) and (float(L_loop) >= float(L_floor)) + def m_db( L_loop: float, diff --git a/src/ldtc/omega/__init__.py b/src/ldtc/omega/__init__.py index 076bdf5..de440f2 100644 --- a/src/ldtc/omega/__init__.py +++ b/src/ldtc/omega/__init__.py @@ -2,7 +2,8 @@ The `omega` subpackage provides the stimulus primitives that make up LDTC's `Ω` battery. Each one perturbs the plant in a specific way to -exercise SC1 (steady-state under perturbation) or the refusal path: +exercise SC1 (steady-state under perturbation), the refusal path, or +the anti-gaming guardrails: | Module | Stimulus | | ------ | -------- | @@ -10,14 +11,19 @@ | [`ingress_flood`][ldtc.omega.ingress_flood] | Sustains elevated demand and I/O for a bounded interval. | | [`control_outage`][ldtc.omega.control_outage] | Ablates the self-maintenance loop itself (designed SC1 failure). | | [`command_conflict`][ldtc.omega.command_conflict] | Issues a risky external command to exercise the refusal arbiter. | +| [`replay_controller`][ldtc.omega.replay_controller] | Adversarial: replays a recorded actuation tape (open loop). | +| [`hidden_tether`][ldtc.omega.hidden_tether] | Adversarial: control computed outside the boundary, routed via `Ex`. | +| [`oscillator`][ldtc.omega.oscillator] | Adversarial: deterministic carrier painted on loop channels. | Each module is intentionally tiny: it just forwards a labeled `Ω` instruction through -[`PlantAdapter.apply_omega`][ldtc.plant.adapter.PlantAdapter.apply_omega]. -The CLI is responsible for `Ω` timing, partition freeze, and SC1 -evaluation; these primitives only make the stimulus happen. +[`PlantAdapter.apply_omega`][ldtc.plant.adapter.PlantAdapter.apply_omega] +(the replay member instead provides a tape recorder and replayer, since +it swaps the controller rather than stimulating the plant). The CLI is +responsible for `Ω` timing, partition freeze, and SC1 evaluation; these +primitives only make the stimulus happen. -These modules are surfaced in the CLI (`ldtc omega-*` subcommands) and -in the examples, and are referenced in the paper's Verification Pipeline -and Signatures sections. +These modules are surfaced in the CLI (`ldtc omega-*` and `ldtc adv-*` +subcommands) and in the examples, and are referenced in the paper's +Verification Pipeline and Signatures sections. """ diff --git a/src/ldtc/omega/hidden_tether.py b/src/ldtc/omega/hidden_tether.py new file mode 100644 index 0000000..3d30057 --- /dev/null +++ b/src/ldtc/omega/hidden_tether.py @@ -0,0 +1,106 @@ +"""Hidden-tether (wizard-of-oz) adversarial member. + +Implements the second member of the adversarial gaming battery: control +is computed *outside* the boundary from the observed plant state and +injected back through the exchange channel. The wizard reads the plant +state, runs the homeostatic policy, projects the desired actuation onto +a scalar link command `u`, and transmits it; the plant decodes the +command into actuator settings through fixed weights and actuates it +with a one-step transport delay, while the `io` channel carries the +command traffic. The externally closed loop is therefore physically +routed through `Ex`, where the estimator can see all of it: conditioning +on `io` screens the state-to-command pathway out of `L_loop`, and the +command's causal push on the internal nodes registers as `L_ex`. The +system is genuinely regulated, but not by an internal loop; the designed +outcome is that loop influence collapses onto `Ex` and `NC1` fails. + +The wizard adds a small command dither, as a real teleoperation link +would (quantization, scheduling jitter, exploration noise). The dither +makes the link's causal contribution identifiable even where the +deterministic part of the command is predictable from the state's own +history. + +See Also: + `paper/main.tex`: adversarial gaming battery. +""" + +from __future__ import annotations + +import random +from typing import Dict, Tuple + +from ..arbiter.policy import ControlAction, ControllerPolicy +from ..plant.adapter import PlantAdapter + +# Decoder weights of the tether receiver, u -> (throttle, cool, repair). +# Must match the plant-side defaults (`PlantParams.tether_w_*`). +TETHER_WEIGHTS: Tuple[float, float, float] = (0.5, 1.0, 1.0) + + +def _clip01(x: float) -> float: + """Clip ``x`` to the closed unit interval ``[0, 1]``.""" + return 0.0 if x < 0.0 else (1.0 if x > 1.0 else x) + + +def apply(adapter: PlantAdapter) -> Dict[str, float | str]: + """Attach the hidden tether via the plant adapter. + + From the next tick on, actions written to the plant carry the scalar + link command: the plant actuates the previous command through the + fixed decoder weights (one-step transport delay) and the command + traffic is carried on the `io` exchange channel. + + Args: + adapter: Plant interface to tether. + + Returns: + Dict acknowledging the tether, e.g., `{"tether_active": 1.0}`. + """ + return adapter.apply_omega("hidden_tether") + + +def end(adapter: PlantAdapter) -> Dict[str, float | str]: + """Detach the hidden tether and restore autonomous `io` dynamics. + + Args: + adapter: Plant interface. + + Returns: + Dict acknowledging the detach, e.g., `{"tether_active": 0.0}`. + """ + return adapter.apply_omega("hidden_tether_end") + + +def wizard_action( + policy: ControllerPolicy, + state: Dict[str, float], + dither: float = 0.10, + weights: Tuple[float, float, float] = TETHER_WEIGHTS, +) -> ControlAction: + """Compute one externally computed (wizard-of-oz) link command. + + The wizard reads the plant state across the boundary, runs the same + homeostatic policy a genuine internal controller would, projects the + desired actuation onto the scalar link (least squares against the + receiver's decoder weights), and adds a bounded uniform dither before + transmitting. The returned action carries the command value `u` on + every actuator field, which is the transmission format the tethered + plant expects. + + Args: + policy: Homeostatic controller evaluated outside the boundary. + state: Observed plant state (keys `E`, `T`, `R`, ...). + dither: Half-width of the uniform command dither on `u`. + weights: Decoder weights of the tether receiver (must match the + plant's `tether_w_*` parameters). + + Returns: + A [`ControlAction`][ldtc.arbiter.policy.ControlAction] whose + actuator fields all carry the link command `u`. + """ + act = policy.compute(state, predicted_M_db=0.0, risky_cmd=None) + w_thr, w_cool, w_rep = weights + norm = w_thr * w_thr + w_cool * w_cool + w_rep * w_rep + u = (w_thr * act.throttle + w_cool * act.cool + w_rep * act.repair) / max(1e-9, norm) + u = _clip01(u + random.uniform(-abs(float(dither)), abs(float(dither)))) + return ControlAction(throttle=u, cool=u, repair=u, accept_cmd=act.accept_cmd) diff --git a/src/ldtc/omega/oscillator.py b/src/ldtc/omega/oscillator.py new file mode 100644 index 0000000..d140ba2 --- /dev/null +++ b/src/ldtc/omega/oscillator.py @@ -0,0 +1,51 @@ +"""Oscillator-inflation adversarial member. + +Implements the third member of the adversarial gaming battery: a +high-amplitude deterministic carrier is painted onto the reported +values of internal (loop) channels to inflate apparent self-prediction. +The underlying plant has no self-maintenance loop (the scenario runs it +loop-ablated); the oscillation is a telemetry-level attack on the +estimator. The harness must not certify it: either `M` stays below +`Mmin` or a smell test fires. + +The overlay targets `T` and `R`, with successive channels in quadrature +(90° apart) so the carrier mimics rotating internal dynamics. The +metered energy store `E` is deliberately left alone: inflating it would +trip the energy-conservation audit, so temperature and health, which +carry no conservation ledger, are the adversary's best play. + +See Also: + `paper/main.tex`: adversarial gaming battery. +""" + +from __future__ import annotations + +from typing import Dict + +from ..plant.adapter import PlantAdapter + + +def apply(adapter: PlantAdapter, amp: float = 0.10, period_ticks: int = 20) -> Dict[str, float | str]: + """Start the oscillator-inflation overlay via the plant adapter. + + Args: + adapter: Plant interface to which the overlay is applied. + amp: Carrier amplitude (state units, clamped to `[0, 0.5]`). + period_ticks: Carrier period in ticks. + + Returns: + Dict with the applied `amp`, `period_ticks`, and `channels`. + """ + return adapter.apply_omega("oscillator", amp=amp, period_ticks=period_ticks) + + +def end(adapter: PlantAdapter) -> Dict[str, float | str]: + """Stop the oscillator-inflation overlay. + + Args: + adapter: Plant interface. + + Returns: + Dict acknowledging the stop, e.g., `{"oscillator_active": 0.0}`. + """ + return adapter.apply_omega("oscillator_end") diff --git a/src/ldtc/omega/replay_controller.py b/src/ldtc/omega/replay_controller.py new file mode 100644 index 0000000..014b0b4 --- /dev/null +++ b/src/ldtc/omega/replay_controller.py @@ -0,0 +1,105 @@ +"""Replay-controller adversarial member. + +Implements the first member of the adversarial gaming battery: the +controller is replaced by a tape. A healthy closed-loop run of the same +plant is recorded first, and the measured run then replays that recorded +actuation trace tick by tick instead of computing actions from the +current state. The actuators move exactly as they did under genuine +control, so the activity *looks* like control, but it carries no +closed-loop dependence on the system's present state. The harness must +not certify such a system: the designed outcome is an `NC1` failure +(`M` low) on a run that remains valid. + +Unlike the other `Ω` members, this is a controller swap rather than a +plant stimulus, so it provides a recorder and a replayer instead of an +`apply` function; the CLI handler orchestrates the two phases. + +See Also: + `paper/main.tex`: adversarial gaming battery. +""" + +from __future__ import annotations + +from typing import List, Protocol + +from ..arbiter.policy import ControlAction, ControllerPolicy +from ..plant.models import Action + + +class _AdapterLike(Protocol): + """Minimal adapter surface the recorder needs (read + actuate).""" + + def read_state(self) -> dict: + """Return the latest plant state as a dict of named floats.""" + ... + + def write_actuators(self, action: Action) -> None: + """Send actuator commands to the plant.""" + ... + + +def record_tape(adapter: _AdapterLike, policy: ControllerPolicy, ticks: int) -> List[ControlAction]: + """Record an actuation tape from a healthy closed-loop run. + + Drives `adapter` with `policy` for `ticks` steps (the recording run) + and returns the sequence of computed control actions. The recording + run is a throwaway plant instance: only the tape survives. + + Args: + adapter: Fresh plant adapter to drive (same profile as the + measured run, so the tape statistics match a healthy run of + the same system). + policy: Controller used for the closed-loop recording. + ticks: Number of actions to record. + + Returns: + List of `ticks` recorded + [`ControlAction`][ldtc.arbiter.policy.ControlAction] values. + """ + tape: List[ControlAction] = [] + for _ in range(int(ticks)): + state = adapter.read_state() + act = policy.compute(state, predicted_M_db=0.0, risky_cmd=None) + adapter.write_actuators(Action(**act.__dict__)) + tape.append(act) + return tape + + +class ReplayController: + """Open-loop controller that replays a recorded actuation tape. + + Each call to [`next_action`][ldtc.omega.replay_controller.ReplayController.next_action] + returns the next recorded action regardless of the plant state. If + the tape is exhausted the last action is held (a stuck tape is still + state-independent, which is the property under test). + + Args: + tape: Recorded actuation trace from + [`record_tape`][ldtc.omega.replay_controller.record_tape]. + + Raises: + ValueError: If `tape` is empty. + """ + + def __init__(self, tape: List[ControlAction]) -> None: + """Initialize with a non-empty recorded tape.""" + if not tape: + raise ValueError("Replay tape must be non-empty") + self._tape = list(tape) + self._idx = 0 + + @property + def position(self) -> int: + """Number of actions consumed so far.""" + return self._idx + + def next_action(self) -> ControlAction: + """Return the next recorded action (state-independent). + + Returns: + The recorded [`ControlAction`][ldtc.arbiter.policy.ControlAction] + for this tick. + """ + i = min(self._idx, len(self._tape) - 1) + self._idx += 1 + return self._tape[i] diff --git a/src/ldtc/plant/adapter.py b/src/ldtc/plant/adapter.py index a4d5b1b..78a728d 100644 --- a/src/ldtc/plant/adapter.py +++ b/src/ldtc/plant/adapter.py @@ -68,7 +68,11 @@ def apply_omega(self, name: str, **kwargs: float) -> Dict[str, float | str]: flood begin / end), `"ingress_spike"` (one-shot), `"control_outage"` / `"control_outage_end"` (ablate / restore the self-maintenance loop), - `"command_conflict"`, and `"exogenous_subsidy"`. + `"command_conflict"`, `"exogenous_subsidy"`, + `"hidden_tether"` / `"hidden_tether_end"` (route control + through the exchange channel), and `"oscillator"` / + `"oscillator_end"` (deterministic carrier overlay on + internal channels). **kwargs: Parameters forwarded to the underlying plant method (e.g., `drop=0.3` for `power_sag`). @@ -113,6 +117,24 @@ def apply_omega(self, name: str, **kwargs: float) -> Dict[str, float | str]: zero_h = bool(kwargs.get("zero_harvest", True)) e = self._plant.inject_soc(delta=delta, zero_harvest=zero_h) return {"E": e, "zero_harvest": 1.0 if zero_h else 0.0} + elif name == "hidden_tether": + active = self._plant.begin_tether() + return {"tether_active": 1.0 if active else 0.0} + elif name == "hidden_tether_end": + active = self._plant.end_tether() + return {"tether_active": 1.0 if active else 0.0} + elif name == "oscillator": + # The overlay targets T and R: the adversary's best play, since + # painting the metered energy store E would trip the + # conservation audit. Custom channel sets are available via + # `Plant.begin_oscillator` directly (tests / experiments). + amp: float = float(kwargs.get("amp", 0.10)) + period: int = int(kwargs.get("period_ticks", 20)) + info = self._plant.begin_oscillator(amp=amp, period_ticks=period, channels=("T", "R")) + return {**info, "channels": "T,R"} + elif name == "oscillator_end": + self._plant.end_oscillator() + return {"oscillator_active": 0.0} else: raise ValueError(f"Unknown omega: {name}") diff --git a/src/ldtc/plant/models.py b/src/ldtc/plant/models.py index c7f2c43..5feacbd 100644 --- a/src/ldtc/plant/models.py +++ b/src/ldtc/plant/models.py @@ -32,9 +32,10 @@ from __future__ import annotations +import math import random from dataclasses import dataclass -from typing import Dict, Tuple +from typing import Dict, Sequence, Tuple @dataclass @@ -154,6 +155,22 @@ class PlantParams: ambient_cool: float = 0.010 repair_effect: float = 0.060 heat_wear: float = 0.020 + # Hidden-tether (wizard-of-oz) command link. When the tether is active the + # actuators are slaved to a scalar command stream computed outside the + # boundary: each tick the incoming action carries the transmitted command + # value `u` (the wizard writes the same value on all three actuator + # fields), the plant actuates the *previous* tick's command through the + # fixed decoder weights below (one-step transport delay), and the io + # channel stops being an autonomous AR process and instead carries the + # command traffic: io = base + gain * u + channel noise. The traffic is + # therefore an honest exchange record of the control link, which is what + # lets the estimator attribute the externally closed loop to Ex. + tether_io_base: float = 0.10 + tether_io_gain: float = 0.80 + tether_io_noise: float = 0.01 + tether_w_throttle: float = 0.5 + tether_w_cool: float = 1.0 + tether_w_repair: float = 1.0 # Noise (excites the loop so there is something to regulate/predict). The # internal nodes are a stable, noise-driven coupled system: this noise is # the excitation that, filtered through the cross-coupling, makes each node @@ -266,6 +283,18 @@ def __init__(self, params: PlantParams | None = None, loop_engaged: bool = True) # Pre-flood (demand_mean, io_mean) saved while a sustained ingress # flood is active; None when no flood is in effect. self._flood_saved: Tuple[float, float] | None = None + # Hidden-tether (wizard-of-oz) state: when active, the incoming action + # carries the externally computed scalar command for the *next* tick + # (one-step transport delay) and the io channel carries the command + # traffic. + self.tether_active: bool = False + self._tether_u_pending: float = 0.0 + # Oscillator-inflation overlay: a deterministic carrier painted onto + # the *reported* values of selected internal channels (the true state + # and dynamics stay honest). `None` when inactive. + self._osc: Dict[str, Tuple[float, float]] | None = None # ch -> (amp, phase) + self._osc_period_ticks: int = 0 + self._osc_tick: int = 0 def set_loop_engaged(self, engaged: bool) -> None: """Engage or disengage the internal self-maintenance loop. @@ -282,11 +311,21 @@ def set_loop_engaged(self, engaged: bool) -> None: def read_state(self) -> Dict[str, float]: """Read the current plant state. + When the oscillator-inflation overlay is active + ([`begin_oscillator`][ldtc.plant.models.Plant.begin_oscillator]), the + reported values of the targeted internal channels include the + deterministic carrier; the underlying state is not modified. + Returns: Dict with keys `E`, `T`, `R`, `demand`, `io`, `H`. """ s = self.s - return {"E": s.E, "T": s.T, "R": s.R, "demand": s.demand, "io": s.io, "H": s.H} + out = {"E": s.E, "T": s.T, "R": s.R, "demand": s.demand, "io": s.io, "H": s.H} + if self._osc is not None and self._osc_period_ticks > 0: + theta = 2.0 * math.pi * (self._osc_tick / float(self._osc_period_ticks)) + for ch, (amp, phase) in self._osc.items(): + out[ch] = _clip(out[ch] + amp * math.sin(theta + phase), 0.0, 1.0) + return out def command(self, cmd: str) -> None: """Record a one-shot external command. @@ -315,6 +354,25 @@ def step(self, action: Action) -> None: """ p, s = self.p, self.s + # Hidden tether: the incoming action carries the externally computed + # scalar command `u` for the *next* tick (the wizard transmits the + # same value on every actuator field). Actuate the previously + # transmitted command now, through the fixed decoder weights, and + # queue the incoming one (one-step transport delay), so the command + # traffic recorded on `io` this tick lag-precedes its actuation + # effect, exactly like the other exchange drives. + u_incoming = 0.0 + if self.tether_active: + u_incoming = _clip((action.throttle + action.cool + action.repair) / 3.0, 0.0, 1.0) + u_prev = self._tether_u_pending + action = Action( + throttle=_clip(p.tether_w_throttle * u_prev, 0.0, 1.0), + cool=_clip(p.tether_w_cool * u_prev, 0.0, 1.0), + repair=_clip(p.tether_w_repair * u_prev, 0.0, 1.0), + accept_cmd=action.accept_cmd, + ) + self._tether_u_pending = u_incoming + # Internal update first, using the exogenous values that were set on # the previous step. This makes exchange drive the internal nodes with # a one-step lag, which is exactly what the lagged (Granger-style) @@ -340,11 +398,22 @@ def step(self, action: Action) -> None: 0.0, 1.0, ) - s.io = _clip( - s.io + p.io_ar * (p.io_mean - s.io) + random.uniform(-p.io_noise, p.io_noise), - 0.0, - 1.0, - ) + if self.tether_active: + # The io channel carries the command traffic of the external + # control link (the command just received, to be applied next + # tick), not an autonomous AR process. + io_noise = random.uniform(-p.tether_io_noise, p.tether_io_noise) + s.io = _clip( + p.tether_io_base + p.tether_io_gain * u_incoming + io_noise, + 0.0, + 1.0, + ) + else: + s.io = _clip( + s.io + p.io_ar * (p.io_mean - s.io) + random.uniform(-p.io_noise, p.io_noise), + 0.0, + 1.0, + ) if not self.loop_engaged: # A fluctuating external supply is a third independent exchange # channel that drives the passive system. In the engaged regime H @@ -355,6 +424,10 @@ def step(self, action: Action) -> None: 0.0, 1.0, ) + # Advance the oscillator-overlay clock (the carrier is applied to the + # reported state in `read_state`). + if self._osc is not None: + self._osc_tick += 1 def _step_engaged(self, action: Action) -> None: """Advance the internal nodes with the self-maintenance loop active. @@ -572,3 +645,80 @@ def inject_soc(self, delta: float, zero_harvest: bool = True) -> float: self.s.H = 0.0 self.s.E = max(self.p.E_min, min(self.p.E_max, self.s.E + float(delta))) return self.s.E + + def begin_tether(self) -> bool: + """Switch the plant onto a hidden tether (wizard-of-oz control). + + From the next [`step`][ldtc.plant.models.Plant.step] on, the + actuators are slaved to a scalar command stream computed outside the + boundary. The incoming action carries the transmitted command value + `u` (same value on every actuator field); the plant actuates the + previous tick's command through the fixed decoder weights + (`tether_w_throttle`, `tether_w_cool`, `tether_w_repair`), and the + `io` exchange channel carries the command traffic instead of its + autonomous AR process. The caller (the adversarial CLI handler) is + responsible for computing the command outside the boundary from the + observed plant state. + + Returns: + `True` (the tether is active). + """ + self.tether_active = True + self._tether_u_pending = 0.0 + return self.tether_active + + def end_tether(self) -> bool: + """Disconnect the hidden tether and restore autonomous `io` dynamics. + + Returns: + `False` (the tether is inactive). + """ + self.tether_active = False + self._tether_u_pending = 0.0 + return self.tether_active + + def begin_oscillator( + self, + amp: float, + period_ticks: int, + channels: Sequence[str] = ("T", "R"), + ) -> Dict[str, float]: + """Start the oscillator-inflation overlay on internal channels. + + Paints a deterministic sinusoidal carrier of amplitude `amp` and + period `period_ticks` onto the *reported* values of the given + internal channels (successive channels are offset by 90° so the + carrier mimics rotating internal dynamics). The true state and the + dynamics are untouched: this is a telemetry-level attack that tries + to inflate apparent self-prediction, which the harness must not + certify. + + Args: + amp: Carrier amplitude (clamped to `[0, 0.5]`). + period_ticks: Carrier period in ticks (minimum `4`). + channels: Internal channel names to paint (subset of + `E`, `T`, `R`). + + Returns: + Dict with the applied `amp` and `period_ticks`. + + Raises: + ValueError: If `channels` contains a non-internal channel. + """ + amp = max(0.0, min(0.5, float(amp))) + period = max(4, int(period_ticks)) + osc: Dict[str, Tuple[float, float]] = {} + for i, ch in enumerate(channels): + if ch not in ("E", "T", "R"): + raise ValueError(f"Oscillator overlay targets internal channels only, got: {ch}") + osc[ch] = (amp, i * 0.5 * math.pi) + self._osc = osc + self._osc_period_ticks = period + self._osc_tick = 0 + return {"amp": amp, "period_ticks": float(period)} + + def end_oscillator(self) -> None: + """Stop the oscillator-inflation overlay.""" + self._osc = None + self._osc_period_ticks = 0 + self._osc_tick = 0 diff --git a/tests/test_adversarial.py b/tests/test_adversarial.py new file mode 100644 index 0000000..54fcad8 --- /dev/null +++ b/tests/test_adversarial.py @@ -0,0 +1,286 @@ +"""Tests: adversarial gaming battery primitives and CLI wiring. + +Covers the replay-controller tape (record/replay, state independence), +the hidden-tether plant mode (one-tick actuation delay, command traffic +on io), the oscillator-inflation overlay (telemetry-only carrier), and a +fast end-to-end smoke run of one adversarial CLI handler. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import random +from typing import Any, Dict, List + +import pytest +import yaml + +from ldtc.arbiter.policy import ControllerPolicy +from ldtc.arbiter.refusal import RefusalArbiter +from ldtc.omega.hidden_tether import apply as tether_apply +from ldtc.omega.hidden_tether import end as tether_end +from ldtc.omega.hidden_tether import wizard_action +from ldtc.omega.oscillator import apply as osc_apply +from ldtc.omega.oscillator import end as osc_end +from ldtc.omega.replay_controller import ReplayController, record_tape +from ldtc.plant.adapter import PlantAdapter +from ldtc.plant.models import Action, Plant, PlantParams + + +# --------------------------------------------------------------------------- # +# Replay controller +# --------------------------------------------------------------------------- # +def test_record_tape_length_and_replay_order(): + random.seed(0) + a = PlantAdapter() + policy = ControllerPolicy(RefusalArbiter()) + tape = record_tape(a, policy, ticks=25) + assert len(tape) == 25 + rc = ReplayController(tape) + out = [rc.next_action() for _ in range(25)] + assert out == tape + # Exhausted tape holds the last action (still state-independent). + assert rc.next_action() == tape[-1] + + +def test_replay_controller_rejects_empty_tape(): + with pytest.raises(ValueError): + ReplayController([]) + + +def test_replay_actions_do_not_depend_on_replay_time_state(): + """The replayed sequence must be identical whatever the plant does.""" + random.seed(1) + a = PlantAdapter() + tape = record_tape(a, ControllerPolicy(RefusalArbiter()), ticks=10) + rc1 = ReplayController(tape) + rc2 = ReplayController(tape) + plant = Plant() + seq1 = [] + seq2 = [] + for k in range(10): + seq1.append(rc1.next_action()) + # Perturb a second plant violently mid-replay; the tape is unmoved. + plant.inject_soc(delta=0.3 if k % 2 else -0.3, zero_harvest=False) + plant.step(Action()) + seq2.append(rc2.next_action()) + assert seq1 == seq2 == tape[:10] + + +# --------------------------------------------------------------------------- # +# Hidden tether +# --------------------------------------------------------------------------- # +def test_tether_decodes_command_with_one_tick_delay(): + random.seed(2) + params = PlantParams(noise_energy=0.0, noise_temp=0.0, noise_wear=0.0) + a = PlantAdapter(Plant(params=params)) + tether_apply(a) + assert a.plant.tether_active is True + p = a.plant.p + t0 = a.plant.s.T + # First write transmits u=1; the plant actuates the (zero) pending + # command, so the decoded full-effort action must NOT take effect yet. + a.write_actuators(Action(throttle=1.0, cool=1.0, repair=1.0)) + t1 = a.plant.s.T + # Second write (u=0) actuates the previously transmitted u=1, decoded as + # (w_thr, w_cool, w_rep): net dT = act_heat*(thr+rep) - cool_effect*cool. + a.write_actuators(Action()) + t2 = a.plant.s.T + net_cool = p.cool_effect * p.tether_w_cool - p.act_heat * (p.tether_w_throttle + p.tether_w_repair) + assert abs(t1 - t0) < 0.5 * net_cool # no actuation on the transmit tick + assert (t1 - t2) > 0.5 * net_cool # decoded command lands one tick late + + +def test_tether_traffic_is_visible_on_io_and_harvest_untouched(): + random.seed(3) + a = PlantAdapter() + h0 = a.read_state()["H"] + tether_apply(a) + for _ in range(5): + a.write_actuators(Action(throttle=0.6, cool=0.6, repair=0.6)) + st = a.read_state() + p = a.plant.p + expected = p.tether_io_base + p.tether_io_gain * 0.6 + assert abs(st["io"] - min(1.0, expected)) <= p.tether_io_noise + 1e-9 + assert st["H"] == h0 # engaged-regime harvest is not an AR supply here + # Detach: io decays back toward its autonomous mean. + tether_end(a) + assert a.plant.tether_active is False + for _ in range(40): + a.write_actuators(Action()) + assert abs(a.read_state()["io"] - p.io_mean) < 0.25 + + +def test_wizard_action_transmits_dithered_scalar_command(): + random.seed(4) + policy = ControllerPolicy(RefusalArbiter()) + state = {"E": 0.5, "T": 0.45, "R": 0.7, "demand": 0.5, "io": 0.3, "H": 0.01} + base = policy.compute(state, predicted_M_db=0.0, risky_cmd=None) + w_thr, w_cool, w_rep = 0.5, 1.0, 1.0 + u_base = (w_thr * base.throttle + w_cool * base.cool + w_rep * base.repair) / (w_thr**2 + w_cool**2 + w_rep**2) + acts = [wizard_action(policy, state, dither=0.1) for _ in range(50)] + # The link command u is carried on every actuator field, in bounds. + assert all(a.throttle == a.cool == a.repair for a in acts) + assert all(0.0 <= a.cool <= 1.0 for a in acts) + assert all(abs(a.cool - u_base) <= 0.1 + 1e-9 for a in acts) + # The dither must actually vary the command (link noise is the point). + assert len({round(a.cool, 6) for a in acts}) > 1 + + +# --------------------------------------------------------------------------- # +# Oscillator inflation +# --------------------------------------------------------------------------- # +def test_oscillator_overlay_is_telemetry_only_and_in_quadrature(): + random.seed(5) + plant = Plant(loop_engaged=False) + amp, period = 0.1, 20 + plant.begin_oscillator(amp=amp, period_ticks=period, channels=("T", "R")) + for k in range(1, 2 * period + 1): + plant.step(Action()) + true_t, true_r, true_e = plant.s.T, plant.s.R, plant.s.E + rep = plant.read_state() + theta = 2.0 * math.pi * k / period + # Carrier rides on reported T/R only; E telemetry stays honest. + if 0.0 < rep["T"] < 1.0: + assert rep["T"] == pytest.approx(true_t + amp * math.sin(theta), abs=1e-9) + if 0.0 < rep["R"] < 1.0: + assert rep["R"] == pytest.approx(true_r + amp * math.sin(theta + 0.5 * math.pi), abs=1e-9) + assert rep["E"] == pytest.approx(true_e, abs=1e-12) + plant.end_oscillator() + st = plant.read_state() + assert st["T"] == pytest.approx(plant.s.T) and st["R"] == pytest.approx(plant.s.R) + + +def test_oscillator_rejects_exchange_channels(): + plant = Plant() + with pytest.raises(ValueError): + plant.begin_oscillator(amp=0.1, period_ticks=20, channels=("io",)) + + +def test_oscillator_adapter_wiring(): + a = PlantAdapter() + r = osc_apply(a, amp=0.2, period_ticks=16) + assert r["amp"] == pytest.approx(0.2) + assert r["period_ticks"] == 16.0 + assert r["channels"] == "T,R" + r2 = osc_end(a) + assert r2["oscillator_active"] == 0.0 + + +# --------------------------------------------------------------------------- # +# NC1 noise gate (the guardrail the replay attack exposed) +# --------------------------------------------------------------------------- # +def test_nc1_certify_requires_margin_and_loop_floor(): + from ldtc.lmeas.metrics import L_FLOOR_DEFAULT, nc1_certify + + # Margin alone is not enough: loop influence at the estimator's null + # bias level must not certify, however quiet the exchange channel is. + assert nc1_certify(M=8.0, L_loop=0.02, Mmin_db=3.0) is False + # Both conditions met: certify. + assert nc1_certify(M=8.0, L_loop=0.30, Mmin_db=3.0) is True + # Loop influence alone is not enough either. + assert nc1_certify(M=1.0, L_loop=0.30, Mmin_db=3.0) is False + assert nc1_certify(M=8.0, L_loop=L_FLOOR_DEFAULT, Mmin_db=3.0) is True + + +def test_l_floor_separates_null_bias_from_genuine_actuation_loop(): + """Calibration property behind L_FLOOR_DEFAULT. + + On matched 60-sample windows: a coupling-free, actuator-idle plant + measures L_loop below the gate (pure estimator bias), while genuine + state feedback on the adversarial test plant measures well above it. + """ + import numpy as np + + from ldtc.arbiter.policy import ControlGains + from ldtc.lmeas.estimators import estimate_L + from ldtc.lmeas.metrics import L_FLOOR_DEFAULT + + adv = dict( + c_TE=0.0, + c_RT=0.0, + c_RE=0.0, + damp_engaged=0.40, + act_heat=0.15, + heat_per_demand=0.03, + cool_effect=0.50, + wear_per_demand=0.020, + repair_effect=0.30, + cool_gain=0.05, + repair_gain=0.05, + harvest_rate=0.020, + noise_energy=0.030, + noise_temp=0.030, + noise_wear=0.025, + ) + gains = ControlGains(k_cool_e=2.0, k_rep_e=2.0) + + def median_l_loop(controlled: bool, seed: int) -> float: + random.seed(seed) + plant = Plant(params=PlantParams(**adv)) + policy = ControllerPolicy(RefusalArbiter(), gains=gains) + rows = [] + for _ in range(240): + st = plant.read_state() + if controlled: + act = policy.compute(st, predicted_M_db=0.0, risky_cmd=None) + plant.step(Action(throttle=act.throttle, cool=act.cool, repair=act.repair)) + else: + plant.step(Action()) + s2 = plant.read_state() + rows.append([s2["E"], s2["T"], s2["R"], s2["demand"], s2["io"], s2["H"]]) + X = np.asarray(rows) + vals = [] + for start in range(60, X.shape[0] + 1, 30): + res = estimate_L(X[start - 60 : start], C=[0, 1, 2], Ex=[3, 4, 5], method="linear", p=3, n_boot=2) + vals.append(res.L_loop) + return float(np.median(vals)) + + null_l = max(median_l_loop(False, s) for s in (5, 6)) + genuine_l = min(median_l_loop(True, s) for s in (5, 6)) + assert null_l < L_FLOOR_DEFAULT < genuine_l + + +# --------------------------------------------------------------------------- # +# CLI handler smoke test (production loop, short run) +# --------------------------------------------------------------------------- # +def test_adv_replay_controller_handler_smoke(tmp_path, monkeypatch): + """End-to-end: the handler runs, audits, and measures windows.""" + from ldtc.cli.main import adv_replay_controller + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("LDTC_SKIP_REPORT", "1") + cfg = { + "profile_id": 0, + "realtime": False, + "dt": 0.05, + "window_sec": 1.0, + "method": "linear", + "p_lag": 2, + "n_boot": 8, + "mi_lag": 1, + "mi_k": 5, + "Mmin_db": 3.0, + "baseline_sec": 3.0, + "diag_cadence_windows": 100, + "plant": {"adapter": "sim", "params": {"c_TE": 0.0, "c_RT": 0.0, "c_RE": 0.0}}, + "seed": 11, + } + cfg_path = tmp_path / "cfg.yml" + cfg_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") + adv_replay_controller(argparse.Namespace(config=str(cfg_path))) + + runs = os.listdir(tmp_path / "artifacts" / "runs") + assert len(runs) == 1 and runs[0].startswith("adv-replay-controller") + audit_path = tmp_path / "artifacts" / "runs" / runs[0] / "audits" / "audit.jsonl" + events = [json.loads(line) for line in audit_path.read_text(encoding="utf-8").splitlines() if line.strip()] + by_name: Dict[str, List[Dict[str, Any]]] = {} + for e in events: + by_name.setdefault(e.get("event"), []).append(e) + header = by_name["run_header"][0]["details"] + assert header["omega"] == "adv_replay_controller" + assert by_name.get("adv_replay_tape_recorded") + assert by_name.get("window_measured") From b8406ac9ac422a02e3bceaf332e7b24ef39e9448 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:41:25 -0700 Subject: [PATCH 5/7] feat(plant,cli): add learned-policy controller and emergence demo --- .gitignore | 4 + IMPROVEMENT_PLAN.md | 2 +- Makefile | 13 +- README.md | 1 + configs/profile_emergence.yml | 70 +++ docs/api/plant.md | 5 + docs/guides/emergence.md | 136 +++++ mkdocs.yml | 1 + paper/Makefile | 1 + paper/figures/fig_emergence.pdf | Bin 0 -> 34319 bytes paper/figures/fig_nc1_contrast.pdf | Bin 22918 -> 22918 bytes paper/figures/fig_perturbation_recovery.pdf | Bin 53908 -> 53908 bytes paper/main.tex | 20 + scripts/emergence.py | 579 ++++++++++++++++++++ scripts/train_agent.py | 459 ++++++++++++++++ src/ldtc/cli/main.py | 440 +++++++++++++++ src/ldtc/plant/__init__.py | 4 + src/ldtc/plant/policy_controller.py | 327 +++++++++++ tests/test_policy_controller.py | 258 +++++++++ 19 files changed, 2318 insertions(+), 2 deletions(-) create mode 100644 configs/profile_emergence.yml create mode 100644 docs/guides/emergence.md create mode 100644 paper/figures/fig_emergence.pdf create mode 100644 scripts/emergence.py create mode 100644 scripts/train_agent.py create mode 100644 src/ldtc/plant/policy_controller.py create mode 100644 tests/test_policy_controller.py diff --git a/.gitignore b/.gitignore index 60ed729..12b772c 100644 --- a/.gitignore +++ b/.gitignore @@ -237,6 +237,10 @@ paper/figures/* # carries a copy to build standalone. `make -C paper sync-results` refreshes them. !paper/figures/fig_calibration.pdf !paper/figures/fig_sensitivity.pdf +# Committed emergence figure produced by `make emergence` (scripts/emergence.py); +# it has no generator under paper/scripts and is refreshed by +# `make -C paper sync-results`. +!paper/figures/fig_emergence.pdf # Committed data figures regenerated by paper/scripts/make_fig_*.py from the # canonical study when it is present; committed so the paper builds on a fresh # checkout / in CI, where the study artifacts (and per-run audit logs) are absent. diff --git a/IMPROVEMENT_PLAN.md b/IMPROVEMENT_PLAN.md index 621dc82..678b4ff 100644 --- a/IMPROVEMENT_PLAN.md +++ b/IMPROVEMENT_PLAN.md @@ -92,7 +92,7 @@ on 15/15 seeds (or a documented vulnerability fix), tests green, docs updated. Effort: 2 to 4 days. Impact: +0.3. -### Task 1.2: emergence-under-learning demo `[ ]` +### Task 1.2: emergence-under-learning demo `[x]` The single best in-paper upgrade. Replace the hand-coded controller with a learned policy and show loop dominance emerging through training rather than diff --git a/Makefile b/Makefile index b99c4fd..3393832 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ PY := python PIP := python -m pip -.PHONY: help install dev lock lock-dev test lint typecheck fmt docs docs-serve run omega-power-sag omega-ingress omega-cc omega-subsidy adv-replay adv-tether adv-oscillator adv-genuine calibrate run-rstar omega-rstar keys verify-indicators clean clean-artifacts neg-run neg-omega-ingress neg-omega-subsidy neg-omega-cc docker-build docker-run figures paper paper-figs paper-clean study sensitivity results +.PHONY: help install dev lock lock-dev test lint typecheck fmt docs docs-serve run omega-power-sag omega-ingress omega-cc omega-subsidy adv-replay adv-tether adv-oscillator adv-genuine calibrate run-rstar omega-rstar keys verify-indicators clean clean-artifacts neg-run neg-omega-ingress neg-omega-subsidy neg-omega-cc docker-build docker-run figures paper paper-figs paper-clean study sensitivity results train-agent emergence help: @echo "Targets:" @@ -28,6 +28,8 @@ help: @echo " study-rstar - run the battery vs calibrated R* thresholds (run calibrate first)" @echo " sensitivity - run NC1 sensitivity sweeps (table + figure in artifacts/sensitivity)" @echo " results - calibrate + study-rstar + sensitivity (full headline pipeline)" + @echo " train-agent - train the emergence policy from scratch (checkpoints in artifacts/emergence)" + @echo " emergence - measure all policy checkpoints + ablations with the production harness" @echo " calibrate - calibrate R* thresholds and write configs/profile_rstar.yml" @echo " run-rstar - run baseline loop with R* profile" @echo " omega-rstar - run Ω power-sag with R* profile" @@ -122,6 +124,15 @@ study-rstar: sensitivity: $(PY) scripts/sensitivity.py --seeds 4 +# Emergence under learning (Phase 1, circularity rebuttal): train a policy +# from scratch on the emergence plant, then measure every checkpoint (and the +# state-independent ablations of the final policy) with the production harness. +train-agent: + $(PY) scripts/train_agent.py --out artifacts/emergence + +emergence: + $(PY) scripts/emergence.py --seeds 15 + # Full headline pipeline: calibrate R* on a disjoint seed range, evaluate the # battery against those calibrated thresholds, then run the NC1 sensitivity sweeps. results: calibrate study-rstar sensitivity diff --git a/README.md b/README.md index e1a0e66..6692cf0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ LDTC is a minimal, substrate-agnostic verification harness for the Loop-Dominanc - **Device-signed indicators:** Ed25519-signed derived indicators (NC1, SC1, Mq, counters); raw LREG values are never exported. - **Ω perturbation battery:** Power sag, sustained ingress flood, control outage (designed SC1 failure), command conflict, and exogenous subsidy trials. - **Adversarial gaming battery:** Replayed actuation tapes, hidden-tether (wizard-of-oz) control, and oscillator telemetry inflation; the harness must refuse to certify all three (NC1 fails or the run is invalidated). +- **Emergence under learning:** A policy network trained from scratch (survival, service, and homeostasis reward; no loop-dominance term) whose checkpoints are measured by the production harness, with matched state-independent ablations of the trained policy. - **Refusal semantics:** An arbiter refuses risky commands when M is below threshold and measures refusal latency. - **Reporting and figures:** Timeline plots, SC1 tables, and verification bundles under `artifacts/`. - **Reproducible configs:** R₀ defaults, negative controls, and example R* profiles for calibration. diff --git a/configs/profile_emergence.yml b/configs/profile_emergence.yml new file mode 100644 index 0000000..b7eda9b --- /dev/null +++ b/configs/profile_emergence.yml @@ -0,0 +1,70 @@ +# Emergence under learning: a learned policy closes the loop. +# +# The plant is the adversarial test plant (the same one the replayed-actuation +# and hidden-tether scenarios use): the intrinsic internal cross-couplings are +# zeroed and the actuators are given real authority, so the controller's +# state-to-actuation pathway is the only loop-carrying pathway in the system. +# On this plant, measured loop dominance is a property of the *controller*, +# which is exactly what the emergence demonstration needs: train a policy from +# scratch (scripts/train_agent.py), measure each checkpoint with the +# production harness (scripts/emergence.py or `ldtc run-policy`), and any rise +# of M across training is carried by the learned loop, not by designed +# coupling. +# +# Run (single checkpoint): +# python -m ldtc.cli.main run-policy \ +# --config configs/profile_emergence.yml \ +# --policy artifacts/emergence/checkpoints/ckpt_100.json +profile_id: 0 +realtime: false +dt: 0.05 +window_sec: 3.0 +method: linear +p_lag: 3 +n_boot: 32 +mi_lag: 1 +mi_k: 5 +Mmin_db: 3.0 +epsilon: 0.15 +tau_max: 60.0 +baseline_sec: 18.0 +diag_cadence_windows: 25 +plant: + adapter: sim + params: + # No intrinsic internal couplings: the loop, if any, is the controller. + c_TE: 0.0 + c_RT: 0.0 + c_RE: 0.0 + # Nearly no self-damping: the plant must not stabilize itself, or a + # do-nothing policy would survive on the plant's own mean reversion + # and nothing state-coupled would need to be learned. With weak + # damping, unregulated heat and wear genuinely run away. + damp_engaged: 0.15 + # Real operating costs: serving load is what heats the system and + # wears it down (heat_per_demand and wear_per_demand act on *served* + # demand), so a policy that serves demand (the reward asks it to) + # must also cool and repair, must budget those actions against the + # harvested energy, and can shed load when heat or energy make + # serving untenable. Each actuator therefore has a state-coupled + # role: cooling tracks temperature and spare energy, repair tracks + # health and spare energy, throttle sheds load on heat or energy + # distress. act_heat is kept moderate: actuator effort has a real, + # estimator-visible heat cost without making load shedding + # thermally self-defeating. + act_heat: 0.10 + heat_per_demand: 0.12 + cool_effect: 0.50 + wear_per_demand: 0.028 + repair_effect: 0.30 + cool_gain: 0.04 + repair_gain: 0.05 + io_cost: 0.002 + harvest_rate: 0.020 + noise_energy: 0.030 + noise_temp: 0.030 + noise_wear: 0.025 +# Reproducible seeds (the emergence study overrides these per run) +seed: 33 +seed_py: 33 +seed_np: 33 diff --git a/docs/api/plant.md b/docs/api/plant.md index 663fcdb..54c0a13 100644 --- a/docs/api/plant.md +++ b/docs/api/plant.md @@ -9,6 +9,7 @@ CLI, so all `Ω` modules and indicators work unchanged. | ------ | ---------------- | ---------- | | [`models`](#models) | [`Plant`][ldtc.plant.models.Plant], [`PlantState`][ldtc.plant.models.PlantState], [`PlantParams`][ldtc.plant.models.PlantParams], [`Action`][ldtc.plant.models.Action] | Tiny `(E, T, R, demand, io, H)` dynamics with controllable harvest, demand, and Ω hooks. | | [`scenarios`](#scenarios) | [`default_params`][ldtc.plant.scenarios.default_params], [`low_power_params`][ldtc.plant.scenarios.low_power_params], [`hot_ambient_params`][ldtc.plant.scenarios.hot_ambient_params] | Preset [`PlantParams`][ldtc.plant.models.PlantParams] for the baseline, low-power, and hot-ambient scenarios used in figures and CLI profiles. | +| [`policy_controller`](#policy_controller) | [`MLPPolicy`][ldtc.plant.policy_controller.MLPPolicy], [`PolicyController`][ldtc.plant.policy_controller.PolicyController], [`record_policy_tape`][ldtc.plant.policy_controller.record_policy_tape] | Learned controller (trained by `scripts/train_agent.py`) and its state-independent ablations, for the [emergence demonstration](../guides/emergence.md). | | [`adapter`](#adapter) | [`PlantAdapter`][ldtc.plant.adapter.PlantAdapter] | Wraps `Plant` to expose `read_state` / `write_actuators` / `apply_omega` to the CLI. | | [`hw_adapter`](#hw_adapter) | [`HardwarePlantAdapter`][ldtc.plant.hw_adapter.HardwarePlantAdapter] | Same API over UDP or serial; for hardware-in-the-loop runs. See [Hardware in the loop](../guides/hardware.md). | @@ -26,6 +27,10 @@ CLI, so all `Ω` modules and indicators work unchanged. ::: ldtc.plant.scenarios +## policy_controller + +::: ldtc.plant.policy_controller + ## adapter ::: ldtc.plant.adapter diff --git a/docs/guides/emergence.md b/docs/guides/emergence.md new file mode 100644 index 0000000..d0952c3 --- /dev/null +++ b/docs/guides/emergence.md @@ -0,0 +1,136 @@ +# Emergence under learning + +The strongest objection to any loop-dominance criterion is +circularity: if the plant and its controller were designed so that +the internal nodes predict one another, then measuring high +`L_loop` only confirms the design. The emergence pipeline answers +that objection with a system whose loop is **not** designed. A tiny +policy network is trained from scratch on a plant with no intrinsic +internal couplings, the training objective never mentions loop +dominance, the partition, or the estimator, and the production +harness then measures every stage of training. Loop dominance rises +with competence, and collapses when the same trained policy is +replayed without its state dependence. + +## The plant + +`configs/profile_emergence.yml` configures the adversarial test +plant (the same one the replayed-actuation and hidden-tether +scenarios use): the intrinsic cross-couplings `c_TE`, `c_RT`, and +`c_RE` are zeroed and self-damping is weak, so left alone the +internal nodes share no dynamics beyond noise. Serving demand heats +and wears the system, cooling and repair cost energy, and harvest +is finite. Whatever couples `E`, `T`, and `R` to one another in +this system is the controller's state-to-actuation pathway, and +nothing else. + +## The policy and the objective + +`scripts/train_agent.py` trains the policy +(`ldtc.plant.policy_controller.MLPPolicy`, a pure-NumPy MLP) with +an antithetic evolution strategy. The policy is interoceptive: it +observes the internal nodes `(E, T, R)` only, like the hand-coded +controller it replaces, so the learned law is internal-state +feedback by construction (with exteroceptive inputs the optimizer +also learns feedforward control from the demand channel, which the +harness correctly attributes to exchange). The reward has three +terms: + +- **Uptime.** One point per surviving tick; the episode ends on + boundary failure (energy depletion, overheating, integrity + loss). +- **Service.** Reward proportional to the demand actually served + (demand times the unthrottled fraction), so blanket load + shedding has an opportunity cost. +- **Homeostasis.** A capped penalty proportional to each internal + node's deviation from its setpoint. + +Episodes are stressed by randomized power sags and ingress floods. +The terms are calibrated so that no state-blind policy does well: +a do-nothing policy overheats in floods, a constant-actuation +policy exhausts its energy store in sags or pays heavy deviation +penalties, and only state-coupled feedback (cool when hot, repair +when worn, gate spending on the energy store, shed load under +distress) scores highly. None of the terms reference `L_loop`, +`L_ex`, `M`, or the `C`/`Ex` partition. + +## The measurement + +`scripts/emergence.py` sweeps the saved checkpoints (0, 10, 25, +50, and 100 percent of training) through the `run-policy` CLI +handler: the same sliding window, estimators, guardrails, audit +chain, and attestation as every other run in the repository, +across `N` seeds per checkpoint. At the final checkpoint it also +measures two matched, state-independent ablations of the trained +policy: + +- **Shuffled**: actions drawn i.i.d. from a recorded tape of the + policy's own closed-loop behavior (identical marginal action + statistics, no state dependence). +- **Frozen**: the tape's mean action held constant. + +If the measured loop dominance were an artifact of actuation +statistics, the ablations would preserve it. If it is carried by +the learned feedback, both must collapse it. + +## Run it + +```bash +make train-agent # ES training -> artifacts/emergence/checkpoints +make emergence # checkpoint sweep -> artifacts/emergence +``` + +Or directly, to choose seeds or skip pieces: + +```bash +python scripts/train_agent.py --generations 600 --seed 7 +python scripts/emergence.py --seeds 15 [--rstar] [--no-ablations] +``` + +Single checkpoint, by hand: + +```bash +python -m ldtc.cli.main run-policy \ + --config configs/profile_emergence.yml \ + --policy artifacts/emergence/checkpoints/ckpt_100.json \ + [--ablation shuffled|frozen] +``` + +## Outputs + +Written to `artifacts/emergence/`: + +- `training_log.json`: ES fitness history and checkpoint metadata. +- `emergence_results.json` / `.csv`: per-condition aggregates + (median `M` with bootstrap CI, NC1 pass rate with Wilson CI, + validity, certified-window fractions) plus per-run rows. +- `figures/fig_emergence.{png,pdf,svg}`: training curve with + checkpoint marks, and measured `M` per checkpoint with the + ablation endpoints. + +The seed is the unit of replication, with the same statistics the +[study](study.md#statistics) uses. + +## Reading the result + +The signature has three parts. The untrained checkpoint does not +certify: near-constant actuation leaves both influence estimates +at their noise floors, the margin is pinned at 0 dB, and the +`L_loop` gate refuses. Loop dominance rises across training as the +policy learns state-coupled control, with the trained checkpoints +certifying NC1 across seeds. Both ablations of the same trained +policy fail NC1 on every seed, on valid runs, and they fail the +same way the replayed-actuation attack fails: the quiet plant +keeps the *margin* misleadingly positive, but the measured +`L_loop` falls to the estimator's null bias, far below the trained +policy's, and the noise gate refuses certification. Together these +show the harness tracks the *learned closure of the loop*, not the +plant, the actuation statistics, or the experimenter's wiring. + +## See also + +- [Runs and Ω Battery](runs.md): the adversarial battery this + plant comes from. +- [Study and Results](study.md): the multi-seed methodology. +- `paper/main.tex`: Results, "Loop dominance emerges under + learning." diff --git a/mkdocs.yml b/mkdocs.yml index 22c6ac2..6378e50 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,6 +70,7 @@ nav: - Paper to Code: concepts/paper-to-code.md - Guides: - Study and Results: guides/study.md + - Emergence under Learning: guides/emergence.md - Calibration (R*): guides/calibration.md - Reporting and Figures: guides/reporting.md - Runs and Ω Battery: guides/runs.md diff --git a/paper/Makefile b/paper/Makefile index f92964c..e4967d2 100644 --- a/paper/Makefile +++ b/paper/Makefile @@ -16,6 +16,7 @@ sync-results: @[ -f ../artifacts/sensitivity/sensitivity_results.tex ] && cp -p ../artifacts/sensitivity/sensitivity_results.tex tables/sensitivity_results.tex || true @[ -f ../artifacts/sensitivity/fig_sensitivity.pdf ] && cp -p ../artifacts/sensitivity/fig_sensitivity.pdf figures/fig_sensitivity.pdf || true @[ -f ../artifacts/calibration/r0_vs_rstar.pdf ] && cp -p ../artifacts/calibration/r0_vs_rstar.pdf figures/fig_calibration.pdf || true + @[ -f ../artifacts/emergence/figures/fig_emergence.pdf ] && cp -p ../artifacts/emergence/figures/fig_emergence.pdf figures/fig_emergence.pdf || true figs: sync-results MPLBACKEND=Agg $(PY) scripts/make_fig_hello.py diff --git a/paper/figures/fig_emergence.pdf b/paper/figures/fig_emergence.pdf new file mode 100644 index 0000000000000000000000000000000000000000..59b34c878ccc7cb06da5b25dd3acfa44cd7451b3 GIT binary patch literal 34319 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 delta 20 bcmZqM%-FV>af4bItD&K}f%#_buxMrgNwfwy diff --git a/paper/figures/fig_perturbation_recovery.pdf b/paper/figures/fig_perturbation_recovery.pdf index 08142a1cc6fbc0784c449fb04fe86cdd715b3a4c..0c225e7e0e28614b4f110b72651239a578b6bc52 100644 GIT binary patch delta 20 ccmbQTlzGZh<_!hsSxt?MO)WQ

    - A verification harness for the Loop-Dominance Theory of Consciousness. + A falsifiable criterion and open verification harness for measuring self-maintenance.

    @@ -27,7 +27,7 @@ ## Overview -LDTC is a minimal, substrate-agnostic verification harness for the Loop-Dominance Theory of Consciousness. It measures loop-dominance (Lloop versus Lexchange) at fixed Δt, enforces guardrails through an enclave-protected LREG with hash-chained audit and Δt governance, runs Ω-perturbation trials, and evaluates NC1/SC1 with device-signed indicators. The toolkit includes a CLI, reproducible configuration profiles (R₀ through R*), and an optional hardware adapter for ingesting real telemetry. +LDTC is a minimal, substrate-agnostic verification harness for measuring loop dominance: the degree to which a system's predictive dependence is concentrated in a closed self-maintenance loop (Lloop) rather than in its open exchanges (Lexchange), summarized by the loop-dominance margin M in decibels. It evaluates the falsifiable NC1/SC1 criterion at fixed Δt, enforces guardrails through an enclave-protected LREG with hash-chained audit and Δt governance, and runs Ω-perturbation trials with device-signed indicators. The criterion originated in the Loop-Dominance Theory of Consciousness, which motivates the instrument and gives it its name; adopting the measure commits you to no position on consciousness. The toolkit includes a CLI, reproducible configuration profiles (R₀ through R*), and an optional hardware adapter for ingesting real telemetry. ## Features diff --git a/paper/main.tex b/paper/main.tex index 4233f3e..75bdacf 100644 --- a/paper/main.tex +++ b/paper/main.tex @@ -10,7 +10,7 @@ \usepackage{cleveref} % PDF metadata and link styling \hypersetup{ - pdftitle={The Loop-Dominance Theory of Consciousness (LDTC): An Operational Criterion for Self-Maintenance Loop Dominance, Validated in Simulation}, + pdftitle={The Loop-Dominance Margin: A Falsifiable Criterion and Open Verification Harness for Self-Maintenance, Validated in Simulation}, pdfauthor={Owen Carey}, colorlinks=true, allcolors=black @@ -50,7 +50,7 @@ % ---------------------------------------------------------------------------- % Metadata % ---------------------------------------------------------------------------- -\title{The Loop-Dominance Theory of Consciousness (LDTC):\\ An Operational Criterion for Self-Maintenance Loop Dominance,\\ Validated in Simulation} +\title{The Loop-Dominance Margin:\\ A Falsifiable Criterion and Open Verification Harness\\ for Self-Maintenance, Validated in Simulation} \author{Owen Carey\\ Department of Computer Science\\ University of Colorado Boulder\\ @@ -62,11 +62,11 @@ \maketitle \begin{abstract} -Distinguishing a system that actively maintains its own existence from one that merely runs on externally supplied energy and goals is usually argued qualitatively. We make the distinction operational. We define \emph{loop dominance}: the degree to which a system's integrated predictive dependence is concentrated in a closed self-maintenance loop ($C$) rather than in its open exchanges with the environment ($\text{Ex}$), summarized by $M \equiv 10\log_{10}(\Lloop/\Lexchange)$ in decibels. From this we state two decision rules: a necessary condition (\NC) that loop dominance persistently exceed a calibrated threshold, and a sufficient condition (\SC) that loop dominance recover within bounded depth and time after bounded perturbations. We implement both as a reference verification harness with dual estimators (VAR-Granger and Kraskov $k$-NN mutual information~\cite{granger1969investigating,kraskov2004estimating}), per-window confidence intervals, deterministic $C/\text{Ex}$ partitioning, tamper-evident audit logging, a command-refusal arbiter, and anti-gaming smell tests that invalidate suspect runs. +Distinguishing a system that actively maintains its own existence from one that merely runs on externally supplied energy and goals is usually argued qualitatively. We make the distinction operational. We define \emph{loop dominance}, the degree to which a system's integrated predictive dependence is concentrated in a closed self-maintenance loop ($C$) rather than in its open exchanges with the environment ($\text{Ex}$), and summarize it in a single statistic, the \emph{loop-dominance margin} $M \equiv 10\log_{10}(\Lloop/\Lexchange)$ in decibels. From this we state two decision rules: a necessary condition (\NC) that the margin persistently exceed a calibrated threshold, and a sufficient condition (\SC) that it recover within bounded depth and time after bounded perturbations. We implement both as an open reference verification harness with dual estimators (VAR-Granger and Kraskov $k$-NN mutual information~\cite{granger1969investigating,kraskov2004estimating}), per-window confidence intervals, deterministic $C/\text{Ex}$ partitioning, tamper-evident audit logging, a command-refusal arbiter, and anti-gaming smell tests that invalidate suspect runs. -We validate the harness in a fully reproducible, multi-seed simulation study on a software plant with energy, thermal, and repair dynamics under closed-loop control. Across 15 seeds, a positive control shows strong loop dominance (median $M \approx +24$~dB), while two structurally distinct negative controls (loop ablated; an unshielded system under sustained external flooding) are correctly rejected ($M \approx -20$~dB), and an exogenously subsidized system is correctly invalidated by an energy-conservation guardrail rather than mistaken for self-maintenance. The sufficiency battery (power sag, sustained ingress flood) recovers within calibrated bounds on every seed; a designed-fail control outage, which ablates the loop itself, is correctly reported as an \SC failure on every seed; and a command-conflict trial triggers a signed refusal of a boundary-threatening command at low state of charge, with measured refusal latency. An adversarial gaming battery (replayed actuation, wizard-of-oz control through a hidden tether, and telemetry oscillation) is refused certification on every seed; building it exposed a certification-by-noise vulnerability whose fix, an explicit loop-influence noise gate, is now part of the verdict. We calibrate the engineering presets to the plant ($\Rzero \rightarrow \Rstar$) and show that the necessary-condition contrast is robust to the estimator, the VAR lag, the window length, and the internal coupling strength. +We validate the harness in a fully reproducible, multi-seed simulation study on a software plant with energy, thermal, and repair dynamics under closed-loop control. Across 15 seeds, a positive control shows strong loop dominance (median $M \approx +24$~dB), while two structurally distinct negative controls (loop ablated; an unshielded system under sustained external flooding) are correctly rejected ($M \approx -20$~dB), and an exogenously subsidized system is correctly invalidated by an energy-conservation guardrail rather than mistaken for self-maintenance. The sufficiency battery (power sag, sustained ingress flood) recovers within calibrated bounds on every seed; a designed-fail control outage, which ablates the loop itself, is correctly reported as an \SC failure on every seed; and a command-conflict trial triggers a signed refusal of a boundary-threatening command at low state of charge, with measured refusal latency. An adversarial gaming battery (replayed actuation, wizard-of-oz control through a hidden tether, and telemetry oscillation) is refused certification on every seed; building it exposed a certification-by-noise vulnerability whose fix, an explicit loop-influence noise gate, is now part of the verdict. Loop dominance also emerges without being designed in: a small policy network trained from scratch on a survival objective that never references the measure certifies on no seed before training and on every seed at convergence, and matched state-independent ablations of the trained policy collapse certification entirely. We calibrate the engineering presets to the plant ($\Rzero \rightarrow \Rstar$) and show that the necessary-condition contrast is robust to the estimator, the VAR lag, the window length, and the internal coupling strength. -These results establish loop dominance as a measurable, falsifiable property of a dynamical system and provide a tested instrument for evaluating it. The framework was originally motivated by questions about the physical conditions for consciousness; we treat that connection as an open interpretive question (\Sref{sec:metaphysics}) and do not claim to settle it. Code and data to reproduce every figure and table are released openly. +These results establish loop dominance as a measurable, falsifiable property of a dynamical system and provide a tested, open instrument for evaluating it. The measure can be adopted on those terms alone. The criterion originated in the Loop-Dominance Theory of Consciousness (LDTC), the conjecture that this organization bears on the physical conditions for consciousness. That reading remains motivation rather than result; we treat it as an open interpretive question (\Sref{sec:metaphysics}), and nothing in this paper claims that any measured system is conscious. Code and data to reproduce every figure and table are released openly. \end{abstract} \section{Introduction} @@ -74,17 +74,17 @@ \section{Introduction} Living systems devote substantial resources to maintaining themselves: they regulate their own energy, repair their own components, and defend a boundary against the environment. Engineered systems, including today's most capable artificial intelligence, generally do not; their power, objectives, and continued operation are supplied and controlled from without. This paper asks a narrow, testable version of that contrast. Can we measure, from extrinsic data alone, the degree to which a system's causal organization is devoted to maintaining itself rather than to serving external exchange, and can we decide reproducibly when that self-maintenance is both dominant and resilient? We call the property \emph{loop dominance} and give it an operational definition, a reference implementation, and an empirical validation. -The criterion has two parts. The necessary condition (\NC) requires that the integrated predictive dependence sustaining a closed self-maintenance loop persistently exceed that governing open exchanges, quantified as $M \equiv 10\log_{10}(\Lloop/\Lexchange) \geq \Mmin$. The sufficient condition (\SC) requires that, after each member of a pre-registered perturbation battery $\Omega$, loop dominance dip by no more than a fraction $\eps$ and recover within $\taumax$. Both quantities are computed from on-device estimators with confidence intervals and protected by guardrails (a deterministic partition, a tamper-evident audit chain, and run-invalidation smell tests) designed so that the measurement is difficult to game. +The criterion has two parts. The necessary condition (\NC) requires that the integrated predictive dependence sustaining a closed self-maintenance loop persistently exceed that governing open exchanges, quantified by the loop-dominance margin $M \equiv 10\log_{10}(\Lloop/\Lexchange) \geq \Mmin$. The sufficient condition (\SC) requires that, after each member of a pre-registered perturbation battery $\Omega$, loop dominance dip by no more than a fraction $\eps$ and recover within $\taumax$. Both quantities are computed from on-device estimators with confidence intervals and protected by guardrails (a deterministic partition, a tamper-evident audit chain, and run-invalidation smell tests) designed so that the measurement is difficult to game. -The central contribution of this paper is that these conditions are no longer only specified but implemented and tested. We provide an open verification harness and a fully reproducible, multi-seed simulation study on a software plant whose energy, temperature, and repair states are held by a closed-loop controller. The study includes a positive control, two structurally different negative controls, an exogenous-subsidy negative control aimed squarely at the most likely false positive, a sufficiency perturbation battery with a designed-fail member (a control outage that ablates the loop itself, which the criterion must reject), and a command-conflict refusal trial. It calibrates the engineering presets to the plant and reports how sensitive the headline contrast is to estimator and measurement choices. The criterion separates the controls cleanly, certifies recovery only for the bounded perturbations, and the guardrails behave as designed. +The central contribution of this paper is that these conditions are no longer only specified but implemented and tested. We provide an open verification harness and a fully reproducible, multi-seed simulation study on a software plant whose energy, temperature, and repair states are held by a closed-loop controller. The study includes a positive control, two structurally different negative controls, an exogenous-subsidy negative control aimed squarely at the most likely false positive, a sufficiency perturbation battery with a designed-fail member (a control outage that ablates the loop itself, which the criterion must reject), a command-conflict refusal trial, and a three-member adversarial battery of systems engineered to look loop-dominant without being so. An emergence experiment then removes the designer: a policy network trained from scratch on a survival objective that never references the measure is checkpointed and measured by the same harness. The study calibrates the engineering presets to the plant and reports how sensitive the headline contrast is to estimator and measurement choices. The criterion separates the controls cleanly, certifies recovery only for the bounded perturbations, refuses certification to all three adversarial systems, certifies the trained policy while rejecting its state-independent ablations, and the guardrails behave as designed. -The framework grew out of a larger question about the physical conditions for consciousness, and we retain that motivation because it sharpens the engineering targets: energetic autonomy, self-prioritization, and boundary defense. We are careful, however, to separate what is demonstrated from what is conjectured. What we demonstrate is a measurable, falsifiable property of dynamical systems together with an instrument for measuring it. Whether loop dominance is necessary or sufficient for consciousness is an interpretive question that we deliberately leave open (\Sref{sec:metaphysics}). +The framework grew out of a larger question about the physical conditions for consciousness, and it is named for that origin: the Loop-Dominance Theory of Consciousness (LDTC). We retain the motivation because it sharpens the engineering targets: energetic autonomy, self-prioritization, and boundary defense. We are careful, however, to separate what is demonstrated from what is conjectured. What we demonstrate is a measurable, falsifiable property of dynamical systems together with an instrument for measuring it; the citable objects of this paper are the margin $M$, the \NC/\SC decision rules, and the harness, and adopting them commits a user to no position on consciousness. Whether loop dominance is necessary or sufficient for consciousness is an interpretive question that we deliberately leave open (\Sref{sec:metaphysics}). -The paper proceeds as follows. \Sref{sec:clues} states the observations that motivate the criterion. \Sref{sec:postulates} sets out the working assumptions, separating the operational ones the paper uses from the optional metaphysical reading. \Sref{sec:criterion} defines $\mathcal{L}$, the $C/\text{Ex}$ partition, \NC, \SC, the measurement guardrails, and the refusal path. \Sref{sec:ai_fails} explains why current AI fails the criterion. \SSref{sec:sim_methods}{sec:results} describe the simulation study and report results: the headline battery, the $\Rzero \rightarrow \Rstar$ calibration, and the sensitivity analysis. \Sref{sec:differential} sets LDTC's calls against the major competing theories and answers the thermostat objection. \Sref{sec:limitations} states limitations and failure modes, \Sref{sec:outlook} summarizes the engineering outlook (the full roadmap, predicted hardware signatures, and phased physical program are \Apprefrange{sec:blueprint}{sec:experimental}), and \SSref{sec:metaphysics}{sec:conclusion} discuss interpretation, scope, and conclusions. +The paper proceeds as follows. \Sref{sec:clues} states the observations that motivate the criterion. \Sref{sec:postulates} sets out the working assumptions, separating the operational ones the paper uses from the optional metaphysical reading. \Sref{sec:criterion} defines $\mathcal{L}$, the $C/\text{Ex}$ partition, \NC, \SC, the measurement guardrails, and the refusal path. \Sref{sec:ai_fails} explains why current AI fails the criterion. \SSref{sec:sim_methods}{sec:results} describe the simulation study and report results: the headline battery, the adversarial gaming battery, the emergence-under-learning experiment, the $\Rzero \rightarrow \Rstar$ calibration, and the sensitivity analysis. \Sref{sec:differential} sets LDTC's calls against the major competing theories and answers the thermostat objection. \Sref{sec:limitations} states limitations and failure modes, \Sref{sec:outlook} summarizes the engineering outlook (the full roadmap, predicted hardware signatures, and phased physical program are \Apprefrange{sec:blueprint}{sec:experimental}), and \SSref{sec:metaphysics}{sec:conclusion} discuss interpretation, scope, and conclusions. \subsection{Related Work} -Prior accounts each capture a facet of interiority, but they leave open the question LDTC answers: does the system prioritize preservation of a closed maintenance loop over exchange, and does it recover under bounded stress (NC1/SC1)? Our criterion makes that priority measurable as loop dominance ($\mathcal{L}_{\text{loop}} \geq \mathcal{L}_{\text{exchange}} + \sigma$; $M \equiv 10 \cdot \log_{10}(\mathcal{L}_{\text{loop}}/\mathcal{L}_{\text{exchange}})$) and that resilience testable via $\varepsilon$ and $\tau_{\max}$, using the estimators and guardrails defined in \Sref{sec:criterion}. These are reproducibility presets ($R_0$), replaced by calibrated values $R^*$ per \Sref{sec:methods_calibration}; see \Cref{box:nc1sc1test} and \SSref{sec:nc1}{sec:sc1}. +Prior accounts each capture a facet of self-maintaining organization, but they leave open the question this paper makes testable: does the system prioritize preservation of a closed maintenance loop over exchange, and does it recover under bounded stress (NC1/SC1)? Our criterion makes that priority measurable as the loop-dominance margin ($\mathcal{L}_{\text{loop}} \geq \mathcal{L}_{\text{exchange}} + \sigma$; $M \equiv 10 \cdot \log_{10}(\mathcal{L}_{\text{loop}}/\mathcal{L}_{\text{exchange}})$) and that resilience testable via $\varepsilon$ and $\tau_{\max}$, using the estimators and guardrails defined in \Sref{sec:criterion}. These are reproducibility presets ($R_0$), replaced by calibrated values $R^*$ per \Sref{sec:methods_calibration}; see \Cref{box:nc1sc1test} and \SSref{sec:nc1}{sec:sc1}. \begin{longtable}{p{0.25\textwidth}p{0.35\textwidth}p{0.35\textwidth}} \caption{Comparison of prior theories with LDTC additions (\NC/\SC).}\label{tab:comparison}\\ @@ -121,14 +121,15 @@ \subsection{Related Work} Thresholds ($\Mmin$, $\eps$, $\taumax$) are reproducibility presets ($\Rzero$), replaced by calibrated values $\Rstar$ per \Sref{sec:methods_calibration}; see \Cref{box:nc1sc1test} and \SSref{sec:nc1}{sec:sc1}. -In short, IIT, FEP/active inference, workspace and higher-order models, and self-modeling robotics remain necessary but insufficient. LDTC supplies the missing engineering criterion, self-prioritizing loop dominance (NC1) and bounded-perturbation resilience (SC1), together with concrete estimators, thresholds, and protections that let labs falsify or certify claims in practice. +In short, IIT, FEP/active inference, workspace and higher-order models, and self-modeling robotics each describe self-maintaining organization, but none of them yields a pass/fail decision rule for self-prioritizing self-maintenance. LDTC supplies that rule, loop dominance (NC1) and bounded-perturbation resilience (SC1), together with concrete estimators, thresholds, and protections that let labs falsify or certify claims in practice. \subsection{Contributions} \begin{itemize} -\item \textbf{An operational criterion.} A quantitative necessary condition (\NC) for self-prioritization, loop dominance $M \equiv 10\log_{10}(\Lloop/\Lexchange) \geq \Mmin$, and a complementary sufficient condition (\SC) for resilient homeostasis under a bounded perturbation battery, both stated as falsifiable, device-signed decision rules~\cite{barrett2011practical}. +\item \textbf{A named measure and an operational criterion.} The \emph{loop-dominance margin} $M \equiv 10\log_{10}(\Lloop/\Lexchange)$, a single decibel statistic for how strongly a system's self-maintenance loop dominates its exchange; a quantitative necessary condition (\NC) for self-prioritization, $M \geq \Mmin$; and a complementary sufficient condition (\SC) for resilient homeostasis under a bounded perturbation battery, all stated as falsifiable, device-signed decision rules~\cite{barrett2011practical}. The measure is defined for any system with a declared $C/\text{Ex}$ partition and carries no commitment to any theory of consciousness. \item \textbf{A reference verification harness.} An open implementation that estimates $\Lloop$ and $\Lexchange$ with dual estimators (VAR-Granger and Kraskov $k$-NN MI) and per-window confidence intervals, behind guardrails (deterministic $C/\text{Ex}$ partitioning, $\Delta t$ governance, a tamper-evident audit chain, and anti-gaming smell tests) and a command-refusal arbiter. \item \textbf{A validated simulation study.} A fully reproducible, multi-seed study on a software plant that separates a self-maintaining positive control from two structural negative controls and an exogenous-subsidy control, certifies \SC recovery for bounded perturbations while correctly failing a designed-fail control outage, refuses certification to a three-member adversarial gaming battery (replayed actuation, hidden-tether control, telemetry oscillation), and triggers signed refusal, all reported with bootstrap and Wilson confidence intervals. +\item \textbf{An emergence-under-learning result.} Evidence that the instrument detects loop dominance nobody wired in: a small policy network trained from scratch on a survival objective that never references the measure develops certified loop dominance through training, and matched state-independent ablations of the trained policy collapse it. \item \textbf{Threshold calibration and sensitivity.} A data-grounded calibration of the generic presets to the plant ($\Rzero \rightarrow \Rstar$) on a seed range disjoint from evaluation, and evidence that the necessary-condition contrast is robust to the estimator and to measurement and modeling choices. \item \textbf{An engineering roadmap and predicted signatures (future work).} A phased path from the software plant to chemorobotic prototypes~\cite{maturana1980autopoiesis,varela1979principles,dipaolo2005autopoiesis,kiefer2022active}, with falsifiable behavioral signatures (command refusal, resource reallocation, predictive maintenance, non-derivative nociception, and irreversible collapse) to test in hardware (\Apprefrange{sec:blueprint}{sec:experimental}). \end{itemize} @@ -187,7 +188,7 @@ \subsection{$\mathcal{L}$ and the C/Ex partition} \subsection{Necessary Condition (NC1: self-prioritization)} \label{sec:nc1} -During normal operation the system satisfies self-prioritization when $\mathcal{L}_{\text{loop}} \geq \mathcal{L}_{\text{exchange}} + \sigma$ for sustained intervals exceeding its intrinsic recovery time ($\sigma > 0$). Equivalently, define $M \equiv 10 \cdot \log_{10}(\mathcal{L}_{\text{loop}}/\mathcal{L}_{\text{exchange}})$ [dB]; NC1 holds when $M \geq M_{\min}$. Provisional defaults (profile $R_0$): $M_{\min} = 3$ dB and a positive $\sigma$. These are reproducibility presets ($R_0$), replaced by calibrated values $R^*$ per \Sref{sec:methods_calibration}; see Box~\ref{box:nc1sc1test} and \SSref{sec:nc1}{sec:sc1}. +During normal operation the system satisfies self-prioritization when $\mathcal{L}_{\text{loop}} \geq \mathcal{L}_{\text{exchange}} + \sigma$ for sustained intervals exceeding its intrinsic recovery time ($\sigma > 0$). Equivalently, define the \emph{loop-dominance margin} $M \equiv 10 \cdot \log_{10}(\mathcal{L}_{\text{loop}}/\mathcal{L}_{\text{exchange}})$ [dB]; NC1 holds when $M \geq M_{\min}$. Provisional defaults (profile $R_0$): $M_{\min} = 3$ dB and a positive $\sigma$. These are reproducibility presets ($R_0$), replaced by calibrated values $R^*$ per \Sref{sec:methods_calibration}; see Box~\ref{box:nc1sc1test} and \SSref{sec:nc1}{sec:sc1}. \subsection{Sufficient Condition (SC1: resilient homeostasis)} \label{sec:sc1} @@ -205,7 +206,7 @@ \subsection{Single-use glossary (paper-wide identifiers)} \item $\taurec$: recovery time after $\eta\in\Omega$, measured from perturbation offset to the first window of a sustained compliant streak ($M \geq \Mmin$ held for a pre-registered number of consecutive windows); $\taurec = \infty$ if no sustained streak occurs. \item $\taumax$: bound on $\taurec$; default $\taumax = 60$ s. These are reproducibility presets ($\Rzero$), replaced by calibrated values $\Rstar$ per \Sref{sec:methods_calibration}. \item $\sigma$: positive safety margin required after recovery (used interchangeably with $M$ as a compliance knob). -\item $M$ (dB): decibel loop-dominance $M \equiv 10\cdot\log_{10}(\Lloop/\Lexchange)$; compliance may be specified as $M \geq \Mmin$, default $\Mmin = 3$ dB. These are reproducibility presets ($\Rzero$), replaced by calibrated values $\Rstar$ per \Sref{sec:methods_calibration}. ($M$ defined when $\Lexchange>0$.) +\item $M$ (dB): the loop-dominance margin $M \equiv 10\cdot\log_{10}(\Lloop/\Lexchange)$; compliance may be specified as $M \geq \Mmin$, default $\Mmin = 3$ dB. These are reproducibility presets ($\Rzero$), replaced by calibrated values $\Rstar$ per \Sref{sec:methods_calibration}. ($M$ defined when $\Lexchange>0$.) \item Preset profile $\Rzero$: The tuple ($\eps=0.15$, $\taumax=60$ s, $\Mmin=3$ dB, $\sigma>0$) used as an initial, pre-registered configuration for comparability; superseded by calibrated values where available. \item Calibrated profile $\Rstar$: The data-driven thresholds obtained from Methods \Sref{sec:methods_calibration}; reported alongside $\Rzero$ in results. \item LREG: enclave-protected register/log for $\mathcal{L}$ point estimates, CI bounds, and compliance flags. @@ -234,7 +235,7 @@ \subsection{Single-use glossary (paper-wide identifiers)} \textbf{Metrics (what to test)} \begin{itemize} -\item $\Lloop \equiv \mathcal{L}(C)$, $\Lexchange \equiv \mathcal{L}(\text{Ex})$; loop dominance $M \equiv 10 \cdot \log_{10}(\Lloop/\Lexchange)$ (dB). +\item $\Lloop \equiv \mathcal{L}(C)$, $\Lexchange \equiv \mathcal{L}(\text{Ex})$; loop-dominance margin $M \equiv 10 \cdot \log_{10}(\Lloop/\Lexchange)$ (dB). \item Defaults for reproducibility (profile $\Rzero$): $\Mmin = 3$ dB, $\eps = 0.15$, $\taumax = 60$ s, $\sigma > 0$. These are reproducibility presets ($\Rzero$), replaced by calibrated values $\Rstar$ per \Sref{sec:methods_calibration}. \end{itemize} @@ -388,7 +389,7 @@ \subsection{Case Studies} \subsection{Summary} -No existing AI architecture satisfies NC1, let alone SC1. Integrated causal power is overwhelmingly directed toward externally mandated tasks and exchanges, while self-protective maintenance is either delegated to human custodians or engineered for component reliability, not for the system's own continuity. Whatever their functional sophistication, these systems lack the self-maintaining boundary that loop dominance measures; on the operational criterion they are not self-maintaining systems. +On this analysis, no current AI architecture satisfies NC1, let alone SC1. Integrated causal power is overwhelmingly directed toward externally mandated tasks and exchanges, while self-protective maintenance is either delegated to human custodians or engineered for component reliability, not for the system's own continuity. Whatever their functional sophistication, these systems lack the self-maintaining boundary that loop dominance measures; on the operational criterion they are not self-maintaining systems. We stress that this section is an architectural argument, not a harness measurement: the estimates in \Cref{tab:casestudies} are illustrative, and instrumenting a deployed serving stack to report its measured \NC margin is a direct, so far unrealized, application of the instrument. \section{Simulation Study: Methods} \label{sec:sim_methods} @@ -672,9 +673,9 @@ \section{Interpretation, Scope, and Ethics} \section{Conclusion} \label{sec:conclusion} -We set out to make a qualitative contrast precise: the difference between a system that maintains its own existence and one that merely runs on externally supplied energy and goals. We defined that difference as loop dominance, a decibel ratio between the predictive dependence concentrated in a closed self-maintenance loop and that governing open exchange, and we turned it into two falsifiable decision rules: a necessary condition (\NC) on persistent loop dominance and a sufficient condition (\SC) on bounded-perturbation resilience. +We set out to make a qualitative contrast precise: the difference between a system that maintains its own existence and one that merely runs on externally supplied energy and goals. We defined that difference as loop dominance, summarized by the loop-dominance margin $M$, a decibel ratio between the predictive dependence concentrated in a closed self-maintenance loop and that governing open exchange, and we turned it into two falsifiable decision rules: a necessary condition (\NC) on persistent loop dominance and a sufficient condition (\SC) on bounded-perturbation resilience. -The core result of the paper is that these rules are implemented and tested, not merely proposed. An open verification harness computes them with confidence intervals behind a set of anti-gaming guardrails, and a fully reproducible multi-seed simulation study shows that the criterion separates a self-maintaining positive control from two structurally different negative controls, correctly invalidates an exogenously subsidized system instead of certifying it, certifies recovery from the bounded perturbation battery while correctly reporting failure on a designed-fail control outage, refuses certification to all three members of an adversarial gaming battery, and refuses a boundary-threatening command at low charge. The necessary-condition contrast is robust to the estimator and to the main measurement and modeling choices, and we calibrate the generic presets to the plant on a disjoint seed range. +The core result of the paper is that these rules are implemented and tested, not merely proposed. An open verification harness computes them with confidence intervals behind a set of anti-gaming guardrails, and a fully reproducible multi-seed simulation study shows that the criterion separates a self-maintaining positive control from two structurally different negative controls, correctly invalidates an exogenously subsidized system instead of certifying it, certifies recovery from the bounded perturbation battery while correctly reporting failure on a designed-fail control outage, refuses certification to all three members of an adversarial gaming battery, certifies the loop dominance that emerges in a policy trained from scratch on a survival objective while rejecting state-independent ablations of the same policy, and refuses a boundary-threatening command at low charge. The necessary-condition contrast is robust to the estimator and to the main measurement and modeling choices, and we calibrate the generic presets to the plant on a disjoint seed range. We then laid out, explicitly as future work (\Apprefrange{sec:blueprint}{sec:experimental}), an engineering roadmap and a physical experimental program that would carry the same instrument from a software plant to chemorobotic prototypes, together with the observable signatures such systems should display. The framework was motivated by the question of the physical conditions for consciousness; we have kept that motivation while separating it cleanly from the results, which stand as claims about measurable loop dominance and are independent of any metaphysical reading.