diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index eca7821a..6012bec0 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -8,12 +8,12 @@ Hypatia is the neurosymbolic CI/CD intelligence layer for the hyperpolymath ecos > against the tree on 2026-08-07. If you change the shape of the system, re-measure > (`ls lib/**/*.ex | wc -l`, `grep -c "export fn" ffi/zig/src/main.zig`, the child list in > `lib/application.ex`) rather than editing a number by eye. Known-stale claims and the -> full audit live in `docs/DEBT-REGISTER.md`. +> full audit live in `docs/DEBT-REGISTER.adoc`. ## Architecture ``` -Hypatia # 142 Elixir modules total (`shopt -s globstar; ls lib/**/*.ex | wc -l`) +Hypatia # 149 Elixir source files under lib/ (recursive tree count, 2026-11-04) ├── Elixir pipeline # core: pattern analysis, dispatch, learning ├── Merge orchestration # 12 modules — leases, ledger, ticker (largest subsystem) ├── Kin subsystem # 6 modules — contingency, arbiter, gate, watchdog @@ -152,7 +152,8 @@ OutcomeTracker.record_outcome() -- Feedback loop | `sequence_model.ex` | Sequence | Sequential dispatch modelling | | `blackboard.ex` | ETS | Shared board all 8 networks read/write, six phases | | `coordinator.ex` | GenServer | Orchestrates all 8 networks over the blackboard | -| `persistence.ex` | — | Warm-restart state for all 8 networks | +| `persistence.ex` | — | Matching source snapshots; ESN/LSM summaries; trust rebuilt | +| `source_store.ex` | — | Captured root identity and explicit lifecycle refusal | | `rebalancer.ex` | — | Training-data rebalancing, strategies A/B/C | | `prover_recommender.ex` | — | Recommends a prover for a proof obligation | @@ -162,7 +163,7 @@ OutcomeTracker.record_outcome() -- Feedback loop |--------|---------| | `training_pipeline.ex` | ESN/RBF training from real verisim-data outcomes + pattern vectors | -Training pipeline reads outcomes/*.jsonl for ESN (confidence time series) and patterns/registry.json for RBF (8-D feature vectors). Coordinator's `:force_cycle` triggers training automatically. +Training pipeline reads outcomes/*.jsonl for ESN (confidence time series) and patterns/registry.json for RBF (8-D feature vectors). `Coordinator.force_cycle/0` admits a source-bound queued request; `:ok` does not claim completed training. Status and health calls refuse mismatched or blocked lifecycles. See `docs/architecture/neural-source-lifecycle.adoc` for the return contracts and qualification boundary. ### Idris2 ABI (src/Hypatia/ABI/) @@ -297,7 +298,7 @@ into redoing finished work. 9. **Zig FFI is ungated** — 18 exports and 16 connectors, no `zig build` in any workflow. The full evidence-backed audit, including CI/CD, licence, proof and metadata debt, is -`docs/DEBT-REGISTER.md`. Read it before trusting any count in an older document. +`docs/DEBT-REGISTER.adoc`. Read it before trusting any count in an older document. ## Code Style diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 8bc97290..9e80e875 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -15,7 +15,6 @@ workflows: - 'actions/upload-artifact@v7.0.2' - 'dtolnay/rust-toolchain@v1' - 'taiki-e/install-action@v2.88.0' - '.github/workflows/cicd-squabbler.yml': [] '.github/workflows/ci-health-sweep.yml': - 'actions/checkout@v7.0.1' - 'step-security/harden-runner@v2.22.3' @@ -30,6 +29,7 @@ workflows: - 'haskell-actions/setup@v2.12.1' - 'swatinem/rust-cache@v2.9.3' - 'taiki-e/install-action@v2.88.0' + '.github/workflows/cicd-squabbler.yml': [] '.github/workflows/clusterfuzzlite.yml': - 'actions/checkout@v7.0.1' - 'google/clusterfuzzlite@v1' @@ -49,12 +49,12 @@ workflows: - 'actions/upload-artifact@v7.0.2' - 'step-security/harden-runner@v2.22.3' '.github/workflows/governance.yml': - - 'metadatastician/standards@1725bebd3773ea25627479f813283abf1c497a85' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/hypatia-remediation-sweep.yml': - 'actions/upload-artifact@v7.0.2' - 'step-security/harden-runner@v2.22.3' '.github/workflows/hypatia-scan.yml': - - 'metadatastician/standards@1725bebd3773ea25627479f813283abf1c497a85' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/inbox-steward-intake.yml': - 'actions/checkout@v7.0.1' - 'step-security/harden-runner@v2.22.3' @@ -68,7 +68,7 @@ workflows: - 'actions/upload-artifact@v7.0.2' - 'erlef/setup-beam@v1.24.1' '.github/workflows/mirror.yml': - - 'metadatastician/standards@1725bebd3773ea25627479f813283abf1c497a85' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/pages.yml': - 'actions/checkout@v7.0.1' - 'actions/deploy-pages@v5.0.1' @@ -108,10 +108,9 @@ workflows: - 'dtolnay/rust-toolchain@v1' - 'swatinem/rust-cache@v2.9.3' '.github/workflows/scorecard.yml': - - 'metadatastician/standards@1725bebd3773ea25627479f813283abf1c497a85' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/secret-scanner.yml': - - 'metadatastician/standards@1725bebd3773ea25627479f813283abf1c497a85' - '.github/workflows/standards-integration.yml': [] + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/security-policy.yml': - 'actions/cache@v6.1.0' - 'actions/checkout@v7.0.1' @@ -122,6 +121,7 @@ workflows: - 'gitleaks/gitleaks-action@v3.0.0' - 'taiki-e/install-action@v2.88.0' - 'trufflesecurity/trufflehog@v3.99.7' + '.github/workflows/standards-integration.yml': [] '.github/workflows/tests.yml': - 'actions/cache@v6.1.0' - 'actions/checkout@v7.0.1' @@ -373,9 +373,9 @@ dependencies: commit: 'sha1-e384daf6167a7a7567642d6a62060106244d2413' owner_id: 6759885 repo_id: 1352485172 - 'metadatastician/standards@1725bebd3773ea25627479f813283abf1c497a85': - ref: '1725bebd3773ea25627479f813283abf1c497a85' - commit: 'sha1-1725bebd3773ea25627479f813283abf1c497a85' + 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12': + ref: 'a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' + commit: 'sha1-a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' owner_id: 297579217 repo_id: 1116521501 uses: diff --git a/.github/workflows/escript-soundness.yml b/.github/workflows/escript-soundness.yml index d753493d..7753d630 100644 --- a/.github/workflows/escript-soundness.yml +++ b/.github/workflows/escript-soundness.yml @@ -177,7 +177,11 @@ jobs: run: | set -euo pipefail mkdir -p "$HOME/.local/bin" - curl -fsSL -o "$HOME/.local/bin/nickel" \ + curl --fail --silent --show-error --location \ + --proto '=https' --proto-redir '=https' \ + --connect-timeout 20 --max-time 60 \ + --retry 3 --retry-delay 2 --retry-max-time 180 \ + -o "$HOME/.local/bin/nickel" \ "https://github.com/tweag/nickel/releases/download/${NICKEL_VERSION}/nickel-x86_64-linux" echo "${NICKEL_SHA256} $HOME/.local/bin/nickel" | sha256sum -c - chmod +x "$HOME/.local/bin/nickel" diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 16897142..d39ad857 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -16,17 +16,11 @@ permissions: jobs: governance: - # Standards pin (immutable, do not float): 1725beb… is the first - # standards revision whose actions allowlist contains - # `metadatastician/standards/.github/workflows/*@*` and whose mirror - # reusable gates SSH-host verification on the key being configured. - # The previous pin (333b06a…) lacked that pattern, so Allowlist - # Preflight failed on the five canon reusables themselves — a - # pinned-policy mismatch (category 3), not five genuine findings. - # Bump all five standards callers + actions.lock together; see - # docs/consumers/standards-governance.adoc and - # test/standards-integration-test.sh. - uses: metadatastician/standards/.github/workflows/governance-reusable.yml@1725bebd3773ea25627479f813283abf1c497a85 + # Published standards PR #1394 includes the two exact ClusterFuzzLite + # action paths already admitted by live policy. All five callers share + # this immutable authority; actions.lock is generated by the official + # recovery runner, never patched by hand. + uses: metadatastician/standards/.github/workflows/governance-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 # Scanner pin: hypatia scans itself with its own main branch. On a PR that # is the base commit; on push, schedule or dispatch it is the commit being # run. It is never the PR head, so a PR cannot weaken the rules that gate diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index ec4aa4e2..ca3aeb68 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -21,7 +21,7 @@ permissions: jobs: hypatia: - uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@1725bebd3773ea25627479f813283abf1c497a85 + uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 # Scanner pin: hypatia's own main branch, never the PR head. See governance.yml. with: hypatia-ref: ${{ github.event.pull_request.base.sha || github.sha }} diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 5d62db1b..dda1f247 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -41,7 +41,7 @@ permissions: jobs: mirror: - uses: metadatastician/standards/.github/workflows/mirror-reusable.yml@1725bebd3773ea25627479f813283abf1c497a85 + uses: metadatastician/standards/.github/workflows/mirror-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 # Only the seven forge keys the callee declares (standards#1108 §6b). secrets: GITLAB_SSH_KEY: ${{ secrets.GITLAB_SSH_KEY }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index a9e4726a..f9327fcb 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -16,7 +16,7 @@ permissions: jobs: scorecard: - uses: metadatastician/standards/.github/workflows/scorecard-reusable.yml@1725bebd3773ea25627479f813283abf1c497a85 + uses: metadatastician/standards/.github/workflows/scorecard-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 permissions: actions: read contents: read diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index ef8f5b7b..771c515b 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -22,4 +22,4 @@ jobs: contents: read pull-requests: write actions: read - uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@1725bebd3773ea25627479f813283abf1c497a85 + uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ea443e83..8d7f9c2c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -110,7 +110,20 @@ jobs: - name: E0 outcome chain and cross-repo evidence regression (Elixir) run: | - mix test test/outcome_tracker_test.exs test/cross_repo_learning_test.exs + mix test --trace \ + test/outcome_tracker_test.exs \ + test/cross_repo_learning_test.exs \ + test/outcome_tracker_runtime_store_test.exs \ + test/merge_orchestration/sensor_test.exs \ + test/merge_orchestration/loop_test.exs \ + test/learning_runtime_store_test.exs \ + test/neural_source_store_lifecycle_test.exs \ + test/merge_orchestration/strategist_test.exs \ + test/merge_orchestration/dispatcher_test.exs \ + test/merge_orchestration/baton_emitter_test.exs \ + test/merge_orchestration/kin_competence_test.exs \ + test/merge_orchestration/merge_orchestrate_task_test.exs \ + test/workflow_hardening_test.exs - name: Run E2E test suite run: bash tests/e2e.sh diff --git a/.machine_readable/descriptiles/PLAYBOOK.a2ml b/.machine_readable/descriptiles/PLAYBOOK.a2ml index e06fa4bf..22b0af83 100644 --- a/.machine_readable/descriptiles/PLAYBOOK.a2ml +++ b/.machine_readable/descriptiles/PLAYBOOK.a2ml @@ -54,7 +54,8 @@ steps = [ [maintenance-operations] baseline-audit = "mix test && cargo test && just validate" hard-pass-gate = "mix test 0 failures (233 :verisim_data excluded by design)" -neural-retrain = "GenServer.call(Hypatia.Neural.Coordinator, :force_cycle)" +neural-retrain = "Hypatia.Neural.Coordinator.force_cycle() — :ok means queued; inspect Coordinator.status() for source refusal and cycle completion" +neural-source-lifecycle = "docs/architecture/neural-source-lifecycle.adoc — an observed data-root change requires an explicit decision and fresh matching cohort; no automatic relabel/restart" scan-estate = "just scan (runs panic-attack assail across estate, writes to verisim-data)" dispatch-run = "just dispatch (runs dispatch-runner.sh against pending.jsonl)" crg-check = "Review STATE.a2ml [project-context] crg-grade vs crg-grade-target" diff --git a/.machine_readable/descriptiles/STATE.a2ml b/.machine_readable/descriptiles/STATE.a2ml index 0e9728b1..86dfd8a9 100644 --- a/.machine_readable/descriptiles/STATE.a2ml +++ b/.machine_readable/descriptiles/STATE.a2ml @@ -298,3 +298,13 @@ actions = [ # Benchmarks confirmed real in fixer/benches/hypatia_bench.rs (7 benches, not stubs). # 2026-04-03: Added 3 new rules: WF013 (permissions typo detection), GS007 (stale branch detection), # SD013 (gitignore path-scope detection). 279 lines of new rule code, 13 tests. + +[neural-source-lifecycle] +status = "authored-pending-hosted-qualification" +checkpoint-date = "2026-11-04" +contract = "docs/architecture/neural-source-lifecycle.adoc" +source-identity = "expanded configured data-root pathname; not content authentication" +new-regression-source = "test/neural_source_store_lifecycle_test.exs" +authored-new-test-functions = 22 +qualification = "No execution receipt for this new lifecycle candidate. Existing runtime-path test receipts do not qualify it. Exact-source compile, focused and normal full gates remain required." +authority = "No automatic restart, dispatch, or deployment promotion from a source refusal." diff --git a/docs/architecture/neural-source-lifecycle.adoc b/docs/architecture/neural-source-lifecycle.adoc new file mode 100644 index 00000000..9b777734 --- /dev/null +++ b/docs/architecture/neural-source-lifecycle.adoc @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += Neural source-store lifecycle + +== Boundary and reason + +Runtime path resolution alone does not bind an existing learned model to the +directory that supplied its observations. Previously a running Coordinator +could retain A's trained ESN/RBF when B had insufficient inputs, and the +persistence destination would be resolved again as B. Separately supervised +GNN/VAE/Sequence processes and the shared Blackboard had no source label. + +The lifecycle now captures the expanded configured data-root pathname at +initialization. Entry and exit checks bind operations to that identity. +Training, trust graph inputs and language context, recommendation maturity +counts, and persistence use the captured directory. An observed root change +or incompatible peer source identity blocks that lifecycle. Switching the +configuration back does not clear the refusal. A process-unavailable error from +an otherwise initialized cohort refuses the current operation without latching +a permanent block; a later operation must check the actual peers again. + +Initialization has a stricter boundary: any failure before complete peer +hydration and the final cohort check blocks that new Coordinator, including a +temporary outage or a generic restore error after partial hydration. Peer +recovery alone cannot qualify an initialization that never completed. An +operator must inspect the source before starting a fresh matching cohort. + +This is a pathname/lifecycle contract. It does not authenticate the directory, +detect an in-place replacement, attest a snapshot, prove training adequacy, +or establish calibrated model performance. A change A-to-B-to-A wholly between +observations is not a recorded configuration history; pinned reads still use A. +CrossRepoLearning's independent index/transfer subsystem is outside this +lifecycle and has not acquired a deployment assurance claim. + +== Cohort and persistence + +The Coordinator checks the actual named GNN, VAE and Sequence processes and +the source-bound Blackboard, including when those servers are isolated PIDs. +Each peer retains its own initialization source. A restarted Coordinator cannot +silently relabel older peers to its newly selected root. + +Queue submission captures the source. Learning-cycle admission and execution +each check the lifecycle. Outcome notifications use a nonblocking submission +to the observed local Coordinator PID so synchronous training cannot exhaust +a caller's admission timeout. Their source and actual peer cohort are checked +when execution starts and when it completes. A source change still refuses +learning; a successful submission is not an admission or completion receipt. + +The outcome mailbox is volatile. Missing/dead/nonlocal targets are refused, +but a target can exit after the liveness observation and lose queued work. +The PID is captured so a later name registration cannot redirect an old event +to a new process. This interface makes no durable-delivery claim. Old unbound +casts remain refused. Parallel phase errors are returned to the guard instead +of being discarded from an accepted trace. + +Persistence first collects all three matching peer snapshots, serializes every +document, then checks the Coordinator source again before writing. A missing +peer, mismatched snapshot, or encoding error prevents the first bundle write. +All writes target the captured directory. Filesystem I/O is not an atomic +multi-file transaction: a write error can leave a partial bundle and is returned +as an error, never as successful persistence. + +Snapshots carry source_schema=1, source_store, and source_status. Startup only +hydrates supported matching ready model snapshots. Legacy, foreign, and future +labels are returned by raw diagnostic reads, are not deleted on startup, and +are not model restore authority. Existing persistence still replaces current +snapshot files when a later successful cycle writes new state. Trust scores +are always rebuilt from current qualified observations, including when a +matching current-format trust snapshot exists. + +== Public result contracts and callers + +[cols="2,3,3",options="header"] +|=== +| Entry point | Successful result | Refusal and caller handling + +| Coordinator.analyze/1, dispatch_recommendation/1 +| Source-labelled analysis/recommendation map; optional server argument supports +an isolated actual cohort. dispatch_recommendation/3 additionally accepts a +caller timeout. Kin's default predictor uses a five-second wait, below its +ten-second review call; this does not cancel an analysis already executing. +Injected predictors retain their existing contract. +| {:error, reason}. Kin holds auto-execute for human review unless the ready +recommendation explicitly permits :auto_execute, is_novel is false, and bounded +confidence agrees. A maturity cap is retained even at high confidence; manual +tiers keep their existing route. PatternAnalyzer already treats non-map replies as +unavailable and applies DispatchGuard's ceiling. + +| Coordinator.force_cycle/0 +| :ok means admitted to the source-bound queue, not completed training. +| {:error, reason}; later execution failures appear in lifecycle diagnostics. +LearningScheduler reports a trigger only after admission. + +| Coordinator.record_outcome/2 +| :ok means volatile submission to an observed live local PID, without waiting +for that process to finish training. Source admission is deferred to execution. +| Missing/dead/nonlocal targets return {:error, :neural_unavailable}. Later +execution refusals remain subject to the source guard and diagnostics. +FleetDispatcher returns :queued for submission or an explicit error; neither +that value nor its independent raw log is evidence of completed learning. + +| Coordinator.status/0 and health_report/0 +| {:ok, status} and a health map respectively, labelled with the current ready +source. A cold model may be available without being trained. +| {:error, {:neural_source_unavailable, details}}. Diagnostics records a failure; +the watcher does not use blocked/old-source drift as corroboration. Busy probes +remain unverified and do not authorize a restart. + +| Named-network state/0, restore/1 and analysis calls +| state/0 remains a labelled diagnostic snapshot. Restore and analysis check +both the process source and request source. +| Diagnostic snapshots expose blocked status. Restore refuses unbound/foreign +maps; a ready label is not a cryptographic provenance certificate. + +| Persistence.save_all/1 and load_all/0 +| :ok only after the checked bundle write; load_all returns raw diagnostic +snapshots, optionally from an explicitly selected directory. +| {:error, reason} from save_all; no fallback to another directory. Diagnostic +read success is not hydration authority. + +| Blackboard source-qualified calls +| Calls go through the owning process and require matching source/round +entries. Raw ETS helpers remain diagnostics. +| Unbound or foreign entries cannot enter the neural trace. An old A task +cannot write into a newly initialized B board. +|=== + +Diagnostics now collects canonical failure tuples and stores observed component +and failure state before notification. Notification failures retain that +observation. Notification has no recovery side effect; the filtered recovery +pass is the sole recovery call path for the observation. Source-lifecycle +failures are excluded from automatic recovery, +including the existing restart path; this does not broaden recovery authority +for other failures. Kin.Watchdog's process-liveness report remains a liveness +report, not a source/model-readiness receipt. + +== Trust graph evidence identities + +A qualified outcome verdict alone is insufficient to identify graph subjects. +Outcome-derived nodes and all direct/cross-repo edges require nonblank string +repo, bot and recipe identities. Canonical pattern is an explicit alternate +recipe identity when recipe_id is nil or false, matching the training reader; +a present blank or malformed recipe_id cannot be hidden by pattern. + +Rejected rows remain in the audit log. The guard is local to GraphOfTrust and +does not widen or redefine OutcomeTracker's global evidence predicate. +Inventory-only recipes and qualified negative-only entities retain the neutral +score policy. Valid supported rows continue to create positive edges. + +== Outcome confidence and annealing persistence + +OutcomeTracker captures the expanded data root and effective annealing-state +directory once at each explicit update entry. The default annealing directory +is derived from that captured root; an explicit runtime override remains +supported. A raw append, its incremental annealing update and its recipe +summary reuse the same captured directories. Standalone confidence updates +also reuse one qualified-count snapshot for confidence and maturity. + +The update checks the captured identity before writes and after derived +writes. An observed source change returns +{:error, {:outcome_store_changed, captured, current}}; write errors remain +errors. Even a switch after a check cannot redirect a write to the new store, +because its path is already captured. These checks do not observe an A-to-B-to-A +change wholly between checks and do not make multiple file writes atomic. +A later write or source failure may leave an earlier derived write in A. +No rollback or complete configuration-history claim is made. + +The explicit persistence entrypoints are update_recipe_confidence/1 and the +incremental update called after a qualified canonical outcome append. +record_outcome/5 and record_fleet_outcome/6 return a committed raw event even +when a derived update fails; they log that pending update for the scheduler. +That return is not a claim that every derived file was updated. A failed raw +append remains an error. + +get_annealing_state/1, annealed_strategy/2 and cross_repo_confidence/2 now +reconcile saved maturity against captured qualified evidence in memory, without +a hidden disk write. Their map, strategy and numeric return contracts remain. +CrossRepoLearning consumes the returned map for arithmetic and does not +depend on a disk rewrite; the Coordinator already uses the separate +annealed_strategy_from_store/3 read-only route. The scheduler's disk report +observes the last successful explicit update, not a diagnostic read. + +The runtime-store controls include actual fixture-file A-to-B changes after +evidence collection, explicit override changes, stable updates, I/O refusal, +raw-event durability and read-only reconciliation. A deterministic barrier +invokes the same update implementation immediately before persistence; it +cannot bypass the source guard. These new controls require hosted execution +on the published candidate before qualification. + +== Registry synchronization + +PatternRegistry captures one expanded runtime data root at sync entry and uses +it for registry, substitution and output paths. Source checks before and after +publication refuse an observed change; encoding and filesystem failures return +errors. Even a switch between a check and the write cannot redirect the captured +destination into a new store. A write already completed in the original store +may precede a post-write refusal; this is not a multi-file transaction or a +complete configuration-history detector. + +== Qualification scope + +The regression source uses actual isolated Coordinator, Blackboard and named +network processes. It does not invoke live dispatch, a real bot, an actuator, +or automatic recovery. Small real reservoirs bound training cost. Controls +cover running A-to-B, empty B, matching B, queued/in-flight changes, peer +availability, all-before-write collection, raw versus qualified snapshots, +pinned reads, malformed graph subjects and refusal propagation through callers. +The follow-up controls distinguish a recoverable peer outage from incomplete +startup, submit outcomes while the actual Coordinator is suspended, then check +same-sender execution barriers, and reject a root change before queued learning. +They also exercise the default Kin predictor deadline and registry file +preservation under an observed source change or failed publication. + +The two fixtures that override global data roots suspend LearningScheduler and +AnomalyDetector before the override and restore the original paths before +resuming those singleton timers. This is test isolation; production timer +scheduling is unchanged. + +Relevant suites: + +* test/neural_source_store_lifecycle_test.exs +* test/learning_runtime_store_test.exs +* test/neural_trust_restart_test.exs +* test/graph_of_trust_qualification_test.exs +* test/graph_of_trust_convergence_test.exs +* test/neural_training_qualification_test.exs +* test/neural_dispatch_guard_test.exs +* test/blackboard_test.exs and test/kin_test.exs + +These controls must pass on the exact published source with the repository's +normal compilation and full test gates before the change is qualified. +The earlier runtime-path repair's receipts do not qualify this new lifecycle +boundary. Source review and authored tests alone are not execution receipts. diff --git a/docs/assurance/2026-11-04-trust-score-boundary.adoc b/docs/assurance/2026-11-04-trust-score-boundary.adoc new file mode 100644 index 00000000..6c58ad1f --- /dev/null +++ b/docs/assurance/2026-11-04-trust-score-boundary.adoc @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Qualified support and trust snapshots + +The graph ranks the subgraph supported by outcomes accepted by +`OutcomeTracker.qualified_outcome?/1` and carrying a positive success edge. +Recipe inventory alone is not successful evidence. A node with no such edge, +including an entity observed only in qualified failures or false positives, +keeps the existing neutral score of `0.5`. It contributes no PageRank mass and +does not change normalization of the supported subgraph. + +These are relative ranking scores, not calibrated success probabilities, +cryptographic attestations or authority to dispatch. Negative outcomes remain +in the graph's observations/nodes but do not create positive edges. A separate +negative penalty and time decay are still unimplemented; this change does not +claim that mixed success/failure histories have a calibrated risk score. + +== Startup and persistence + +The coordinator rebuilds the graph from the currently selected runtime data +store at startup and every learning cycle. It never overlays saved trust +scores. Legacy, current-labelled, malformed and future-version snapshots have +no restore authority. This is necessary even for structurally valid snapshots: +their outcomes may have been removed, invalidated or requalified since saving. + +New trust snapshots use `schema_version: 2` and +`restore_policy: recompute_from_current_qualified_outcomes`. Their scores and +edges remain useful diagnostic history. The label does not authenticate them +or enable hydration. Old files are retained until ordinary persistence replaces +them; no migration deletes or edits the historical outcome corpus. Learned +network restoration outside the trust graph retains its existing behavior. + +Both graph loading and neural snapshot persistence use `Hypatia.Paths` to +resolve the current runtime store. Changing that configured store therefore +changes the source read by the next rebuild and the destination of new snapshots. +Outcome recording, replay identity lookup and recipe confidence updates resolve +that same runtime store. Fleet output uses the runtime fleet setting. An explicit +annealing-directory override remains supported; without one, annealing state +lives under the selected data store. This does not claim atomic reconfiguration +in the middle of a running learning cycle. + +== Qualification controls + +`test/graph_of_trust_qualification_test.exs` exercises empty and unqualified +histories, failure-only and false-positive-only histories, and an expansion by +500 unsupported recipes. The positive-support ranking must survive unchanged +while all unsupported nodes remain neutral. + +`test/neural_trust_restart_test.exs` calls the actual coordinator initializer +against synthetic poisoned snapshots and a replacement runtime store. It checks +that an old score cannot return under a current schema label, that persistence +writes the diagnostic version, and that a new qualified success still affects +the rebuilt graph. Fixtures live only in the disposable test store. + +These tests qualify these source-level boundaries. They do not authenticate +the historical outcome corpus or establish a live dispatch/training deployment. diff --git a/docs/consumers/standards-governance.adoc b/docs/consumers/standards-governance.adoc index ced0f63c..52010fc4 100644 --- a/docs/consumers/standards-governance.adoc +++ b/docs/consumers/standards-governance.adoc @@ -117,15 +117,28 @@ Note also PR #989's body claimed the Governance pin had moved to callers and in `actions.lock`. This change makes the tree match the claim and adds a contract test so the claim is checked, not trusted. +== ClusterFuzzLite authority repair (2026-11-04) + +Standards PR #1394 published the two exact action paths in commit +`a797d308c0517d4e2d21cc7f1cb08d41f82b1a12`. Its 29 policy controls +and 85 hosted checks passed before merge. This addresses the policy +copy drift described above; it does not exempt fuzzing or add an +owner-wide permission. All five Hypatia callers now target that +published authority. The native Actions lock must be regenerated by +the verified official recovery runner for the exact updated subject, +and the resulting hosted checks must pass before this consumer is +called qualified. + == The published integration (what consumers copy) `metadatastician/standards` at commit -`1725bebd3773ea25627479f813283abf1c497a85` (standards `main` at the -time of writing; the commit, not the branch, is the contract). Its +`a797d308c0517d4e2d21cc7f1cb08d41f82b1a12` (standards `main` at the +qualified rollout; the commit, not the branch, is the contract). Its reusables carry their own contract tests upstream (`scripts/tests/governance-reusable-contract-test.sh`, `scripts/tests/hypatia-scanner-refs-test.sh`), resolve Hypatia to a -vetted default SHA (`adb2f41…`) unless a caller canaries `HEAD`, and +vetted default SHA (`adb2f41…`) unless a caller explicitly canaries a +full source SHA, and stage their policy helpers from `job.workflow_sha` — the exact commit the caller pinned. @@ -140,7 +153,7 @@ the examples below are not a universal permission block. ---- jobs: governance: - uses: metadatastician/standards/.github/workflows/governance-reusable.yml@1725bebd3773ea25627479f813283abf1c497a85 + uses: metadatastician/standards/.github/workflows/governance-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 with: # Scanner source: base commit on PRs, event commit otherwise. # Never the PR head — a PR must not weaken the rules gating it. @@ -150,7 +163,7 @@ jobs: contents: read security-events: write hypatia: - uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@1725bebd3773ea25627479f813283abf1c497a85 + uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 with: hypatia-ref: ${{ github.event.pull_request.base.sha || github.sha }} secrets: # only the declared secret; never `secrets: inherit` diff --git a/docs/design/merge-orchestration/05-execution-substrate-bag-of-actions.adoc b/docs/design/merge-orchestration/05-execution-substrate-bag-of-actions.adoc index 62cc750c..cd72dfc8 100644 --- a/docs/design/merge-orchestration/05-execution-substrate-bag-of-actions.adoc +++ b/docs/design/merge-orchestration/05-execution-substrate-bag-of-actions.adoc @@ -6,10 +6,15 @@ [NOTE] ==== -*Status: DESIGN ONLY.* `hyperpolymath/bag-of-actions` is not yet in this -work's session scope, so its exact Baton format / `Bag.Mesh` API could not be -read first-hand. This doc records the mapping and the integration sketch so the -build is ready the moment the repo is in scope. Nothing here is wired. +*Status: unqualified design; mutation contained.* The 2026-11-04 source review +found that the previous emitter's verifier map did not execute the farm gate +or establish the downstream verifier/ledger contract. `BatonEmitter` now +explicitly refuses mutation. The sketch below is retained as a proposal, not an +operational guarantee. See link:06-observation-and-actuation-provenance.adoc[the +current observation and execution boundary]. The Mix command rejects +`--actuation baton` and `--actuation both` before starting a cycle or writing a +manifest, including empty cycles. Use `--actuation manifest` for the available +decision-artifact path; a manifest does not establish a completed merge. ==== toc::[] @@ -18,13 +23,16 @@ toc::[] The cron + tick (artifact-7) make the merge-orchestration loop *runnable on a schedule* — a GenServer tick in the brain, or an hourly GitHub Actions cron. -Both write the decision manifest (`merge-decisions.jsonl`) that the -`.git-private-farm` actuator polls and executes. +Both can write a decision manifest (`merge-decisions.jsonl`). The +`.git-private-farm` actuator remains deliberately unwired and dry-run by default; +this document does not authorize polling, merging or enabling a scheduler. That JSONL+poll seam is the weakest link: it is a *passive* hand-off (the farm must poll), the "no double-merge" guarantee is a `MapSet` + a file lock rather -than a type, and it runs on GitHub-hosted Actions minutes (the farm's billing -is currently the blocker on scheduled actuation). +than a type, and a hosted deployment would use GitHub Actions minutes. The +farm's September billing incident is historical: its 2026-11-03 inventory +included successful and failed jobs, so there is no current blanket billing +block claim here. *Bag-of-Actions* (BoA) is the owner-flagged alternative: a "distributed OS for mobile continuations". It is a near-exact structural fit for what the @@ -67,7 +75,7 @@ attested merge actions — and it dissolves all three weaknesses above. | *Freeze / Thaw* migration | the decided Baton *freezes* on the brain and *thaws* on the token-bearing farm node — replacing "write JSONL, farm polls" with a real continuation hand-off -| the cron/tick + the dead farm Actions billing +| the cron/tick and the historical farm billing incident | self-hosted *BEAM mesh + Guix builds* | runs the loop *off* GitHub-hosted minutes — which is why it bears on the billing question |=== @@ -119,23 +127,24 @@ Bag.Mesh.submit(%{ this is the token-free-brain invariant expressed as a capability fact, proved in `Bag.Estate`). -== What it buys over JSONL + poll +== Proposed benefits and obligations -* *Type-level no-double-merge* (Ephapax) instead of a lock + a `MapSet`. +* *Linear resource handling* (Ephapax) is a proposed local guarantee. It does not + by itself prove that an external GitHub effect occurred exactly once. * *Capability-routed actuation* — no hard-coded "the farm polls a file"; the Baton finds the token-holder. -* *Resumable* — a crashed actuation thaws and retries on another capable node. -* *Self-hosted* — runs on the BEAM mesh, off GitHub Actions minutes; this is the - path that makes the farm billing fix *moot* rather than a workaround. (Owner - has deferred the billing fix itself; this is the longer arc it sits on.) +* *Resumable* — migration after a crash would require durable intent and + read-only reconciliation of uncertain remote effects before any retry. +* *Self-hosted* — a qualified mesh could use capacity outside GitHub-hosted + minutes. That changes deployment costs, not the authorization/evidence gates. == Honest caveats * BoA is research-grade (Milestones 1–4, no release artifacts). This is a *direction*, not a drop-in. -* The integration is *additive*: the `merge-decisions.jsonl` manifest remains - the record and the fallback; the actuator's independent re-verification stays - exactly as-is (defense in depth holds whether delivery is poll or Baton). +* A future integration must preserve the complete decision subject, event, + independent re-verification and durable ledger. The former prototype did not + establish those properties; the current emitter refuses mutation. * Building it needs: (a) BoA added to session scope to read the real Baton format + `Bag.Mesh` surface; (b) `BatonEmitter` on the hypatia side; (c) the `merge_pr` action + capability facts on the BoA side. diff --git a/docs/design/merge-orchestration/06-observation-and-actuation-provenance.adoc b/docs/design/merge-orchestration/06-observation-and-actuation-provenance.adoc new file mode 100644 index 00000000..1adc724a --- /dev/null +++ b/docs/design/merge-orchestration/06-observation-and-actuation-provenance.adoc @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Observation provenance and the execution boundary + +== Current boundary + +The merge model computes policy choices. Its confidence, per-repository lease, +and a document containing a signature are not execution authorization. The +production Sensor has no qualified signature/trust verifier. All Sensor-fed +approvals are therefore capped at `review`; incomplete observations are +non-executable. Monotone veto, meta, license, hold, draft and pool restrictions +remain at least as conservative. Direct Strategist and Dispatcher callers also +default to review when `execution_cap` is absent or malformed. Tests of an +uncapped policy model must supply `execution_cap: :arm_auto` explicitly; that +input is an assumption, not a receipt from a live verified bot. + +The old Baton spec directly emitted `gh pr merge` and carried a descriptive +`verifier` map. Nothing in this producer called the named farm gate, enforced +its durable ledger, or established the downstream executor's verification +contract. `BatonEmitter.to_spec/2` now returns +`{:error, :qualified_merge_executor_unavailable}`; `emit/2` reports refusals and +never calls a submission function. No alternate backend silently bypasses the +farm. This is containment, not a claim that the proposed mesh integration is +qualified or that signature verification has been implemented. + +`BatonEmitter.availability/0` reports the same unavailable executor error. The +Mix command checks availability before starting a cycle: `--actuation baton` +and `--actuation both` fail with a nonzero error even when there are no armed +candidates. They submit nothing and leave an existing manifest unchanged; +`both` cannot claim partial completion by writing a manifest first. +`--actuation manifest` remains available. The pure Loop API's refusal records +are descriptive results and do not establish execution or a successful merge. + +== Qualified observation batches + +The paired `.git-private-farm` producer paginates PRs and files, compares file +counts against `changed_files`, and checks the PR/head population around the +collection. An observation includes a full head and base SHA, explicit event, +timestamp, state, file paths, labels and a complete collection marker. Renamed +files retain both paths so moving a workflow or licence does not hide it. + +The producer publishes `observations/.observation-batch.json` with schema version +1, status, event, timestamp, requested repository scope and exact members. Each +member binds filename, repository, PR number, head and SHA-256 of the record. +`collecting` precedes any record replacement; `complete` is published last. +Failure leaves the batch unqualified. Unlisted records remain historical. + +`Loop.run/1` requires the complete receipt. It checks the scope, safe member +names, hashes, full subject identities and matching event/timestamp; it reads the +receipt again after the files. Missing, malformed, failed, partial, changed or +symlinked inputs cannot become a successful empty cycle. No new manifest is +published on that error. A complete empty batch is accepted only for a nonempty +qualified scope. Manifest replacement is atomic after successful collection. + +Old standalone observations and abbreviated-head fixtures are retained for +manual examination and negative controls. They are never upgraded by guessing +the current live head. The change requires producer/reader rollout together; +older stores without a batch receipt fail qualification until the authorized +observer publishes one. + +== Subject and event continuity + +The store attestation resolver accepts `(repo, PR number, full head SHA)`. +`Sensor.to_context/3` independently applies the same check even to an injected +resolver. An approval for an old revision, an unbound document, a different PR +or a different repository cannot support the current decision. The subject is +retained in the council input and its authenticity remains explicitly +`unverified`; an embedded `seal` or `verified` field cannot promote it. + +Sensor, Strategist and Dispatcher preserve the observed `head_sha` and +`event_id`. The dispatcher independently downgrades a model arm without complete +observation provenance to review. SHA, event and batch-digest validators match +the entire string; a final newline is not part of an admissible identifier. +Pool strings use the farm's canonical +`P0`/`P1`/`P2`/`P3`/`mass_squash` vocabulary rather than serializing lower-case +Elixir atom names. This adapter does not fetch or substitute a newer head. + +The paired farm actuator remains unwired and dry-run by default. An explicitly +authorized future write would require matching live identity/head, real required +checks and owner-held pool gates. It holds the durable-ledger lock across checks, +an fsynced intent, the request and reconciliation. Unknown or queued effects +are not retried blindly; read-only reconciliation records `observed_merged`, +not a newly applied effect. Missing/corrupt/legacy ledger state is a blocker, +not permission to create a fresh history. See the farm's +`docs/operations/merge-provenance.adoc` for the matching effect contract. + +== Status and qualification + +A completed decision cycle is not a merge and is not a learning outcome. The +merge ticker records `last_outcome: :failed` and clears `last_stats` when a later +read fails, so prior successful statistics cannot appear to describe that failed +attempt. The learning subsystem must still demand actual repository/revision/ +recipe/run-bound effect evidence; this change does not write learning records. + +Focused tests exercise exact-head attestation selection, malformed/legacy +observations, forged verification claims, complete/failed/changed receipts, +digest and subject contradictions, missing and symlinked members, stable +metadata across serialization, stronger veto/meta guards, and refusal to submit +a Baton. Additional controls exercise missing/invalid execution caps, trailing +newline identifiers, all three direct Dispatcher APIs, and the actual Mix task +argument path for manifest output and refused Baton modes. Policy-model +threshold tests and the independent KinGate tests remain separate from +production authenticity claims. Test source is not an execution receipt; +qualification results must identify the tested revision. + +No live GitHub merge, store publication, credential repair, scheduler enablement +or signature/trust implementation is established by these offline tests. diff --git a/lib/fleet_dispatcher.ex b/lib/fleet_dispatcher.ex index 6e9169dc..8eb56f58 100644 --- a/lib/fleet_dispatcher.ex +++ b/lib/fleet_dispatcher.ex @@ -654,6 +654,28 @@ defmodule Hypatia.FleetDispatcher do execute_graphql(mutation, "robot-repo-automaton") end + # :queued records a volatile source-bound submission, never completed learning. + @doc false + def record_neural_outcome(finding, outcome, server \\ Hypatia.Neural.Coordinator) do + case Hypatia.Neural.Coordinator.record_outcome(finding, outcome, server) do + :ok -> :queued + {:error, reason} = error -> + Logger.warning("Neural outcome not queued: #{inspect(reason)}") + error + other -> + Logger.warning("Unexpected neural outcome queue reply: #{inspect(other)}") + {:error, :unexpected_neural_reply} + end + rescue + error -> + Logger.warning("Neural outcome queue failed: #{Exception.message(error)}") + {:error, :neural_unavailable} + catch + :exit, reason -> + Logger.warning("Neural coordinator unavailable for outcome recording: #{inspect(reason)}") + {:error, :neural_unavailable} + end + defp ingest_fix_outcome(finding) do pattern = finding_field(finding, :pattern, "unknown") success = finding_field(finding, :success, false) @@ -668,11 +690,7 @@ defmodule Hypatia.FleetDispatcher do # The coordinator updates MoE, LSM, and ESN networks with this data. outcome_str = if success, do: "success", else: "failure" - try do - Hypatia.Neural.Coordinator.record_outcome(finding, outcome_str) - catch - :exit, _ -> Logger.warning("Neural coordinator unavailable for outcome recording") - end + record_neural_outcome(finding, outcome_str) observation = %{ type: pattern, diff --git a/lib/hypatia/diagnostics/monitor.ex b/lib/hypatia/diagnostics/monitor.ex index f759f2e9..8117cf0e 100644 --- a/lib/hypatia/diagnostics/monitor.ex +++ b/lib/hypatia/diagnostics/monitor.ex @@ -86,16 +86,9 @@ defmodule Hypatia.Diagnostics.Monitor do defp do_health_check(state) do try do components = check_all_components() - failures = detect_failures(components) - + observed = record_observations(state, components) schedule_checks() - - if failures != [] do - notify_failures(failures) - attempt_recoveries(failures, state) - else - %{state | components: components, failures: [], last_check: DateTime.utc_now()} - end + finish_observations(observed, ¬ify_failures/1, &attempt_recoveries/2) rescue error -> Logger.error("Diagnostics monitor error: #{inspect(error)}") @@ -154,21 +147,33 @@ defmodule Hypatia.Diagnostics.Monitor do # Probe the neural coordinator without treating a long-running training # cycle as a crash. Other exits and exceptions report a failed health check. - defp check_neural() do + @doc false + def check_neural(server \\ Hypatia.Neural.Coordinator, timeout \\ 1000) do try do - case GenServer.call(Hypatia.Neural.Coordinator, :status, 1000) do - nil -> {:error, :neural_unresponsive} - status -> {:ok, {:neural, status}} - end + source = Hypatia.Neural.SourceStore.current() + status = GenServer.call(server, {:status, source}, timeout) + classify_neural_status(status) rescue _ -> {:error, :neural_crashed} catch - # Training cycles can take minutes -- a timeout means busy, not crashed - :exit, {:timeout, _} -> {:ok, {:neural, :training_in_progress}} + # A busy process has not supplied a current-source health receipt. + # Report uncertainty without granting automatic restart authority. + :exit, {:timeout, _} -> {:error, {:neural_source_unavailable, :busy_or_unverified}} :exit, _ -> {:error, :neural_crashed} end end + @doc false + def classify_neural_status({:ok, status}) do + if Hypatia.Neural.SourceStore.ready_report?(status) and Map.get(status, :status) == :running do + {:ok, {:neural, status}} + else + {:error, {:neural_source_unavailable, status}} + end + end + + def classify_neural_status(other), do: {:error, {:neural_source_unavailable, other}} + defp check_kin() do try do case GenServer.call(Hypatia.Kin.Coordinator, :ecosystem_health, 1000) do @@ -188,31 +193,69 @@ defmodule Hypatia.Diagnostics.Monitor do # Failure Detection # ==================================================================== - defp detect_failures(components) do - Enum.filter(components, fn + @doc false + def detect_failures(components) do + Enum.flat_map(components, fn # Hard failures {:error, reason} -> - {:failure, reason} + [{:failure, reason}] # Performance issues {:ok, {_, metrics}} when is_map(metrics) -> cond do Map.get(metrics, :dropped, 0) > Map.get(metrics, :dispatched, 1) * @error_rate_threshold -> - {:failure, :high_error_rate} + [{:failure, :high_error_rate}] Map.get(metrics, :buffered, 0) > Map.get(metrics, :max_buffer, 1) * @buffer_warning_threshold -> - {:failure, :buffer_overflow} + [{:failure, :buffer_overflow}] true -> - nil + [] end _ -> - nil + [] end) end + @doc false + def record_observations(state, components) do + %{state | components: components, failures: detect_failures(components), last_check: DateTime.utc_now()} + end + + # Callbacks are explicit so regression tests can exercise notification + # failure without contacting channels or invoking any recovery action. + @doc false + def finish_observations(observed, notify, recover) do + if observed.failures == [] do + observed + else + try do + notify.(observed.failures) + + recoverable = + Enum.reject(observed.failures, fn {:failure, reason} -> + source_lifecycle_failure?(reason) + end) + + if recoverable == [], do: observed, else: recover.(recoverable, observed) + rescue + error -> + Logger.error("Diagnostics notification/recovery failed: #{inspect(error)}") + observed + catch + :exit, reason -> + Logger.error("Diagnostics notification/recovery exited: #{inspect(reason)}") + observed + end + end + end + + @doc false + def source_lifecycle_failure?({:neural_source_unavailable, _}), do: true + def source_lifecycle_failure?(_), do: false + # ==================================================================== # Failure Notification # ==================================================================== @@ -225,8 +268,8 @@ defmodule Hypatia.Diagnostics.Monitor do # Log for persistence Logger.error("DIAGNOSTICS ALERT: #{inspect(reason)}") - # Attempt recovery - attempt_recovery(reason) + # Notification has no recovery side effect. finish_observations filters + # source refusals before the sole attempt_recoveries path. end) end @@ -350,14 +393,17 @@ defmodule Hypatia.Diagnostics.Monitor do Logger.info("Attempting neural coordinator recovery...") try do - case GenServer.call(Hypatia.Neural.Coordinator, :health_report, 2000) do - %{} -> - Logger.info("Neural coordinator responding -- healthy") - :ok - - _ -> - Logger.warning("Neural coordinator returned unexpected status") - :ok + source = Hypatia.Neural.SourceStore.current() + report = GenServer.call(Hypatia.Neural.Coordinator, {:health_report, source}, 2000) + + if Hypatia.Neural.SourceStore.ready_report?(report) do + Logger.info("Neural coordinator responding with its current source") + :ok + else + # A lifecycle refusal requires an explicit operator decision. Do not + # restart or relabel the cohort to turn that refusal into availability. + Logger.warning("Neural recovery refused; inspect source lifecycle: #{inspect(report)}") + :error end catch :exit, _ -> @@ -385,18 +431,22 @@ defmodule Hypatia.Diagnostics.Monitor do defp attempt_recoveries(failures, state) do Enum.reduce(failures, state, fn {:failure, reason}, acc -> - _recovery_result = attempt_recovery(reason) + if source_lifecycle_failure?(reason) do + acc + else + _recovery_result = attempt_recovery(reason) - # Track recovery attempts - attempt_count = Map.get(acc.recovery_attempts, reason, 0) + 1 - attempts = Map.put(acc.recovery_attempts, reason, attempt_count) + # Preserve the existing recovery authority and accounting for other + # failure types; source lifecycle refusals never enter this branch. + attempt_count = Map.get(acc.recovery_attempts, reason, 0) + 1 + attempts = Map.put(acc.recovery_attempts, reason, attempt_count) - # Give up after max attempts - if attempt_count >= @max_recovery_attempts do - Logger.warning("Max recovery attempts reached for: #{inspect(reason)}") - end + if attempt_count >= @max_recovery_attempts do + Logger.warning("Max recovery attempts reached for: #{inspect(reason)}") + end - %{acc | recovery_attempts: attempts} + %{acc | recovery_attempts: attempts} + end end) end end diff --git a/lib/hypatia/watcher/anomaly_detector.ex b/lib/hypatia/watcher/anomaly_detector.ex index 720fa355..e6e40069 100644 --- a/lib/hypatia/watcher/anomaly_detector.ex +++ b/lib/hypatia/watcher/anomaly_detector.ex @@ -177,12 +177,13 @@ defmodule Hypatia.Watcher.AnomalyDetector do _ -> try do report = Hypatia.Neural.Coordinator.health_report() + report = if Hypatia.Neural.SourceStore.ready_report?(report), do: report, else: %{} drift = get_in(report, [:esn, :drift]) trained? = get_in(report, [:esn, :trained]) accuracy = get_in(report, [:esn, :accuracy]) cond do - not trained? -> + trained? != true -> state drift in [:rising_drift, :falling_drift] -> @@ -285,6 +286,16 @@ defmodule Hypatia.Watcher.AnomalyDetector do # ─── ESN signal (optional, soft) ─────────────────────────────────────── + @doc false + def source_esn_signal(report) do + if Hypatia.Neural.SourceStore.ready_report?(report) and get_in(report, [:esn, :trained]) == true do + drift = get_in(report, [:esn, :drift]) || :unavailable + {drift, drift in [:rising_drift, :falling_drift]} + else + {:unavailable, false} + end + end + defp esn_signal do case Process.whereis(Hypatia.Neural.Coordinator) do nil -> @@ -292,9 +303,7 @@ defmodule Hypatia.Watcher.AnomalyDetector do _ -> try do - report = Hypatia.Neural.Coordinator.health_report() - state = get_in(report, [:esn, :drift]) || :unavailable - {state, state in [:rising_drift, :falling_drift]} + Hypatia.Neural.Coordinator.health_report() |> source_esn_signal() rescue _ -> {:unavailable, false} catch diff --git a/lib/kin/gate.ex b/lib/kin/gate.ex index 69290773..5a9db7c3 100644 --- a/lib/kin/gate.ex +++ b/lib/kin/gate.ex @@ -13,7 +13,7 @@ defmodule Hypatia.Kin.Gate do - **Conflict detection**: blocks fixes that contradict pending fixes - **Confidence floor**: enforces minimum confidence for auto-execute tier - **Rate guard**: prevents flood of PRs to a single repo - - **Neural consensus**: if available, checks neural network agreement on severity + - **Neural consensus**: auto-execute requires a current-source neural assessment ## Decision outcomes @@ -39,6 +39,7 @@ defmodule Hypatia.Kin.Gate do @lock_timeout_ms 30 * 60 * 1_000 @stale_scan_threshold_hours 48 @min_auto_execute_confidence 0.95 + @neural_consensus_timeout_ms 5_000 @max_prs_per_repo_per_hour 3 @held_file "held.jsonl" @rejected_file "rejected.jsonl" @@ -427,57 +428,60 @@ defmodule Hypatia.Kin.Gate do end end - # Check 6: If neural networks are available, check consensus - defp check_neural_consensus(action, _state) do + # Keep the default predictor's wait below review/1's caller deadline. + # This bounds waiting; it does not cancel work already executing in the Coordinator. + defp bounded_recommendation(finding) do + Hypatia.Neural.Coordinator.dispatch_recommendation( + finding, + Hypatia.Neural.Coordinator, + @neural_consensus_timeout_ms + ) + end + + # Check 6: unavailable or refused predictions cannot count as agreement. + defp check_neural_consensus(action, _state), do: neural_consensus(action) + + @doc false + def neural_consensus(action, predictor \\ &bounded_recommendation/1) do if action.dispatch_tier == :auto_execute do - case check_neural_available() do - # neural layer not available, don't block - false -> - :pass - - true -> - # Ask the neural coordinator for a confidence assessment - case neural_agrees?(action) do - true -> - :pass + try do + finding = %{"id" => action.pattern_id, "category" => "unknown"} - false -> - {:hold, - "neural network disagrees with dispatch confidence -- human review recommended"} - end - end - else - :pass - end - end + case predictor.(finding) do + %{confidence: neural_conf} = recommendation + when is_number(neural_conf) and neural_conf >= 0.0 and neural_conf <= 1.0 -> + cond do + not Hypatia.Neural.SourceStore.ready_report?(recommendation) -> + neural_unavailable_hold() - defp check_neural_available do - case Process.whereis(Hypatia.Neural.Coordinator) do - nil -> false - pid -> Process.alive?(pid) - end - end + Map.get(recommendation, :strategy) != :auto_execute or + Map.get(recommendation, :is_novel) != false -> + {:hold, "neural assessment requires manual review -- automation cap retained"} - defp neural_agrees?(action) do - try do - finding = %{"id" => action.pattern_id, "category" => "unknown"} + abs(neural_conf - Map.get(action, :confidence, 0.0)) < 0.15 -> + :pass - case Hypatia.Neural.Coordinator.dispatch_recommendation(finding) do - %{confidence: neural_conf} -> - # Neural agrees if its confidence is within 0.15 of the recipe confidence - abs(neural_conf - Map.get(action, :confidence, 0.0)) < 0.15 + true -> + {:hold, "neural network disagrees with dispatch confidence -- human review recommended"} + end - # if prediction fails, don't block - _ -> - true + _ -> + neural_unavailable_hold() + end + rescue + _ -> neural_unavailable_hold() + catch + :exit, _ -> neural_unavailable_hold() end - rescue - _ -> true - catch - :exit, _ -> true + else + :pass end end + defp neural_unavailable_hold do + {:hold, "current-source neural assessment unavailable -- human review required"} + end + # --- Helpers --- defp acquire_lock(state, action) do diff --git a/lib/learning_scheduler.ex b/lib/learning_scheduler.ex index 0297442e..6bf74ada 100644 --- a/lib/learning_scheduler.ex +++ b/lib/learning_scheduler.ex @@ -30,17 +30,6 @@ defmodule Hypatia.LearningScheduler do # pins. @action_sha_verify_interval_seconds 24 * 60 * 60 @action_sha_cache_path "data/verified-action-shas.json" - @verisimdb_data_path Application.compile_env(:hypatia, :verisimdb_data_path, "data/verisim") - @fleet_path Application.compile_env( - :hypatia, - :fleet_path, - "~/Documents/hyperpolymath-repos/gitbot-fleet" - ) - @annealing_state_path Application.compile_env( - :hypatia, - :annealing_state_path, - "data/verisim/annealing-states" - ) # --- Client API --- @@ -64,7 +53,7 @@ defmodule Hypatia.LearningScheduler do def init(_opts) do state = %{ last_run: nil, - # filename => byte offset of last read + # filename => source-qualified position of the last complete read last_outcome_positions: %{}, pending_confidence_recipes: MapSet.new(), # A restarted scheduler rechecks persisted source IDs, not just this cache. @@ -322,19 +311,28 @@ defmodule Hypatia.LearningScheduler do # runs inside the Coordinator process); we only confirm the process is # alive. Non-fatal: the scheduler must keep running even if the neural # layer is down. - defp trigger_neural_cycle do - case Process.whereis(Hypatia.Neural.Coordinator) do - nil -> + @doc false + def trigger_neural_cycle(server \\ Hypatia.Neural.Coordinator) do + case Hypatia.Neural.Coordinator.force_cycle(server) do + :ok -> + true + + {:error, reason} -> + Logger.warning("LearningScheduler: neural cycle not queued: #{inspect(reason)}") false - _pid -> - Hypatia.Neural.Coordinator.force_cycle() - true + other -> + Logger.warning("LearningScheduler: unexpected neural queue reply: #{inspect(other)}") + false end rescue e -> Logger.warning("LearningScheduler: neural cycle trigger failed: #{inspect(e)}") false + catch + :exit, reason -> + Logger.warning("LearningScheduler: neural coordinator unavailable: #{inspect(reason)}") + false end # --- M18: Cross-org peer polling ------------------------------------- @@ -529,9 +527,9 @@ defmodule Hypatia.LearningScheduler do |> Base.encode16(case: :lower) end - # Normalise a persisted position entry. Pre-E0 positions were bare - # integers (byte offsets). Post-E0 positions are - # `%{offset: int, incomplete_tail: binary}`. Anything else resets. + # Normalise the offset/tail view of a position entry. Legacy entries + # may be bare integers or maps without a source_path; ingestion must + # separately qualify their source before reusing an offset. @doc false def normalise_position(pos) when is_integer(pos) and pos >= 0, do: %{offset: pos, incomplete_tail: ""} @@ -542,9 +540,18 @@ defmodule Hypatia.LearningScheduler do def normalise_position(_), do: %{offset: 0, incomplete_tail: ""} + # Keep filename keys compatible with existing state, but never reuse + # an offset from another runtime store. Source-less legacy cursors + # conservatively replay once rather than skip an unidentified source. + defp position_for_source(%{source_path: source_path} = position, path) + when source_path == path, + do: normalise_position(position) + + defp position_for_source(_position, _path), do: normalise_position(0) + @doc false def ingest_new_outcomes(last_positions) do - outcomes_dir = Path.join(Path.expand(@verisimdb_data_path), "outcomes") + outcomes_dir = Hypatia.Paths.outcomes() |> Path.expand() case File.ls(outcomes_dir) do {:ok, files} -> @@ -553,7 +560,7 @@ defmodule Hypatia.LearningScheduler do Enum.reduce(jsonl_files, {last_positions, 0, MapSet.new()}, fn file, {positions, count, recipes} -> path = Path.join(outcomes_dir, file) - pos = normalise_position(Map.get(positions, file, 0)) + pos = position_for_source(Map.get(positions, file), path) case File.stat(path) do {:ok, %{size: size}} when size < pos.offset -> @@ -563,59 +570,25 @@ defmodule Hypatia.LearningScheduler do "(size #{size} < offset #{pos.offset}), resetting" ) - {Map.put(positions, file, %{offset: 0, incomplete_tail: ""}), count, recipes} + # The stat already invalidated the old cursor. Persist the + # path-bound reset in the accumulator before attempting I/O, + # so a failed read cannot restore that cursor and skip a + # replacement prefix once the file grows beyond its old size. + reset = %{offset: 0, incomplete_tail: "", source_path: path} + + ingest_local_outcome_file( + path, + file, + reset, + {Map.put(positions, file, reset), count, recipes} + ) {:ok, %{size: size}} when size == pos.offset and pos.incomplete_tail == "" -> # No new bytes and nothing buffered — nothing to do. {positions, count, recipes} {:ok, _stat} -> - case File.open(path, [:read, :binary]) do - {:ok, fd} -> - :file.position(fd, pos.offset) - raw = IO.read(fd, :eof) - File.close(fd) - - # Prepend any previously buffered incomplete tail so a - # producer that finishes writing between polls is read - # as one complete record, not a suffix fragment. - full_data = pos.incomplete_tail <> to_string(raw) - - # Split with trim: false — the last element after - # splitting "a\nb\n" is "", and after "a\nb" is "b" - # (the incomplete tail we must not yet acknowledge). - lines = String.split(full_data, "\n", trim: false) - - {complete_lines, incomplete_tail} = - case List.last(lines) do - nil -> {[], ""} - "" -> {Enum.drop(lines, -1), ""} - tail -> {Enum.drop(lines, -1), tail} - end - - # Advance past every complete line. The incomplete tail - # stays at the old offset so it is re-read next poll. - new_offset = pos.offset + byte_size(full_data) - byte_size(incomplete_tail) - - {new_count, new_recipes, quarantined} = - process_local_complete_lines(complete_lines, file, pos.offset, pos.incomplete_tail) - - if quarantined > 0 do - Logger.warning( - "LearningScheduler: quarantined #{quarantined} malformed outcome row(s) in #{file}" - ) - end - - {Map.put(positions, file, %{offset: new_offset, incomplete_tail: incomplete_tail}), - count + new_count, MapSet.union(recipes, new_recipes)} - - {:error, reason} -> - Logger.warning( - "LearningScheduler: cannot read outcome file #{file}: #{inspect(reason)}" - ) - - {positions, count, recipes} - end + ingest_local_outcome_file(path, file, pos, {positions, count, recipes}) {:error, reason} -> Logger.warning( @@ -631,25 +604,72 @@ defmodule Hypatia.LearningScheduler do {last_positions, 0, MapSet.new()} {:error, reason} -> - Logger.warning( - "LearningScheduler: cannot list outcomes directory: #{inspect(reason)}" - ) + Logger.warning("LearningScheduler: cannot list outcomes directory: #{inspect(reason)}") {last_positions, 0, MapSet.new()} end end + defp ingest_local_outcome_file(path, file, pos, {positions, count, recipes} = acc) do + read_result = + File.open(path, [:read, :binary], fn fd -> + with {:ok, _offset} <- :file.position(fd, pos.offset) do + IO.binread(fd, :eof) + end + end) + + case read_result do + {:ok, raw} when is_binary(raw) or raw == :eof -> + # The offset is the start of the incomplete tail, not the end of + # the previous read. Re-read those bytes from the file exactly once; + # prepending the saved tail would duplicate a partially written row. + full_data = if raw == :eof, do: "", else: raw + lines = String.split(full_data, "\n", trim: false) + incomplete_tail = List.last(lines) + complete_lines = Enum.drop(lines, -1) + + # Acknowledge only newline-terminated bytes. Retain the trailing + # fragment as diagnostic state without moving the read position. + new_offset = pos.offset + byte_size(full_data) - byte_size(incomplete_tail) + + {new_count, new_recipes, quarantined} = + process_local_complete_lines(complete_lines, path, file, pos.offset) + + if quarantined > 0 do + Logger.warning( + "LearningScheduler: quarantined #{quarantined} malformed outcome row(s) in #{file}" + ) + end + + new_position = %{ + offset: new_offset, + incomplete_tail: incomplete_tail, + source_path: path + } + + {Map.put(positions, file, new_position), count + new_count, + MapSet.union(recipes, new_recipes)} + + {:ok, {:error, reason}} -> + local_read_failed(file, reason, acc) + + {:error, reason} -> + local_read_failed(file, reason, acc) + end + end + + defp local_read_failed(file, reason, acc) do + Logger.warning("LearningScheduler: cannot read outcome file #{file}: #{inspect(reason)}") + acc + end + # Process a batch of complete JSONL lines from a local outcome file. - # `base_offset` is the byte offset of the first line in the batch - # (after prepending any buffered incomplete tail); `base_tail` is the - # text prepended so line-number accounting matches the physical file. + # `base_offset` is the byte offset of the first line in the batch. # Returns `{local_count, recipe_ids, quarantined_count}`. - defp process_local_complete_lines(complete_lines, file, base_offset, base_tail) do - # The physical line number of the first line in this batch. We count - # newlines before base_offset to derive it, but only when the base - # offset is zero (cold start) or the buffer is empty. For mid-file - # batches we use a monotone counter seeded from the prior run. - start_line = local_line_start(file, base_offset, base_tail) + defp process_local_complete_lines(complete_lines, path, file, base_offset) do + # Use the same resolved file as the read, including after a runtime + # data-directory change, so diagnostic line numbers refer to that file. + start_line = local_line_start(path, base_offset) complete_lines |> Enum.with_index(start_line) @@ -693,15 +713,12 @@ defmodule Hypatia.LearningScheduler do end # Compute the physical line number for a batch starting at - # `base_offset`. On a cold start (offset 0, no buffered tail) this is 1. + # `base_offset`. On a cold start (offset 0) this is 1. # Otherwise count newlines in the file prefix to find the first line # that begins at `base_offset`. - defp local_line_start(_file, 0, ""), do: 1 - - defp local_line_start(file, base_offset, base_tail) do - outcomes_dir = Path.join(Path.expand(@verisimdb_data_path), "outcomes") - path = Path.join(outcomes_dir, file) + defp local_line_start(_path, 0), do: 1 + defp local_line_start(path, base_offset) do case File.open(path, [:read, :binary]) do {:ok, fd} -> prefix = @@ -723,16 +740,7 @@ defmodule Hypatia.LearningScheduler do |> Kernel.-(1) |> max(0) - # The buffered tail may also contain embedded newlines from a - # prior incomplete read that has since grown. - tail_newlines = - base_tail - |> String.split("\n") - |> length() - |> Kernel.-(1) - |> max(0) - - prefix_newlines + tail_newlines + 1 + prefix_newlines + 1 {:error, _} -> 1 @@ -757,19 +765,31 @@ defmodule Hypatia.LearningScheduler do classified = case {verdict, Map.get(record, "outcome")} do - {"verified", "success"} when is_integer(before_hits) and before_hits > 0 and - after_hits == 0 -> {:success, "verified"} + {"verified", "success"} + when is_integer(before_hits) and before_hits > 0 and + after_hits == 0 -> + {:success, "verified"} + {"failed", "failure"} when is_integer(exit_code) and exit_code != 0 -> {:failure, "failed"} + {"incomplete", "failure"} when is_integer(after_hits) and after_hits > 0 -> {:failure, "incomplete"} - _ -> :unqualified + + _ -> + :unqualified end cond do - Map.get(record, "bot") == "hypatia" -> :skip - not (is_binary(recipe_id) and recipe_id != "" and is_binary(repo) and repo != "") -> :skip - classified == :unqualified -> :skip + Map.get(record, "bot") == "hypatia" -> + :skip + + not (is_binary(recipe_id) and recipe_id != "" and is_binary(repo) and repo != "") -> + :skip + + classified == :unqualified -> + :skip + true -> {outcome, verification} = classified {:ok, recipe_id, repo, file, outcome, verification} @@ -786,16 +806,28 @@ defmodule Hypatia.LearningScheduler do |> Base.encode16(case: :lower) end - defp ingest_fleet_outcomes(previous_signature) do + @doc false + def ingest_fleet_outcomes(previous_signature) do fleet_file = Path.join([ - Path.expand(@fleet_path), + Path.expand(Hypatia.Paths.fleet()), "shared-context/learning/fix-outcomes.jsonl" ]) case File.read(fleet_file) do {:ok, content} -> - signature = :crypto.hash(:sha256, content) + # Identical source bytes still need importing into a newly selected + # destination. Persisted source IDs, not this cache, prevent replay. + signature = + :crypto.hash( + :sha256, + :erlang.term_to_binary({ + :fleet_import_cache_v2, + fleet_file, + Path.expand(Hypatia.Paths.outcomes()), + :crypto.hash(:sha256, content) + }) + ) if signature == previous_signature do {0, signature} @@ -803,10 +835,14 @@ defmodule Hypatia.LearningScheduler do case Hypatia.OutcomeTracker.imported_fleet_ids() do {:ok, known_ids} -> case import_fleet_lines(content, known_ids) do - {:ok, imported} -> {imported, signature} + {:ok, imported} -> + {imported, signature} {:error, reason} -> - Logger.warning("LearningScheduler: fleet import not acknowledged: #{inspect(reason)}") + Logger.warning( + "LearningScheduler: fleet import not acknowledged: #{inspect(reason)}" + ) + {0, previous_signature} end @@ -845,7 +881,12 @@ defmodule Hypatia.LearningScheduler do {:cont, {:ok, seen, count}} else case Hypatia.OutcomeTracker.record_fleet_outcome( - recipe_id, repo, file, outcome, id, verification + recipe_id, + repo, + file, + outcome, + id, + verification ) do {:ok, _record} -> {:cont, {:ok, MapSet.put(seen, id), count + 1}} @@ -866,7 +907,10 @@ defmodule Hypatia.LearningScheduler do else # A permanently malformed historical row must not starve later # valid events. It remains in the append-only source as evidence. - Logger.warning("LearningScheduler: quarantined malformed fleet row #{line_number}: #{inspect(reason)}") + Logger.warning( + "LearningScheduler: quarantined malformed fleet row #{line_number}: #{inspect(reason)}" + ) + {:cont, {:ok, seen, count}} end end @@ -890,7 +934,10 @@ defmodule Hypatia.LearningScheduler do {count, pending} error -> - Logger.warning("LearningScheduler: confidence update deferred for #{recipe_id}: #{inspect(error)}") + Logger.warning( + "LearningScheduler: confidence update deferred for #{recipe_id}: #{inspect(error)}" + ) + {count, MapSet.put(pending, recipe_id)} end end) @@ -899,7 +946,7 @@ defmodule Hypatia.LearningScheduler do # --- Report any recipes with concerning confidence drift --- defp report_confidence_drift do - recipes_dir = Path.join(Path.expand(@verisimdb_data_path), "recipes") + recipes_dir = Hypatia.Paths.recipes() |> Path.expand() case File.ls(recipes_dir) do {:ok, files} -> @@ -940,8 +987,9 @@ defmodule Hypatia.LearningScheduler do # Scans all persisted annealing state files and produces a summary # of stage distribution and drift/reheat counts. - defp report_annealing_state do - annealing_dir = Path.expand(@annealing_state_path) + @doc false + def report_annealing_state do + annealing_dir = Path.expand(Hypatia.Paths.annealing_states()) stage_counts = %{nascent: 0, adolescent: 0, mature: 0, veteran: 0} reheated = 0 diff --git a/lib/merge_orchestration/baton_emitter.ex b/lib/merge_orchestration/baton_emitter.ex index 0e76db0a..dcc82f7b 100644 --- a/lib/merge_orchestration/baton_emitter.ex +++ b/lib/merge_orchestration/baton_emitter.ex @@ -2,95 +2,29 @@ # Copyright (c) 2026 Jonathan D.A. Jewell defmodule Hypatia.MergeOrchestration.BatonEmitter do @moduledoc """ - Emit armed merge decisions onto the Bag-of-Actions mesh as Batons. + Containment boundary for the proposed Bag-of-Actions merge backend. - The alternative actuation backend to `merge-decisions.jsonl` (artifact-7 design, - `docs/design/merge-orchestration/05-execution-substrate-bag-of-actions.adoc`), - built against `bag-of-actions`' real API: `Bag.Mesh.submit_planned/2` takes a - spec `%{check_id, command, required_cap, mutating?, risk?}`, the planner routes - it to the cheapest capable node, **gates `mutating: true` work on a verifier** - (that is the independent re-verification), runs the `command` there, and returns - a `Bag.ActionResult` — with zero GitHub Actions minutes. + The former spec emitted `gh pr merge` directly. Its `verifier` map only named + the farm's gate: this module never executed that gate, bound the assessed head, + or used its durable intent/uncertainty ledger. A non-nil map is not evidence of + independent verification. The downstream executor is not qualified here. - A merge is just a `command` (`gh pr merge …`) carrying `required_cap: - "secret-access"` — the capability only `mesh-github-runner` holds in the estate - `nodes.scm` (the token-bearer). The brain's node lacks it, so the Baton cannot - execute on the brain and must migrate to the runner: the token-free-brain - invariant expressed as capability routing. `mutating: true` makes the planner - gate the merge on a verifier — defence in depth, the same posture as the - `.git-private-farm` actuator's `decide_action`. - - `to_spec/2` is pure (data in, data out). `emit/2` submits one Baton per *armed* - entry of a `Loop` result; the `Bag.Mesh` call is late-bound (`apply/3`) and - injectable, so this stays compile-decoupled from `bag-of-actions` and the logic - tests run without it. + Until that actual contract exists, `to_spec/2` returns an explicit error and + `emit/2` records refusals without invoking a submit function. The manifest + backend remains available, dry-run farm actuation stays separate, and no + alternate executor can silently bypass its checks. """ - # the capability `mesh-github-runner` holds in bag-of-actions' nodes.scm — i.e. - # "can run gh with a GitHub token". Only that node has it; the brain does not. - # - # The string MUST match the capability tag the bag-of-actions Zig bridge parses - # (`estate.Capability.fromString`) and the Elixir `Bag.Planner` matches on: - # `"secret_access"` (underscore). An unknown tag is treated as unprovable and - # NEVER satisfied (fail-closed), so a hyphenated `"secret-access"` would route - # to *no* node and the merge would silently suspend — verified against - # bag-of-actions `src/main.zig` (`match` subcommand) and `bag/test/planner_test.exs`. - @runner_cap "secret_access" - - @doc "Build a `Bag.Mesh.submit_planned` spec for one armed decision (+ its lease)." - def to_spec(decision, lease \\ %{}) do - repo = decision.pr.repo - number = to_string(decision.pr.number) - - %{ - check_id: "merge-" <> String.replace(repo, "/", "__") <> "-" <> number, - command: ["gh", "pr", "merge", number, "--repo", repo, gh_flag(decision.method)], - required_cap: @runner_cap, - # a merge mutates -> the planner gates it on a verifier (independent re-verify) - mutating: true, - # The verifier the planner REQUIRES before it will plan mutating work - # (`Bag.Planner`: `mutating and is_nil(verifier) -> {:rejected, ...}`). It - # names the independent re-verification that runs on the token-bearing node - # *before* the merge — the `.git-private-farm` actuator's `decide_action` - # (veto / meta / not-armed / ci-green / P3 / mass_squash), re-checking the - # council's signed verdict rather than trusting the brain's say-so. - verifier: %{by: "git-private-farm:decide_action", reverifies: decision.route}, - risk: risk(decision.pool), - # carried for the trust proof-chain / structured residue; the planner ignores - # extra keys, but the verdict's residue can reference the council's verdict. - attestation: %{ - lease_id: lease["lease_id"], - route: decision.route, - method: to_string(decision.method), - pool: to_string(decision.pool), - rationale: decision.rationale - } - } - end + @doc "Whether a qualified merge executor is available; performs no submission." + def availability, do: {:error, :qualified_merge_executor_unavailable} - @doc """ - Emit a Baton per *armed* entry of a `Loop` result (`%{results: [...]}`). Only - gate-`{:armed, lease}` entries are submitted. `opts[:submit]` defaults to the - late-bound `Bag.Mesh.submit_planned/1`. Returns `[%{pr, spec, result}]`. - """ - def emit(%{results: results}, opts \\ []) do - submit = Keyword.get(opts, :submit, &default_submit/1) + @doc "No mutating spec can be constructed without a qualified merge executor." + def to_spec(_decision, _lease \\ %{}), do: availability() + @doc "Return explicit refusals for armed candidates; never submit a mutation." + def emit(%{results: results}, _opts \\ []) do for %{gate: {:armed, lease}, decision: decision} <- results do - spec = to_spec(decision, lease) - %{pr: decision.pr, spec: spec, result: submit.(spec)} + %{pr: decision.pr, spec: nil, result: to_spec(decision, lease)} end end - - defp default_submit(spec), do: apply(Bag.Mesh, :submit_planned, [spec]) - - defp gh_flag(:squash), do: "--squash" - # :rebase is never emitted -- a stale decision carrying it squashes (see - # Strategist.decide_method/1); rebase is disabled estate-wide. - defp gh_flag(:merge_commit), do: "--merge" - defp gh_flag(_), do: "--squash" - - # the aggressive pools carry higher planner risk (more verifier scrutiny) - defp risk(pool) when pool in [:p3, :mass_squash], do: :high - defp risk(_), do: :low end diff --git a/lib/merge_orchestration/dispatcher.ex b/lib/merge_orchestration/dispatcher.ex index a20a7179..4392bbe4 100644 --- a/lib/merge_orchestration/dispatcher.ex +++ b/lib/merge_orchestration/dispatcher.ex @@ -12,10 +12,11 @@ defmodule Hypatia.MergeOrchestration.Dispatcher do coordination lease (artifact 5) for the PR's territory. `dispatch/1` and `decide_all/1` are pure (data in, data out). `write_manifest/2` - is the thin operational glue that emits the merge-decision JSONL. + is the thin operational glue that emits the merge-decision JSONL. All three + use the Strategist's review cap when a caller omits an execution cap. """ - alias Hypatia.MergeOrchestration.Strategist + alias Hypatia.MergeOrchestration.{Sensor, Strategist} @doc "Decide, then return `{decision, manifest_entry, lease}`." def dispatch(ctx) do @@ -61,27 +62,54 @@ defmodule Hypatia.MergeOrchestration.Dispatcher do @doc "Map a decision onto the existing DispatchManifest entry shape, plus a `merge` extension." def to_manifest_entry(decision) do + # A pure policy-model arm without an observed subject/event is review-only. + # This adapter never manufactures a revision from the repository's live head. + decision = qualify_for_manifest(decision) + %{ "tier" => tier(decision.safety), "strategy" => strategy(decision.safety), "repo" => decision.pr.repo, + "event_id" => Map.get(decision, :event_id), "confidence" => decision.confidence, "severity" => "Info", "timestamp" => now(), "merge" => %{ "pr_number" => decision.pr.number, + "head_sha" => Map.get(decision, :head_sha), + "observation_complete" => Map.get(decision, :observation_complete, false), + "evidence_authenticity" => + to_string(Map.get(decision, :evidence_authenticity, :unspecified)), "method" => to_string(decision.method), "safety" => to_string(decision.safety), "route" => decision.route, "vetoes" => decision.vetoes, "change_class" => to_string(decision.change_class), "change_level" => to_string(decision.change_level), - "pool" => to_string(decision.pool), + "pool" => pool_name(decision.pool), "rationale" => decision.rationale } } end + defp qualify_for_manifest(%{safety: :arm_auto} = decision) do + if Sensor.valid_subject?(decision.pr.repo, decision.pr.number, Map.get(decision, :head_sha)) and + Sensor.valid_event?(Map.get(decision, :event_id)) and + Map.get(decision, :observation_complete, false) do + decision + else + %{decision | safety: :review, rationale: decision.rationale <> " [unqualified-observation]"} + end + end + + defp qualify_for_manifest(decision), do: decision + + defp pool_name(:p0), do: "P0" + defp pool_name(:p1), do: "P1" + defp pool_name(:p2), do: "P2" + defp pool_name(:p3), do: "P3" + defp pool_name(:mass_squash), do: "mass_squash" + @doc "Mint the Kin.Gate coordination lease (artifact-5 schema) for this decision's territory." def lease_for(ctx, decision) do %{ diff --git a/lib/merge_orchestration/loop.ex b/lib/merge_orchestration/loop.ex index 76c9a652..33d24c87 100644 --- a/lib/merge_orchestration/loop.ex +++ b/lib/merge_orchestration/loop.ex @@ -20,16 +20,17 @@ defmodule Hypatia.MergeOrchestration.Loop do defence in depth behind the Strategist's own meta⇒flag guard. The brain stays token-free: the loop only *reads* the store (observations the - farm producer deposited, the bots' signed attestations, the repos' pool + farm producer deposited, the bots' attestation documents, the repos' pool policies) and *writes* the decision manifest + lease records. The actuator (`.git-private-farm`) is a separate process that reads the manifest and merges; the loop never calls it. Store layout (all under `opts[:store]`): - * `observations/*.json` — per-PR observations (from `observe.sh`) + * `observations/.observation-batch.json` — complete batch membership + digests + * `observations/*.json` — per-PR observations named by that receipt * `pools/.json` — `RepoPoolPolicy` per repo - * `attestations/*.json` — signed bot attestations + * `attestations/*.json` — bot documents; signatures are not verified here * `leases/.json` — `KinGate` leases (written here) * `merge-decisions.jsonl` — the output manifest (written here) @@ -51,15 +52,13 @@ defmodule Hypatia.MergeOrchestration.Loop do * `:manifest` (default) — write `merge-decisions.jsonl`; the `.git-private-farm` actuator polls it. Returns the plan summary plus `:manifest_path`. - * `:baton` — submit one Bag-of-Actions Baton per armed entry via - `BatonEmitter.emit/2` (capability-routed actuation, off GitHub minutes). - Returns the plan summary plus `:batons`; no manifest is written. - * `:both` — do both (manifest as the durable record/fallback, Batons as the - live path). Returns the plan summary plus `:manifest_path` *and* `:batons`. - - For the Baton backends the `Bag.Mesh` call is late-bound (`apply/3`) so this - module stays compile-decoupled from `bag-of-actions`; inject `:submit` (a - `spec -> result` fn) in tests, and `:budget` (a `Bag.Budget`) in production. + * `:baton` — report explicit Baton refusals for any armed entries. Mutation + is unavailable until an actual verifier/executor contract is qualified; + descriptive metadata does not execute the farm's re-verification. + * `:both` — write the manifest and report those refusals. + + Sensor-fed evidence is currently capped at review because no production + signature verifier is present. Neither backend bypasses that cap. """ def run(opts) do store = Keyword.fetch!(opts, :store) @@ -69,6 +68,10 @@ defmodule Hypatia.MergeOrchestration.Loop do now = Keyword.get(opts, :now, DateTime.utc_now()) trust = Keyword.get(opts, :trust, %{}) actuation = Keyword.get(opts, :actuation, :manifest) + + unless actuation in [:manifest, :baton, :both], + do: raise(ArgumentError, "unknown actuation backend") + leases_dir = Path.join(store, "leases") acquire = @@ -92,22 +95,11 @@ defmodule Hypatia.MergeOrchestration.Loop do defp actuate_manifest(result, _store, _encode, _actuation), do: result defp actuate_batons(result, opts, actuation) when actuation in [:baton, :both] do - # lazy: an injected `:submit` (tests) means `default_submit/1` — and thus any - # reference to `Bag.*` — is never built. - submit = Keyword.get_lazy(opts, :submit, fn -> default_submit(opts) end) - Map.put(result, :batons, BatonEmitter.emit(result, submit: submit)) + Map.put(result, :batons, BatonEmitter.emit(result, opts)) end defp actuate_batons(result, _opts, _actuation), do: result - # The live `Bag.Mesh.submit_planned(spec, budget)` call, late-bound via `apply/3` - # so an unconfigured `:manifest`-only run never references `bag-of-actions`. The - # default budget is also late-bound (`Bag.Budget.unlimited/0`). - defp default_submit(opts) do - budget = Keyword.get_lazy(opts, :budget, fn -> apply(Bag.Budget, :unlimited, []) end) - fn spec -> apply(Bag.Mesh, :submit_planned, [spec, budget]) end - end - @doc """ The pure planning core: sense → (weight) → decide → gate, over already-loaded observations. `acquire` is `(request -> {:ok,lease} | {:conflict,l} | {:refused,r})`. @@ -221,23 +213,91 @@ defmodule Hypatia.MergeOrchestration.Loop do defp read_observations(store, decode) do dir = Path.join(store, "observations") + receipt_path = Path.join(dir, ".observation-batch.json") + receipt_body = read_regular!(receipt_path) + receipt = decode.(receipt_body) + + unless valid_batch?(receipt), + do: unqualified_batch!("missing, incomplete or malformed receipt") + + observations = + Enum.map(receipt["observations"], fn entry -> + body = read_regular!(Path.join(dir, entry["file"])) + digest = :crypto.hash(:sha256, body) |> Base.encode16(case: :lower) + obs = decode.(body) + + unless digest == entry["sha256"] and Sensor.valid_observation?(obs) and + obs["repo"] == entry["repo"] and obs["number"] == entry["number"] and + obs["head_sha"] == entry["head_sha"] and obs["event_id"] == receipt["event_id"] and + obs["observed_at"] == receipt["observed_at"] do + unqualified_batch!("observation digest, subject or event mismatch") + end + + obs + end) - case File.ls(dir) do - {:ok, names} -> - names - |> Enum.filter(&String.ends_with?(&1, ".json")) - |> Enum.sort() - |> Enum.map(&decode.(File.read!(Path.join(dir, &1)))) + # A producer publishes `collecting` before changing files and `complete` + # last. Re-read after the files so a concurrent collection cannot be mixed. + unless read_regular!(receipt_path) == receipt_body, + do: unqualified_batch!("receipt changed while reading") + + observations + end - _ -> - [] + defp valid_batch?(%{} = receipt) do + repos = receipt["repos"] + entries = receipt["observations"] + + receipt["schema_version"] == 1 and receipt["status"] == "complete" and + Sensor.valid_event?(receipt["event_id"]) and is_binary(receipt["observed_at"]) and + match?({:ok, _, _}, DateTime.from_iso8601(receipt["observed_at"])) and + is_list(repos) and repos != [] and Enum.uniq(repos) == repos and + Enum.all?(repos, &Sensor.valid_subject?(&1, 1, String.duplicate("0", 40))) and + is_list(entries) and Enum.all?(entries, &valid_batch_entry?(&1, repos)) and + Enum.uniq_by(entries, & &1["file"]) == entries + end + + defp valid_batch?(_), do: false + + defp valid_batch_entry?( + %{ + "repo" => repo, + "number" => number, + "head_sha" => head, + "file" => file, + "sha256" => digest + }, + repos + ) do + Sensor.valid_subject?(repo, number, head) and repo in repos and + file == String.replace(repo, "/", "__") <> "__" <> to_string(number) <> ".json" and + is_binary(digest) and Regex.match?(~r/\A[0-9a-f]{64}\z/, digest) + end + + defp valid_batch_entry?(_, _), do: false + + defp read_regular!(path) do + case File.lstat(path) do + {:ok, %File.Stat{type: :regular}} -> File.read!(path) + _ -> unqualified_batch!("receipt/observation unavailable or not a regular file") end end + defp unqualified_batch!(reason), + do: raise(ArgumentError, "unqualified observation batch: #{reason}; no new manifest written") + defp write_manifest(store, entries, encode) do path = Path.join(store, "merge-decisions.jsonl") body = Enum.map_join(entries, "\n", encode) - File.write!(path, if(body == "", do: "", else: body <> "\n")) + temporary = path <> ".tmp-" <> to_string(System.unique_integer([:positive])) + + try do + File.write!(temporary, if(body == "", do: "", else: body <> "\n"), [:exclusive]) + File.rename!(temporary, path) + after + File.rm(temporary) + end + path end diff --git a/lib/merge_orchestration/scheduler.ex b/lib/merge_orchestration/scheduler.ex index f08f6b4d..8b5c483b 100644 --- a/lib/merge_orchestration/scheduler.ex +++ b/lib/merge_orchestration/scheduler.ex @@ -36,7 +36,8 @@ defmodule Hypatia.MergeOrchestration.Scheduler do Run one merge-orchestration cycle. Returns the `Loop.run/1` summary. `:actuation` (`:manifest` default | `:baton` | `:both`) selects the actuation - backend; `:budget`/`:submit` tune the Baton backends (see `Loop.run/1`). The + backend. Baton mutation is currently refused; `:submit` cannot bypass the + missing qualified executor (see `Loop.run/1`). The result carries `:manifest_path` and/or `:batons` according to the backend. """ def cycle(opts \\ []) do @@ -55,13 +56,13 @@ defmodule Hypatia.MergeOrchestration.Scheduler do result end - # Describe what the cycle actuated, whichever backend(s) ran. + # These are decision artifacts/refusals, never proof that a merge happened. defp actuation_summary(result) do [ result[:manifest_path], case result[:batons] do nil -> nil - batons -> "#{length(batons)} baton(s)" + batons -> "#{length(batons)} refused Baton candidate(s)" end ] |> Enum.reject(&is_nil/1) diff --git a/lib/merge_orchestration/sensor.ex b/lib/merge_orchestration/sensor.ex index 758bd5ec..bc763462 100644 --- a/lib/merge_orchestration/sensor.ex +++ b/lib/merge_orchestration/sensor.ex @@ -11,7 +11,10 @@ defmodule Hypatia.MergeOrchestration.Sensor do %{"repo" => "hyperpolymath/panll", "number" => 412, "branch" => "dependabot/cargo/serde-1.0.200", "title" => "Bump serde from 1.0.199 to 1.0.200", - "head_sha" => "abc123", "files" => ["Cargo.lock"], "labels" => ["dependencies"]} + "head_sha" => String.duplicate("a", 40), "base_sha" => String.duplicate("b", 40), + "schema_version" => 1, "complete" => true, "event_id" => "github:hyperpolymath/panll:run:123", + "observed_at" => "2026-11-04T00:00:00Z", "state" => "open", + "changed_file_count" => 1, "files" => ["Cargo.lock"], "labels" => ["dependencies"]} The brain holds no GitHub token: the *fetch* (gh / API) is done by the token-bearing scanning bot (or a CI job, or the `.git-private-farm` hand) and @@ -21,7 +24,11 @@ defmodule Hypatia.MergeOrchestration.Sensor do * the **observation** itself (above) — what changed; * the repo's **pool policy** (artifact-5 `RepoPoolPolicy`, the per-repo `bot_directive` merge label) — how aggressively this repo may be merged; - * the bots' **signed attestations** (artifact-3) for the PR — the evidence. + * the bots' attestation documents for the exact PR **head** — the evidence. + + Subject matching is not signature verification. This runtime has no qualified + signature/trust verifier, so every Sensor-fed context is capped at review. + An embedded seal or a high model confidence cannot lift that cap. Classification is pure (`classify/1`): a PR's branch/title/files/labels decide its `change_class` (route) and `change_level` (object vs the meta reflexivity @@ -37,16 +44,22 @@ defmodule Hypatia.MergeOrchestration.Sensor do Sense across many observations into decision contexts. `resolve_pool` is `(repo -> pool_policy | nil)`; `resolve_attestations` is - `(repo, number -> [attestation])`. Both default to store-backed readers via + `(repo, number, head_sha -> [attestation])`. Both default to store-backed readers via `store_resolvers/2`. Returns a list of `ctx` maps ready for `Dispatcher`. """ def sense(observations, resolve_pool, resolve_attestations) when is_list(observations) do Enum.map(observations, fn obs -> - to_context( - obs, - resolve_pool.(obs["repo"]), - resolve_attestations.(obs["repo"], obs["number"]) - ) + if valid_subject?(obs["repo"], obs["number"], obs["head_sha"]) do + to_context( + obs, + resolve_pool.(obs["repo"]), + resolve_attestations.(obs["repo"], obs["number"], obs["head_sha"]) + ) + else + # Legacy/incomplete observations remain available for classification and + # manual review; they cannot query evidence for a guessed revision. + to_context(obs, nil, []) + end end) end @@ -61,33 +74,85 @@ defmodule Hypatia.MergeOrchestration.Sensor do """ def to_context(obs, pool_policy, attestations) do {class, level, license_touch} = classify(obs) - atts = Enum.map(attestations, &normalize_attestation/1) + qualified = valid_observation?(obs) + + atts = + attestations + |> Enum.filter(&attests_to?(&1, obs["repo"], obs["number"], obs["head_sha"])) + |> Enum.filter(&valid_attestation?/1) + |> Enum.map(&normalize_attestation/1) %{ pr: %{repo: obs["repo"], number: obs["number"]}, - title: Map.get(obs, "title", ""), - labels: Map.get(obs, "labels", []), - state: Map.get(obs, "state", "open"), + head_sha: obs["head_sha"], + event_id: obs["event_id"], + observation_complete: qualified, + evidence_authenticity: :unverified, + execution_cap: if(qualified, do: :review, else: :flag), + execution_blocker: + if(qualified, do: "attestation-verification-unavailable", else: "unqualified-observation"), + title: text_or_empty(obs["title"]), + labels: strings_or_empty(obs["labels"]), + state: text_or_empty(obs["state"]), change_class: class, change_level: level, license_touch: license_touch, pool: pool_atom(pool_policy), - branch: Map.get(obs, "branch", "main"), - paths: Map.get(obs, "files", []), + branch: text_or_empty(obs["branch"]), + paths: strings_or_empty(obs["files"]), attestations: atts, contributing_bots: atts |> Enum.map(& &1.bot) |> Enum.uniq() } end + @doc "Whether a producer record carries complete, explicit observation provenance." + def valid_observation?(%{} = obs) do + valid_subject?(obs["repo"], obs["number"], obs["head_sha"]) and + valid_sha?(obs["base_sha"]) and valid_event?(obs["event_id"]) and + obs["schema_version"] == 1 and obs["complete"] == true and + is_binary(obs["observed_at"]) and + match?({:ok, _, _}, DateTime.from_iso8601(obs["observed_at"])) and + is_binary(obs["branch"]) and obs["branch"] != "" and is_binary(obs["title"]) and + obs["state"] in ["open", "draft"] and strings?(obs["files"]) and + strings?(obs["labels"]) and is_integer(obs["changed_file_count"]) and + obs["changed_file_count"] >= 0 and + length(obs["files"]) >= obs["changed_file_count"] and + length(obs["files"]) <= 2 * obs["changed_file_count"] and + length(Enum.uniq(obs["files"])) == length(obs["files"]) + end + + def valid_observation?(_), do: false + + @doc false + def valid_subject?(repo, number, head) do + is_binary(repo) and + (Regex.match?(~r/\Ahyperpolymath\/[A-Za-z0-9_.-]+\z/, repo) or + Hypatia.Rules.StandardsRepo.name?(repo)) and + not String.contains?(repo, "..") and is_integer(number) and number > 0 and valid_sha?(head) + end + + @doc false + def valid_sha?(sha), do: is_binary(sha) and Regex.match?(~r/\A[0-9a-f]{40}\z/, sha) + + @doc false + def valid_event?(event), + do: is_binary(event) and Regex.match?(~r/\A[A-Za-z0-9][A-Za-z0-9_.:\/-]{0,255}\z/, event) + + defp strings?(xs), do: is_list(xs) and Enum.all?(xs, &(is_binary(&1) and &1 != "")) + defp strings_or_empty(xs) when is_list(xs), do: Enum.filter(xs, &is_binary/1) + defp strings_or_empty(_), do: [] + defp text_or_empty(s) when is_binary(s), do: s + defp text_or_empty(_), do: "" + # ── Classification (pure) ──────────────────────────────────────────────────── @doc "Classify an observation into `{change_class, change_level, license_touch}`." def classify(obs) do - files = Map.get(obs, "files", []) || [] - branch = Map.get(obs, "branch", "") || "" - title = Map.get(obs, "title", "") || "" - labels = Map.get(obs, "labels", []) || [] - repo = Map.get(obs, "repo", "") || "" + files = strings_or_empty(obs["files"]) + branch = text_or_empty(obs["branch"]) + title = text_or_empty(obs["title"]) + labels = strings_or_empty(obs["labels"]) + repo = text_or_empty(obs["repo"]) license_touch? = Enum.any?(files, &license_path?/1) level = if Enum.any?(files, &meta_path?(&1, repo)), do: :meta, else: :object @@ -170,7 +235,9 @@ defmodule Hypatia.MergeOrchestration.Sensor do base = %{ bot: pick(a, "bot"), verdict: verdict_atom(pick(a, "verdict")), - confidence: pick(a, "confidence") || 0.0 + confidence: pick(a, "confidence") || 0.0, + subject: pick(a, "subject"), + authenticity: :unverified } case pick(a, "rationale") do @@ -179,6 +246,19 @@ defmodule Hypatia.MergeOrchestration.Sensor do end end + defp valid_attestation?(a) when is_map(a) do + bot = pick(a, "bot") + verdict = pick(a, "verdict") + confidence = pick(a, "confidence") + + is_binary(bot) and bot != "" and + verdict in [:approve, :hold, :veto, "approve", "hold", "veto"] and + (verdict in [:veto, "veto"] or + (is_number(confidence) and confidence >= 0 and confidence <= 1)) + end + + defp valid_attestation?(_), do: false + @doc "Map a `RepoPoolPolicy` (`\"P2\"`/`\"mass_squash\"`) onto the Strategist pool atom; nil ⇒ `:p1`." def pool_atom(nil), do: :p1 def pool_atom(%{} = policy), do: policy |> pick("pool") |> pool_atom() @@ -208,6 +288,8 @@ defmodule Hypatia.MergeOrchestration.Sensor do end end + defp pick(_, _), do: nil + defp known_atom("bot"), do: :bot defp known_atom("verdict"), do: :verdict defp known_atom("confidence"), do: :confidence @@ -215,6 +297,7 @@ defmodule Hypatia.MergeOrchestration.Sensor do defp known_atom("subject"), do: :subject defp known_atom("repo"), do: :repo defp known_atom("number"), do: :number + defp known_atom("head_sha"), do: :head_sha defp known_atom("pool"), do: :pool defp known_atom(_), do: nil @@ -225,7 +308,8 @@ defmodule Hypatia.MergeOrchestration.Sensor do Layout (token-free, what the scanning bots deposit): * `/pools/.json` — the repo's `RepoPoolPolicy` - * `/attestations/*.json` — signed attestations (filtered by subject) + * `/attestations/*.json` — documents filtered by exact subject/head; + signatures are not verified by this reader `opts[:decode]` defaults to `&Jason.decode!/1` (a real hypatia dep). Repo names are flattened `owner/name -> owner__name` for the pool filename. @@ -249,15 +333,15 @@ defmodule Hypatia.MergeOrchestration.Sensor do end end - @doc "A `(repo, number -> [attestation])` that reads every `*.json` in `dir` and filters by subject." + @doc "A `(repo, number, head_sha -> [attestation])` reader; subject equality is not authenticity." def dir_attestation_resolver(dir, decode \\ &__MODULE__.json_decode!/1) do - fn repo, number -> + fn repo, number, head -> case File.ls(dir) do {:ok, names} -> names |> Enum.filter(&String.ends_with?(&1, ".json")) |> Enum.map(&decode.(File.read!(Path.join(dir, &1)))) - |> Enum.filter(&attests_to?(&1, repo, number)) + |> Enum.filter(&attests_to?(&1, repo, number, head)) _ -> [] @@ -270,9 +354,11 @@ defmodule Hypatia.MergeOrchestration.Sensor do # test harness never reaches here because it injects its own decode. def json_decode!(body), do: apply(Jason, :decode!, [body]) - defp attests_to?(att, repo, number) do + defp attests_to?(att, repo, number, head) do subj = pick(att, "subject") || %{} - pick(subj, "repo") == repo and pick(subj, "number") == number + + valid_subject?(repo, number, head) and pick(subj, "repo") == repo and + pick(subj, "number") == number and pick(subj, "head_sha") == head end defp flatten_repo(repo), do: String.replace(repo, "/", "__") diff --git a/lib/merge_orchestration/strategist.ex b/lib/merge_orchestration/strategist.ex index 7898b86c..b4f9ee33 100644 --- a/lib/merge_orchestration/strategist.ex +++ b/lib/merge_orchestration/strategist.ex @@ -13,7 +13,11 @@ defmodule Hypatia.MergeOrchestration.Strategist do Confidence and bot-vetoes come from `KinCouncil.aggregate/2` over the PR's attestations (artifact 3); a license/SPDX touch adds a symbolic owner-only - veto. No I/O — the brain decides; it never executes. + veto. No I/O — this is a policy model, not a signature verifier. Runtime Sensor + contexts carry an execution cap because their attestation authenticity is + unverified; model confidence cannot remove that cap. Direct callers also + default to review when the cap is missing or invalid. An explicit + `execution_cap: :arm_auto` is a policy-model assumption, not verification. """ alias Hypatia.MergeOrchestration.KinCouncil @@ -36,6 +40,10 @@ defmodule Hypatia.MergeOrchestration.Strategist do %{ pr: ctx.pr, + head_sha: Map.get(ctx, :head_sha), + event_id: Map.get(ctx, :event_id), + observation_complete: Map.get(ctx, :observation_complete, false), + evidence_authenticity: Map.get(ctx, :evidence_authenticity, :unspecified), change_class: ctx.change_class, change_level: ctx.change_level, route: decide_route(ctx), @@ -53,9 +61,28 @@ defmodule Hypatia.MergeOrchestration.Strategist do # --- Safety axis: confidence + veto + meta, clamped by pool (monotone) --- defp decide_safety(ctx, confidence, vetoes) do cond do - vetoes != [] -> {:flag, "veto:" <> first_reason(vetoes)} - ctx.change_level == :meta -> {:flag, "meta"} - true -> confidence |> confidence_to_safety() |> clamp_to_pool(ctx.pool) + vetoes != [] -> + {:flag, "veto:" <> first_reason(vetoes)} + + ctx.change_level == :meta -> + {:flag, "meta"} + + true -> + {base, reason} = confidence |> confidence_to_safety() |> clamp_to_pool(ctx.pool) + capped = min_safety(base, execution_cap(ctx)) + + {capped, + if(capped != base, + do: Map.get(ctx, :execution_blocker, "runtime-execution-cap"), + else: reason + )} + end + end + + defp execution_cap(ctx) do + case Map.get(ctx, :execution_cap, :review) do + cap when cap in [:flag, :review, :arm_auto] -> cap + _ -> :review end end diff --git a/lib/merge_orchestration/ticker.ex b/lib/merge_orchestration/ticker.ex index 0296987a..4468f715 100644 --- a/lib/merge_orchestration/ticker.ex +++ b/lib/merge_orchestration/ticker.ex @@ -36,7 +36,7 @@ defmodule Hypatia.MergeOrchestration.Ticker do @doc "Run a cycle now (manual / test trigger), out of band of the interval." def run_now(server \\ __MODULE__), do: GenServer.cast(server, :tick_now) - @doc "Current ticker state (last_run, runs, last_stats, errors)." + @doc "Current ticker state; last_stats is nil after a failed attempt, never stale success." def status(server \\ __MODULE__), do: GenServer.call(server, :status) # --- GenServer callbacks --- @@ -49,6 +49,7 @@ defmodule Hypatia.MergeOrchestration.Ticker do last_run: nil, runs: 0, last_stats: nil, + last_outcome: :never_run, errors: [] } @@ -76,11 +77,23 @@ defmodule Hypatia.MergeOrchestration.Ticker do def do_cycle(state) do result = Scheduler.cycle(state.cycle_opts) Logger.info("MergeOrchestration.Ticker: cycle #{inspect(result.stats)}") - %{state | last_run: DateTime.utc_now(), runs: state.runs + 1, last_stats: result.stats} + + Map.merge(state, %{ + last_run: DateTime.utc_now(), + runs: state.runs + 1, + last_stats: result.stats, + last_outcome: :completed + }) rescue e -> Logger.warning("MergeOrchestration.Ticker: cycle failed #{inspect(e)}") - %{state | last_run: DateTime.utc_now(), errors: [inspect(e) | state.errors]} + + Map.merge(state, %{ + last_run: DateTime.utc_now(), + last_stats: nil, + last_outcome: :failed, + errors: [inspect(e) | state.errors] + }) end defp config(key, default) do diff --git a/lib/mix/tasks/hypatia.merge_orchestrate.ex b/lib/mix/tasks/hypatia.merge_orchestrate.ex index 69fcee95..5371ab34 100644 --- a/lib/mix/tasks/hypatia.merge_orchestrate.ex +++ b/lib/mix/tasks/hypatia.merge_orchestrate.ex @@ -15,24 +15,23 @@ defmodule Mix.Tasks.Hypatia.MergeOrchestrate do `--actuation` selects the actuation backend (default `manifest`): - * `manifest` — write `merge-decisions.jsonl`; the `.git-private-farm` - actuator (a separate, token-bearing process) polls and performs the merges. - * `baton` — submit one Bag-of-Actions Baton per armed decision. Each carries - `required_cap: "secret_access"`, which only the token-bearing - `mesh-github-runner` node holds — so the merge migrates off the (token-free) - brain to the runner, off GitHub Actions minutes. - * `both` — manifest as the durable record/fallback, Batons as the live path. + * `manifest` — write `merge-decisions.jsonl` for separate, explicitly + authorized farm actuation. Writing the manifest does not merge a PR. + * `baton` and `both` — currently unavailable: no qualified merge executor + exists. The task raises before starting a cycle, even with zero armed + decisions. It submits no Baton and writes no partial manifest. + + Use `manifest` for the available decision-artifact path. The pure policy model + and descriptive refusal records do not establish an operational Baton backend. """ use Mix.Task - alias Hypatia.MergeOrchestration.Scheduler + alias Hypatia.MergeOrchestration.{BatonEmitter, Scheduler} @actuations ~w(manifest baton both) @impl Mix.Task def run(argv) do - Mix.Task.run("app.start") - {opts, _, _} = OptionParser.parse(argv, strict: [store: :string, holder: :string, actuation: :string]) @@ -41,17 +40,24 @@ defmodule Mix.Tasks.Hypatia.MergeOrchestrate do |> Keyword.take([:store, :holder]) |> put_actuation(opts[:actuation]) + require_backend!(Keyword.get(cycle_opts, :actuation, :manifest)) + Mix.Task.run("app.start") result = Scheduler.cycle(cycle_opts) - summary = - [ - result[:manifest_path], - result[:batons] && "#{length(result[:batons])} baton(s)" - ] - |> Enum.reject(&is_nil/1) - |> Enum.join(" + ") + Mix.shell().info("[merge-orchestration] #{inspect(result.stats)} -> #{result.manifest_path}") + end + + defp require_backend!(:manifest), do: :ok + + defp require_backend!(mode) when mode in [:baton, :both] do + # Availability currently has only this explicit refusal result. A future + # qualified executor must update this preflight and its argv controls. + {:error, reason} = BatonEmitter.availability() - Mix.shell().info("[merge-orchestration] #{inspect(result.stats)} -> #{summary}") + Mix.raise( + "--actuation #{mode} unavailable: #{reason}; no cycle started, " <> + "no Baton submitted and no manifest written. Use --actuation manifest." + ) end defp put_actuation(cycle_opts, nil), do: cycle_opts diff --git a/lib/neural/blackboard.ex b/lib/neural/blackboard.ex index cee0b322..75a73171 100644 --- a/lib/neural/blackboard.ex +++ b/lib/neural/blackboard.ex @@ -16,12 +16,14 @@ defmodule Hypatia.Neural.Blackboard do Uses ETS for lock-free concurrent reads across network GenServers. The GenServer process owns the ETS table and manages round lifecycle; - reads and writes go directly to ETS for performance. + raw diagnostic reads and writes go directly to ETS. The neural execution + path uses source-qualified calls through the owner so an old task cannot + insert an A result into a newly initialized B board. Each analysis round: - 1. `start_round/0` -- clears the blackboard, increments round counter + 1. `start_round_source/2` -- clears this board and increments its round counter 2. Networks execute in phased order, reading/writing the blackboard - 3. `end_round/0` -- freezes the round, returns the complete reasoning trace + 3. `end_round_source/2` -- returns the source-checked reasoning trace Phase order (6 phases, 8 networks): 1. RBF (novelty) -- everything benefits from knowing novel vs known @@ -35,6 +37,8 @@ defmodule Hypatia.Neural.Blackboard do use GenServer require Logger + alias Hypatia.Neural.SourceStore + @table_name :hypatia_blackboard @meta_table :hypatia_blackboard_meta @@ -97,7 +101,8 @@ defmodule Hypatia.Neural.Blackboard do key: key, value: value, round: current_round(), - timestamp: System.os_time(:millisecond) + timestamp: System.os_time(:millisecond), + source_store: nil } :ets.insert(@table_name, {{network, key}, entry}) @@ -200,36 +205,46 @@ defmodule Hypatia.Neural.Blackboard do end end + @doc "Start a round only in the board's captured source lifecycle." + def start_round_source(source, server \\ __MODULE__), + do: GenServer.call(server, {:source, source, :start_round}) + + def end_round_source(source, server \\ __MODULE__), + do: GenServer.call(server, {:source, source, :end_round}) + + def write_source(source, network, key, value, server \\ __MODULE__), + do: GenServer.call(server, {:source, source, {:write, network, key, value}}) + + def read_source(source, network, key, server \\ __MODULE__), + do: GenServer.call(server, {:source, source, {:read, network, key}}) + + def snapshot_source(source, server \\ __MODULE__), + do: GenServer.call(server, {:source, source, :snapshot}) + + def identity_source(source, server \\ __MODULE__), + do: GenServer.call(server, {:source, source, :identity}) + # ── GenServer Callbacks ──────────────────────────────── @impl true - def init(_opts) do - # Create ETS tables. :public allows direct read/write from any process. - # The GenServer owns the tables (they die with this process). + def init(opts) do + # Isolated actual boards use unnamed tables for lifecycle tests. The + # production instance retains its existing named diagnostic tables. + naming = if Keyword.get(opts, :isolated, false), do: [], else: [:named_table] + table = - :ets.new(@table_name, [ - :named_table, - :public, - :set, - read_concurrency: true, - write_concurrency: true - ]) - - meta = - :ets.new(@meta_table, [ - :named_table, - :public, - :set, - read_concurrency: true - ]) - - # Initialise round metadata - :ets.insert(@meta_table, {:round, 0}) - :ets.insert(@meta_table, {:round_started_at, nil}) - :ets.insert(@meta_table, {:round_ended_at, nil}) + :ets.new( + @table_name, + naming ++ [:public, :set, read_concurrency: true, write_concurrency: true] + ) + + meta = :ets.new(@meta_table, naming ++ [:public, :set, read_concurrency: true]) + :ets.insert(meta, {:round, 0}) + :ets.insert(meta, {:round_started_at, nil}) + :ets.insert(meta, {:round_ended_at, nil}) Logger.info("Blackboard initialised (ETS tables: #{inspect(table)}, #{inspect(meta)})") - {:ok, %{table: table, meta: meta}} + {:ok, Map.merge(%{table: table, meta: meta}, SourceStore.fields())} end @impl true @@ -277,6 +292,86 @@ defmodule Hypatia.Neural.Blackboard do {:reply, stats, state} end + def handle_call({:source, source, operation}, _from, state) do + SourceStore.reply(state, source, fn -> + {source_operation(state, source, operation), state} + end) + end + + defp source_operation(state, _source, :identity), + do: {:ok, SourceStore.tag(%{}, state)} + + defp source_operation(state, _source, :start_round) do + :ets.delete_all_objects(state.table) + round = (table_meta(state.meta, :round) || 0) + 1 + :ets.insert(state.meta, {:round, round}) + :ets.insert(state.meta, {:round_started_at, System.os_time(:millisecond)}) + :ets.insert(state.meta, {:round_ended_at, nil}) + {:ok, round} + end + + defp source_operation(state, source, :end_round) do + result = source_snapshot(state, source) + :ets.insert(state.meta, {:round_ended_at, System.os_time(:millisecond)}) + result + end + + defp source_operation(state, source, :snapshot), do: source_snapshot(state, source) + + defp source_operation(state, source, {:write, network, key, value}) do + entry = %{ + network: network, + key: key, + value: value, + source_store: source, + round: table_meta(state.meta, :round), + timestamp: System.os_time(:millisecond) + } + + :ets.insert(state.table, {{network, key}, entry}) + :ok + end + + defp source_operation(state, source, {:read, network, key}) do + case :ets.lookup(state.table, {network, key}) do + [{_, entry}] -> + if Map.get(entry, :source_store) == source and + entry.round == table_meta(state.meta, :round) do + {:ok, entry.value} + else + {:error, :foreign_blackboard_entry} + end + + [] -> + :not_found + end + end + + defp source_snapshot(state, source) do + entries = :ets.tab2list(state.table) |> Enum.map(&elem(&1, 1)) + round = table_meta(state.meta, :round) + + if Enum.all?(entries, &(Map.get(&1, :source_store) == source and &1.round == round)) do + {:ok, + %{ + source_store: source, + round: round, + networks: Enum.group_by(entries, & &1.network), + entry_count: length(entries), + timestamp: System.os_time(:millisecond) + }} + else + {:error, :foreign_blackboard_entry} + end + end + + defp table_meta(table, key) do + case :ets.lookup(table, key) do + [{^key, value}] -> value + [] -> nil + end + end + # ── Private Helpers ───────────────────────────────────── @doc false diff --git a/lib/neural/coordinator.ex b/lib/neural/coordinator.ex index 6b34bd92..5df1c7ff 100644 --- a/lib/neural/coordinator.ex +++ b/lib/neural/coordinator.ex @@ -7,7 +7,7 @@ # architecture (8 networks, 6 phases, shared ETS state). # # Each phase reads from the blackboard, computes, and writes results back. -# Parallel phases run concurrently via Task.async_stream. +# Parallel phases return source-bound failures to the coordinator. defmodule Hypatia.Neural.Coordinator do @moduledoc """ @@ -56,9 +56,8 @@ defmodule Hypatia.Neural.Coordinator do LiquidStateMachine, EchoStateNetwork, RadialNeuralNetwork, - GraphNeuralNetwork, - VariationalAutoencoder, - SequenceModel + Persistence, + SourceStore } defstruct [ @@ -73,7 +72,12 @@ defmodule Hypatia.Neural.Coordinator do last_cycle: nil, # Blackboard architecture metadata total_rounds: 0, - last_round_trace: nil + last_round_trace: nil, + source_store: nil, + source_status: :ready, + source_error: nil, + named_networks: %{}, + blackboard: Blackboard ] @network_count 8 @@ -86,63 +90,101 @@ defmodule Hypatia.Neural.Coordinator do end @impl true - def init(_opts) do - persisted = safe_load_persisted() - rbf = hydrate_rbf(RadialNeuralNetwork.init(), persisted) - moe = hydrate_moe(MixtureOfExperts.init(), persisted) - trust_graph = hydrate_trust_graph(GraphOfTrust.build(), persisted) - - # M17 — restore GNN / VAE / Seq via their GenServer restore APIs. - # These networks are siblings under the same supervisor; they're - # already started by the time the Coordinator init runs (the - # Application supervision order in lib/application.ex puts them - # before this module). Restore is best-effort and asynchronous - # in spirit — a missing PID or restore failure leaves the network - # at its fresh-init defaults rather than crashing the supervisor. - restore_named_network(GraphNeuralNetwork, get_in(persisted, [:gnn])) - restore_named_network(VariationalAutoencoder, get_in(persisted, [:vae])) - restore_named_network(SequenceModel, get_in(persisted, [:sequence])) + def init(opts) do + source = SourceStore.current() + raw_persisted = safe_load_persisted(source) + persisted = qualified_snapshots(raw_persisted, source) state = %__MODULE__{ - trust_graph: trust_graph, - moe: moe, + source_store: source, + named_networks: Keyword.get(opts, :named_networks, Persistence.default_networks()), + blackboard: Keyword.get(opts, :blackboard, Blackboard), + trust_graph: GraphOfTrust.build_from_store(source), + moe: hydrate_moe(MixtureOfExperts.init(), persisted), lsm: LiquidStateMachine.init(), esn: EchoStateNetwork.init(), - rbf: rbf + rbf: hydrate_rbf(RadialNeuralNetwork.init(), persisted) } + # Sibling processes retain their own source identity. Validate the actual + # cohort before attempting hydration; never relabel an existing A model B. + state = + case SourceStore.transact(state, source, fn -> + ensure_cohort!(state) + restore_named_networks!(state, persisted) + ensure_cohort!(state) + {:ok, state} + end) do + {:ok, :ok, ready} -> ready + {:error, reason, incomplete} -> + Logger.warning("Neural initialization refused: #{inspect(reason)}") + # Even a retryable runtime outage cannot qualify a startup whose + # complete peer hydration and final cohort check never finished. + %{ + incomplete + | source_status: :blocked, + source_error: {:source_initialization_incomplete, reason} + } + end + notes = [ - rbf_restore_note(rbf), - moe_restore_note(moe, persisted), - trust_restore_note(persisted), + rbf_restore_note(state.rbf), + moe_restore_note(state.moe, persisted), + trust_rebuild_note(raw_persisted), new_networks_restore_note(persisted) ] Logger.info( - "Neural Coordinator initialised: blackboard architecture, " <> - "#{@network_count} networks, #{@phase_count} phases" <> + "Neural Coordinator initialized: #{@network_count} networks, #{@phase_count} phases; " <> + "source #{inspect(state.source_store)} is #{state.source_status}" <> Enum.join(notes, "") ) {:ok, state} end - defp restore_named_network(module, {:ok, data}) when is_map(data) do - if Process.whereis(module) do - try do - module.restore(data) - rescue - _ -> :ok - catch - _, _ -> :ok + # Raw snapshots remain on disk for diagnosis. Only the captured store's + # explicitly bound model snapshots may be hydrated. Trust is always rebuilt. + defp qualified_snapshots(raw, source) do + Map.new(raw, fn {name, snapshot} -> + qualified = + case snapshot do + {:ok, data} -> + if SourceStore.snapshot_matches?(data, source), do: snapshot, else: :not_found + + _ -> + :not_found + end + + {name, qualified} + end) + end + + defp restore_named_networks!(state, persisted) do + Enum.each([:gnn, :vae, :sequence], fn name -> + case Map.get(persisted, name) do + {:ok, data} -> + SourceStore.call!(Map.fetch!(state.named_networks, name), {:restore, state.source_store, data}) + + _ -> + :ok end + end) + end + + defp ensure_cohort!(state) do + SourceStore.accept!(SourceStore.check(state, state.source_store)) + SourceStore.accept!(Persistence.collect_named(state.source_store, state.named_networks)) + identity = SourceStore.accept!(Blackboard.identity_source(state.source_store, state.blackboard)) + + unless SourceStore.snapshot_matches?(identity, state.source_store) do + raise SourceStore.Refusal, + reason: {:cohort_source_mismatch, :blackboard, :unqualified_identity} end :ok end - defp restore_named_network(_module, _other), do: :ok - defp new_networks_restore_note(persisted) do restored = [:gnn, :vae, :sequence] @@ -162,8 +204,8 @@ defmodule Hypatia.Neural.Coordinator do # Best-effort load of persisted neural state. Never raises: a missing or # malformed store must not crash the supervisor -- networks fall back to # init-defaults and are retrained by the first LearningScheduler cycle. - defp safe_load_persisted do - Hypatia.Neural.Persistence.load_all() + defp safe_load_persisted(source) do + Persistence.load_all(source) rescue e -> Logger.warning( @@ -173,11 +215,9 @@ defmodule Hypatia.Neural.Coordinator do %{} end - # RBF is the only network restored at init: it is genuinely learned, - # plainly serialisable (Persistence.save_rbf/1 is a 1:1 of the struct), - # and otherwise unusable until a full training cycle runs. trust is - # rebuilt from source every cycle; MoE/ESN/LSM are continuously / - # per-cycle trained (ESN/LSM persistence is summary-only by design). + # RBF and MoE have explicit learned-state restoration below. Trust is + # rebuilt from current qualified source at startup and every cycle; + # ESN/LSM persistence remains summary-only by design. defp hydrate_rbf(default_rbf, %{rbf: {:ok, data}}) when is_map(data) do centers = Map.get(data, "centers", []) weights = Map.get(data, "output_weights", []) @@ -292,33 +332,14 @@ defmodule Hypatia.Neural.Coordinator do defp moe_restore_note(_moe, _), do: "" - # Trust graph hydration — replays nodes + edges from the persisted - # snapshot. Trust scores are kept as the last computed values. If - # the persisted form is unparseable, the freshly built graph (from - # GraphOfTrust.build/0) is used unchanged. - defp hydrate_trust_graph(default_graph, %{trust: {:ok, data}}) when is_map(data) do - persisted_scores = Map.get(data, "trust_scores", %{}) + # Every trust snapshot is diagnostic-only, including current-version + # snapshots. A version label cannot authenticate old scores or bind them + # to the current source. Invalidating all cached scores also prevents a + # rollback to the legacy model from being reintroduced on restart. + defp trust_rebuild_note(%{trust: {:ok, _data}}), + do: " (persisted trust scores ignored; rebuilt from current qualified outcomes)" - if is_map(persisted_scores) and map_size(persisted_scores) > 0 do - %{default_graph | trust_scores: persisted_scores} - else - default_graph - end - rescue - _ -> default_graph - end - - defp hydrate_trust_graph(default_graph, _), do: default_graph - - defp trust_restore_note(%{trust: {:ok, data}}) do - scores = Map.get(data, "trust_scores", %{}) - - if is_map(scores) and map_size(scores) > 0, - do: " (trust scores restored for #{map_size(scores)} node(s))", - else: "" - end - - defp trust_restore_note(_), do: "" + defp trust_rebuild_note(_), do: "" # Convert "domain_string" back to :domain_atom safely. Returns nil # if the atom doesn't already exist in the VM — defensive against @@ -345,35 +366,66 @@ defmodule Hypatia.Neural.Coordinator do 5. VAE interpretation clustering 6. Sequence model choreography prediction - Returns the complete reasoning trace from the blackboard. + Returns the complete reasoning trace from the blackboard, labelled with + its source, or {:error, reason} when the lifecycle cannot serve this source. """ - def analyze(finding) do - GenServer.call(__MODULE__, {:analyze, finding}, 30_000) + def analyze(finding, server \\ __MODULE__) do + GenServer.call(server, {:analyze, SourceStore.current(), finding}, 30_000) end - @doc "Record an outcome and update all networks." - def record_outcome(finding, outcome) do - GenServer.cast(__MODULE__, {:outcome, finding, outcome}) + @doc """ + Submit an outcome without waiting behind synchronous training. + + Captures the source and the currently observed local Coordinator PID. :ok + means a volatile mailbox submission, not source admission, durable delivery + or completed learning. Missing/dead/nonlocal targets return {:error, reason}. + The captured source and actual peer cohort are checked when the event runs; + execution refusal remains visible in status. A process exit can lose queued + work, and this API does not claim a durable acknowledgement. + """ + def record_outcome(finding, outcome, server \\ __MODULE__) do + source = SourceStore.current() + + case GenServer.whereis(server) do + pid when is_pid(pid) and node(pid) == node() -> + if Process.alive?(pid) do + send(pid, {:source_bound, source, {:outcome, finding, outcome}}) + :ok + else + {:error, :neural_unavailable} + end + + _ -> + {:error, :neural_unavailable} + end end @doc """ Get dispatch recommendation using blackboard-derived confidence. Unlike the old fixed-weight aggregation, confidence is derived from - the complete reasoning trace across all 8 networks. + the complete reasoning trace across all 8 networks. Source refusals return + {:error, reason}; they are not confidence or strategy recommendations. + An optional third argument bounds the caller's wait in milliseconds; it + does not cancel an analysis already executing in the Coordinator. """ - def dispatch_recommendation(finding) do - GenServer.call(__MODULE__, {:dispatch_rec, finding}, 30_000) + def dispatch_recommendation(finding, server \\ __MODULE__, timeout \\ 30_000) do + GenServer.call(server, {:dispatch_rec, SourceStore.current(), finding}, timeout) + end + + @doc "Return a current-source health map, or an explicit lifecycle refusal." + def health_report(server \\ __MODULE__) do + GenServer.call(server, {:health_report, SourceStore.current()}) end - @doc "Get system-wide neural health report for all 8 networks." - def health_report do - GenServer.call(__MODULE__, :health_report) + @doc "Return current-source status or an explicit lifecycle refusal." + def status(server \\ __MODULE__) do + GenServer.call(server, {:status, SourceStore.current()}) end - @doc "Force a learning cycle across all networks." - def force_cycle do - GenServer.cast(__MODULE__, :force_cycle) + @doc "Queue a learning cycle; :ok acknowledges admission, not completion." + def force_cycle(server \\ __MODULE__) do + GenServer.call(server, {:queue, SourceStore.current(), :force_cycle}) end @doc "Get the number of networks in the coordinator." @@ -385,7 +437,115 @@ defmodule Hypatia.Neural.Coordinator do # ── GenServer Callbacks ───────────────────────────────── @impl true - def handle_call(:status, _from, state) do + def handle_call({:status, source}, _from, state) do + diagnostic_reply(state, source, fn -> {:ok, status_map(state)} end) + end + + # Compatibility for existing diagnostic probes. New callers bind the + # request when submitted; this probe still refuses a mismatched cohort. + def handle_call(:status, from, state), + do: handle_call({:status, SourceStore.current()}, from, state) + + @impl true + def handle_call({:analyze, source, finding}, _from, state) do + SourceStore.reply(state, source, fn -> + ensure_cohort!(state) + {trace, updated_state} = run_phased_analysis(finding, state) + + analysis = %{ + # Legacy fields for backward compatibility + moe: trace_field(trace, :moe, :domain_confidence), + rbf: trace_field(trace, :rbf, :novelty_result), + lsm: trace_field(trace, :lsm, :temporal_result), + esn: trace_field(trace, :esn, :trajectory_result), + # New blackboard fields + gnn: trace_field(trace, :gnn, :graph_metrics), + vae: trace_field(trace, :vae, :interpretation), + sequence: trace_field(trace, :sequence, :prediction), + # Derived confidence from full reasoning trace + aggregated_confidence: derive_confidence(trace), + is_novel: is_novel?(trace), + # Blackboard metadata + round: trace.round, + phase_count: @phase_count, + network_count: @network_count, + architecture: :blackboard + } + + {Map.merge(analysis, SourceStore.diagnostic(updated_state)), updated_state} + end) + end + + @impl true + def handle_call({:dispatch_rec, source, finding}, _from, state) do + SourceStore.reply(state, source, fn -> + ensure_cohort!(state) + {trace, updated_state} = run_phased_analysis(finding, state) + + confidence = derive_confidence(trace) + novel = is_novel?(trace) + + # Trust graph: find best bots + trusted_bots = GraphOfTrust.trusted_bots(state.trust_graph) + trusted_recipes = GraphOfTrust.trusted_recipes(state.trust_graph) + + raw_strategy = + cond do + novel -> :report_only + confidence >= 0.95 -> :auto_execute + confidence >= 0.85 -> :review + true -> :report_only + end + + # Annealing gate: clamp strategy based on recipe's outcome maturity. + # A recipe with only 3 outcomes cannot reach :auto_execute regardless + # of neural confidence -- it must accumulate evidence first. + recipe_id = + [Map.get(finding, "recipe_id"), Map.get(finding, "pattern_id"), Map.get(finding, "id")] + |> Enum.find("unknown", fn id -> is_binary(id) and byte_size(id) > 0 end) + + strategy = Hypatia.OutcomeTracker.annealed_strategy_from_store(recipe_id, raw_strategy, source) + + recommendation = %{ + strategy: strategy, + raw_strategy: raw_strategy, + confidence: confidence, + preferred_bots: Enum.take(trusted_bots, 3), + preferred_recipes: Enum.take(trusted_recipes, 5), + is_novel: novel, + reason: dispatch_reason(strategy, confidence, novel), + annealing_clamped: strategy != raw_strategy, + architecture: :blackboard, + networks_consulted: @network_count + } + + {Map.merge(recommendation, SourceStore.diagnostic(updated_state)), updated_state} + end) + end + + @impl true + def handle_call({:health_report, source}, _from, state) do + diagnostic_reply(state, source, fn -> health_map(state) end) + end + + def handle_call(:health_report, from, state), + do: handle_call({:health_report, SourceStore.current()}, from, state) + + def handle_call({:queue, source, event}, _from, state) + when event == :force_cycle or + (is_tuple(event) and tuple_size(event) == 3 and elem(event, 0) == :outcome) do + SourceStore.reply(state, source, fn -> + ensure_cohort!(state) + send(self(), {:source_bound, source, event}) + {:ok, state} + end) + end + + def handle_call(_unbound_request, _from, state) do + {:reply, {:error, :unbound_request}, SourceStore.refuse(state, :unbound_request)} + end + + defp status_map(state) do status = %{ networks: [ :radial_neural_network, @@ -405,81 +565,22 @@ defmodule Hypatia.Neural.Coordinator do total_rounds: state.total_rounds } - {:reply, {:ok, status}, state} + Map.merge(status, SourceStore.diagnostic(state)) end - @impl true - def handle_call({:analyze, finding}, _from, state) do - {trace, updated_state} = run_phased_analysis(finding, state) - - analysis = %{ - # Legacy fields for backward compatibility - moe: trace_field(trace, :moe, :domain_confidence), - rbf: trace_field(trace, :rbf, :novelty_result), - lsm: trace_field(trace, :lsm, :temporal_result), - esn: trace_field(trace, :esn, :trajectory_result), - # New blackboard fields - gnn: trace_field(trace, :gnn, :graph_metrics), - vae: trace_field(trace, :vae, :interpretation), - sequence: trace_field(trace, :sequence, :prediction), - # Derived confidence from full reasoning trace - aggregated_confidence: derive_confidence(trace), - is_novel: is_novel?(trace), - # Blackboard metadata - round: trace.round, - phase_count: @phase_count, - network_count: @network_count, - architecture: :blackboard - } - - {:reply, analysis, updated_state} - end - - @impl true - def handle_call({:dispatch_rec, finding}, _from, state) do - {trace, updated_state} = run_phased_analysis(finding, state) - - confidence = derive_confidence(trace) - novel = is_novel?(trace) - - # Trust graph: find best bots - trusted_bots = GraphOfTrust.trusted_bots(state.trust_graph) - trusted_recipes = GraphOfTrust.trusted_recipes(state.trust_graph) - - raw_strategy = - cond do - novel -> :report_only - confidence >= 0.95 -> :auto_execute - confidence >= 0.85 -> :review - true -> :report_only - end - - # Annealing gate: clamp strategy based on recipe's outcome maturity. - # A recipe with only 3 outcomes cannot reach :auto_execute regardless - # of neural confidence -- it must accumulate evidence first. - recipe_id = - [Map.get(finding, "recipe_id"), Map.get(finding, "pattern_id"), Map.get(finding, "id")] - |> Enum.find("unknown", fn id -> is_binary(id) and byte_size(id) > 0 end) - strategy = Hypatia.OutcomeTracker.annealed_strategy(recipe_id, raw_strategy) - - recommendation = %{ - strategy: strategy, - raw_strategy: raw_strategy, - confidence: confidence, - preferred_bots: Enum.take(trusted_bots, 3), - preferred_recipes: Enum.take(trusted_recipes, 5), - is_novel: novel, - reason: dispatch_reason(strategy, confidence, novel), - annealing_clamped: strategy != raw_strategy, - architecture: :blackboard, - networks_consulted: @network_count - } - - {:reply, recommendation, updated_state} + defp diagnostic_reply(state, source, report) do + case SourceStore.transact(state, source, fn -> + ensure_cohort!(state) + {report.(), state} + end) do + {:ok, result, ready} -> {:reply, result, ready} + {:error, reason, refused} -> + details = Map.merge(SourceStore.diagnostic(refused), %{reason: reason, cycle_count: state.cycle_count}) + {:reply, {:error, {:neural_source_unavailable, details}}, refused} + end end - @impl true - def handle_call(:health_report, _from, state) do + defp health_map(state) do report = %{ architecture: :blackboard, network_count: @network_count, @@ -504,23 +605,43 @@ defmodule Hypatia.Neural.Coordinator do trained: state.rbf.trained, training_stats: state.rbf.training_stats }, - # New 3 networks (GenServer, status via process alive check) - gnn: %{alive: process_alive?(GraphNeuralNetwork)}, - vae: %{alive: process_alive?(VariationalAutoencoder)}, - sequence_model: %{alive: process_alive?(SequenceModel)}, + # Actual cohort identity has already passed ensure_cohort!/1. + gnn: %{alive: process_alive?(state.named_networks.gnn)}, + vae: %{alive: process_alive?(state.named_networks.vae)}, + sequence_model: %{alive: process_alive?(state.named_networks.sequence)}, # Blackboard health - blackboard: %{alive: Blackboard.alive?()}, + blackboard: %{alive: process_alive?(state.blackboard)}, # Lifecycle cycle_count: state.cycle_count, last_cycle: state.last_cycle, total_rounds: state.total_rounds } - {:reply, report, state} + Map.merge(report, SourceStore.diagnostic(state)) end @impl true - def handle_cast({:outcome, finding, outcome}, state) do + def handle_info({:source_bound, source, event}, state) do + SourceStore.noreply(state, source, fn -> + ensure_cohort!(state) + updated = apply_event(event, state) + ensure_cohort!(updated) + {:ok, updated} + end) + end + + def handle_info(_unbound_message, state) do + Logger.warning("Neural coordinator refused an unbound message") + {:noreply, SourceStore.refuse(state, :unbound_request)} + end + + @impl true + def handle_cast(_unbound_message, state) do + Logger.warning("Neural coordinator refused an unbound cast") + {:noreply, SourceStore.refuse(state, :unbound_request)} + end + + defp apply_event({:outcome, finding, outcome}, state) do # Update MoE atom_outcome = case outcome do @@ -541,18 +662,20 @@ defmodule Hypatia.Neural.Coordinator do {_pred, updated_esn} = EchoStateNetwork.step(state.esn, confidence) # Feed decision to sequence model for trace learning - SequenceModel.observe(%{finding: finding, outcome: outcome, confidence: confidence}) + SourceStore.call!(state.named_networks.sequence, { + :observe, state.source_store, + %{finding: finding, outcome: outcome, confidence: confidence} + }) - {:noreply, %{state | moe: updated_moe, lsm: updated_lsm, esn: updated_esn}} + %{state | moe: updated_moe, lsm: updated_lsm, esn: updated_esn} end - @impl true - def handle_cast(:force_cycle, state) do + defp apply_event(:force_cycle, state) do cycle = state.cycle_count + 1 Logger.info("Neural Coordinator: forcing learning cycle ##{cycle}") # Rebuild trust graph from latest data - updated_graph = GraphOfTrust.build() + updated_graph = GraphOfTrust.build_from_store(state.source_store) # M9 — Strategy selection for ESN rebalancing. Three operational # modes, configurable via :hypatia, :neural_rebalance_strategy: @@ -572,11 +695,12 @@ defmodule Hypatia.Neural.Coordinator do trained_esn = Hypatia.Neural.TrainingPipeline.train_esn( state.esn, - rebalance_strategy: rebalance_strategy + rebalance_strategy: rebalance_strategy, + data_path: state.source_store ) # Train RBF on pattern registry data - trained_rbf = Hypatia.Neural.TrainingPipeline.train_rbf(state.rbf) + trained_rbf = Hypatia.Neural.TrainingPipeline.train_rbf(state.rbf, data_path: state.source_store) updated_state = %{ state @@ -588,9 +712,9 @@ defmodule Hypatia.Neural.Coordinator do } # Persist all neural states to flat files - Hypatia.Neural.Persistence.save_all(updated_state) + SourceStore.accept!(Persistence.save_all(updated_state)) - {:noreply, updated_state} + updated_state end # ── Phased Blackboard Execution ───────────────────────── @@ -598,10 +722,10 @@ defmodule Hypatia.Neural.Coordinator do @doc false defp run_phased_analysis(finding, state) do # Start a new blackboard round - {:ok, _round} = Blackboard.start_round() + SourceStore.accept!(Blackboard.start_round_source(state.source_store, state.blackboard)) # Write the input finding to the blackboard - Blackboard.write(:input, :finding, finding) + write_board!(state, :input, :finding, finding) # Phase 1: RBF (novelty detection) -- sequential, everything depends on this state = run_phase_1_rbf(finding, state) @@ -616,13 +740,13 @@ defmodule Hypatia.Neural.Coordinator do state = run_phase_4_temporal(finding, state) # Phase 5: VAE (interpretation clustering) -- reads everything - run_phase_5_vae(finding) + run_phase_5_vae(finding, state) # Phase 6: Sequence model (choreography) -- reads everything - run_phase_6_sequence(finding) + run_phase_6_sequence(finding, state) # End the round and capture the reasoning trace - {:ok, trace} = Blackboard.end_round() + trace = SourceStore.accept!(Blackboard.end_round_source(state.source_store, state.blackboard)) updated_state = %{state | total_rounds: state.total_rounds + 1, last_round_trace: trace} {trace, updated_state} @@ -641,8 +765,8 @@ defmodule Hypatia.Neural.Coordinator do is_novel: match?({:novel, _}, novelty) } - Blackboard.write(:rbf, :novelty_result, result) - Blackboard.write(:rbf, :novelty, %{score: rbf_confidence, status: novelty_status(novelty)}) + write_board!(state, :rbf, :novelty_result, result) + write_board!(state, :rbf, :novelty, %{score: rbf_confidence, status: novelty_status(novelty)}) state end @@ -650,7 +774,7 @@ defmodule Hypatia.Neural.Coordinator do # Phase 2: PageRank + GNN (parallel) defp run_phase_2_graph(finding, state) do tasks = [ - Task.async(fn -> + guarded_task(fn -> # PageRank: trust-weighted routing trusted_bots = GraphOfTrust.trusted_bots(state.trust_graph) trusted_recipes = GraphOfTrust.trusted_recipes(state.trust_graph) @@ -661,16 +785,16 @@ defmodule Hypatia.Neural.Coordinator do repos_needing_attention: length(GraphOfTrust.repos_needing_attention(state.trust_graph)) } - Blackboard.write(:pagerank, :trust_result, trust_result) + write_board!(state, :pagerank, :trust_result, trust_result) end), - Task.async(fn -> + guarded_task(fn -> # GNN: agent interaction patterns (calls the GNN GenServer) - GraphNeuralNetwork.analyse(finding) + SourceStore.call!(state.named_networks.gnn, {:analyse, state.source_store, finding, state.blackboard}) end) ] # Await both parallel tasks (5s timeout each) - Task.await_many(tasks, 5_000) + tasks |> Task.await_many(5_000) |> Enum.each(&SourceStore.accept!/1) :ok end @@ -684,7 +808,7 @@ defmodule Hypatia.Neural.Coordinator do top_domain: List.first(experts) } - Blackboard.write(:moe, :domain_confidence, domain_result) + write_board!(state, :moe, :domain_confidence, domain_result) state end @@ -692,14 +816,15 @@ defmodule Hypatia.Neural.Coordinator do defp run_phase_4_temporal(finding, state) do # Read MoE confidence from blackboard for ESN input moe_confidence = - case Blackboard.read(:moe, :domain_confidence) do + case Blackboard.read_source(state.source_store, :moe, :domain_confidence, state.blackboard) do {:ok, %{confidence: c}} -> c - _ -> 0.5 + :not_found -> 0.5 + error -> SourceStore.accept!(error) end # Run ESN and LSM in parallel, but we need the updated state back esn_task = - Task.async(fn -> + guarded_task(fn -> {esn_pred, updated_esn} = EchoStateNetwork.step(state.esn, moe_confidence) drift = EchoStateNetwork.detect_drift(updated_esn) @@ -709,12 +834,12 @@ defmodule Hypatia.Neural.Coordinator do input_confidence: moe_confidence } - Blackboard.write(:esn, :trajectory_result, trajectory_result) + write_board!(state, :esn, :trajectory_result, trajectory_result) updated_esn end) lsm_task = - Task.async(fn -> + guarded_task(fn -> updated_lsm = LiquidStateMachine.process_event(state.lsm, finding) {lsm_score, lsm_status} = LiquidStateMachine.predict(updated_lsm) anomalies = LiquidStateMachine.detect_anomalies(updated_lsm) @@ -725,28 +850,44 @@ defmodule Hypatia.Neural.Coordinator do anomaly_count: length(anomalies) } - Blackboard.write(:lsm, :temporal_result, temporal_result) + write_board!(state, :lsm, :temporal_result, temporal_result) updated_lsm end) - updated_esn = Task.await(esn_task, 5_000) - updated_lsm = Task.await(lsm_task, 5_000) + updated_esn = SourceStore.accept!(Task.await(esn_task, 5_000)) + updated_lsm = SourceStore.accept!(Task.await(lsm_task, 5_000)) %{state | esn: updated_esn, lsm: updated_lsm} end - # Phase 5: VAE interpretation clustering - defp run_phase_5_vae(finding) do - # VAE is a GenServer -- calls it to cluster the finding - VariationalAutoencoder.cluster(finding) - :ok + # Named models receive both the captured source and this cohort's board. + defp run_phase_5_vae(finding, state) do + SourceStore.call!(state.named_networks.vae, {:cluster, state.source_store, finding, state.blackboard}) end - # Phase 6: Sequence model choreography prediction - defp run_phase_6_sequence(finding) do - # Sequence model is a GenServer -- calls it to predict next step - SequenceModel.predict(finding) - :ok + defp run_phase_6_sequence(finding, state) do + SourceStore.call!(state.named_networks.sequence, {:predict, state.source_store, finding, state.blackboard}) + end + + defp write_board!(state, network, key, value) do + SourceStore.accept!( + Blackboard.write_source(state.source_store, network, key, value, state.blackboard) + ) + end + + # Preserve parallel phases while returning task failures to the lifecycle + # guard. A failed peer must not disappear from an otherwise accepted trace. + defp guarded_task(operation) do + Task.async(fn -> + try do + {:ok, operation.()} + rescue + error in SourceStore.Refusal -> {:error, error.reason} + error -> {:error, {:neural_task_failed, Exception.message(error)}} + catch + :exit, reason -> {:error, {:neural_task_unavailable, reason}} + end + end) end # ── Confidence Derivation ─────────────────────────────── diff --git a/lib/neural/graph_neural_network.ex b/lib/neural/graph_neural_network.ex index a117589f..a424733f 100644 --- a/lib/neural/graph_neural_network.ex +++ b/lib/neural/graph_neural_network.ex @@ -21,6 +21,8 @@ defmodule Hypatia.Neural.GraphNeuralNetwork do use GenServer + alias Hypatia.Neural.{Blackboard, SourceStore} + defstruct [ # %{agent_id => [Float]} :node_embeddings, @@ -31,7 +33,10 @@ defmodule Hypatia.Neural.GraphNeuralNetwork do # number of message-passing layers :layer_count, # dimension of node embeddings - :embedding_dim + :embedding_dim, + source_store: nil, + source_status: :ready, + source_error: nil ] def start_link(opts \\ []) do @@ -41,6 +46,7 @@ defmodule Hypatia.Neural.GraphNeuralNetwork do @impl true def init(_opts) do state = %__MODULE__{ + source_store: SourceStore.current(), node_embeddings: %{}, edge_weights: %{}, clusters: %{}, @@ -53,41 +59,90 @@ defmodule Hypatia.Neural.GraphNeuralNetwork do @doc "Analyse agent interaction graph and write to blackboard." def analyse(finding) do - GenServer.call(__MODULE__, {:analyse, finding}) + GenServer.call(__MODULE__, {:analyse, SourceStore.current(), finding, Blackboard}) end @doc "Snapshot serialisable state for M17 persistence." def state, do: GenServer.call(__MODULE__, :get_state) @doc "Restore state from a previously snapshotted map." - def restore(map) when is_map(map), do: GenServer.call(__MODULE__, {:restore, map}) - def restore(_), do: :ok + def restore(map) when is_map(map), do: GenServer.call(__MODULE__, {:restore, SourceStore.current(), map}) + def restore(_), do: {:error, :unbound_snapshot} @impl true - def handle_call({:analyse, finding}, _from, state) do - # Read novelty from blackboard (Phase 1 output) - novelty = - case Hypatia.Neural.Blackboard.read(:rbf, :novelty) do - {:ok, v} -> v - :not_found -> %{score: 0.5, status: :unknown} - end - - # Compute graph metrics - result = %{ - cluster: Map.get(state.clusters, finding[:agent_id], 0), - centrality: compute_centrality(finding[:agent_id], state), - interaction_strength: 0.5, - novelty_aware: novelty - } + def handle_call({:analyse, source, finding, blackboard}, _from, state) do + SourceStore.reply(state, source, fn -> + # Read novelty from blackboard (Phase 1 output) + novelty = + case Blackboard.read_source(source, :rbf, :novelty, blackboard) do + {:ok, v} -> v + :not_found -> %{score: 0.5, status: :unknown} + error -> SourceStore.accept!(error) + end + + # Compute graph metrics + result = %{ + cluster: Map.get(state.clusters, finding[:agent_id], 0), + centrality: compute_centrality(finding[:agent_id], state), + interaction_strength: 0.5, + novelty_aware: novelty + } + + # Write to blackboard + SourceStore.accept!(Blackboard.write_source(source, :gnn, :graph_metrics, result, blackboard)) + SourceStore.accept!(Blackboard.write_source(source, :gnn, :cluster, result.cluster, blackboard)) + + {result, state} + end) + end + + def handle_call(:get_state, _from, state) do + state = SourceStore.observe(state) + {:reply, snapshot(state), state} + end - # Write to blackboard - Hypatia.Neural.Blackboard.write(:gnn, :graph_metrics, result) - Hypatia.Neural.Blackboard.write(:gnn, :cluster, result.cluster) + def handle_call({:snapshot, source}, _from, state) do + SourceStore.reply(state, source, fn -> {{:ok, snapshot(state)}, state} end) + end - {:reply, result, state} + def handle_call({:restore, source, map}, _from, state) do + SourceStore.reply(state, source, fn -> + unless SourceStore.snapshot_matches?(map, source), + do: raise(SourceStore.Refusal, reason: :unbound_or_foreign_snapshot) + + edges = + (Map.get(map, "edge_weights", []) || []) + |> Enum.reduce(%{}, fn + %{"from" => f, "to" => t, "weight" => w}, acc when is_number(w) -> + Map.put(acc, {f, t}, w) + + _, acc -> + acc + end) + + node_embeddings = + Map.get(map, "node_embeddings", state.node_embeddings) || state.node_embeddings + + clusters = Map.get(map, "clusters", state.clusters) || state.clusters + + restored = %{ + state + | node_embeddings: node_embeddings, + edge_weights: edges, + clusters: clusters, + layer_count: Map.get(map, "layer_count", state.layer_count), + embedding_dim: Map.get(map, "embedding_dim", state.embedding_dim) + } + + {:ok, restored} + end) end - def handle_call(:get_state, _from, state) do + def handle_call(_unbound_request, _from, state) do + {:reply, {:error, :unbound_request}, SourceStore.refuse(state, :unbound_request)} + end + + defp snapshot(state) do snap = %{ "node_embeddings" => stringify_keys(state.node_embeddings), "edge_weights" => @@ -100,35 +155,7 @@ defmodule Hypatia.Neural.GraphNeuralNetwork do "trained" => map_size(state.node_embeddings) > 0 } - {:reply, snap, state} - end - - def handle_call({:restore, map}, _from, state) do - edges = - (Map.get(map, "edge_weights", []) || []) - |> Enum.reduce(%{}, fn - %{"from" => f, "to" => t, "weight" => w}, acc when is_number(w) -> - Map.put(acc, {f, t}, w) - - _, acc -> - acc - end) - - node_embeddings = - Map.get(map, "node_embeddings", state.node_embeddings) || state.node_embeddings - - clusters = Map.get(map, "clusters", state.clusters) || state.clusters - - restored = %{ - state - | node_embeddings: node_embeddings, - edge_weights: edges, - clusters: clusters, - layer_count: Map.get(map, "layer_count", state.layer_count), - embedding_dim: Map.get(map, "embedding_dim", state.embedding_dim) - } - - {:reply, :ok, restored} + SourceStore.tag(snap, state) end defp stringify_keys(map) when is_map(map) do diff --git a/lib/neural/graph_of_trust.ex b/lib/neural/graph_of_trust.ex index a53305a8..3909fc7d 100644 --- a/lib/neural/graph_of_trust.ex +++ b/lib/neural/graph_of_trust.ex @@ -12,10 +12,12 @@ defmodule Hypatia.Neural.GraphOfTrust do - Contributors: trust score based on fix quality Trust propagation uses a PageRank-style iterative algorithm: - - Successful fixes propagate trust forward - - Failures reduce trust - - False positives reduce trust more heavily - - Trust decays over time (recency weighting) + - Qualified successful fixes propagate positive support + - Inventory-only and negative-only entities remain neutral (0.5) + - Negative outcomes remain visible but do not create positive edges + + Scores are relative graph rankings, not calibrated success probabilities or + authenticated proof. Negative penalties and time decay are not implemented. The graph enables: - Routing dispatches to trusted bots @@ -26,7 +28,8 @@ defmodule Hypatia.Neural.GraphOfTrust do require Logger - @verisimdb_data_path Application.compile_env(:hypatia, :verisimdb_data_path, "data/verisim") + alias Hypatia.OutcomeTracker + @damping_factor 0.85 @convergence_threshold 0.001 @max_iterations 50 @@ -41,10 +44,40 @@ defmodule Hypatia.Neural.GraphOfTrust do # --- Public API --- - @doc "Build the trust graph from outcome data, including cross-repo edges." - def build do - outcomes = load_all_outcomes() - recipes = load_all_recipes() + @doc """ + Build the trust graph from qualified outcome data, including cross-repo edges. + + Unqualified observations remain in the outcome log but contribute neither + outcome-derived nodes nor edges to the graph. Outcome subjects must have + nonblank string repo, bot and recipe identity (recipe_id or canonical pattern). + Missing and malformed subjects remain audit rows and create no partial edges. + Qualified negative evidence + retains the existing graph policy: it does not create positive edges. + """ + def build, do: build_from_store(Hypatia.Paths.verisimdb_data()) + + @doc "Build trust and language context from one captured source directory." + def build_from_store(data_root) do + source = Path.expand(data_root) + build_qualified(load_all_outcomes(source), load_all_recipes(source), source) + end + + @doc false + def build(outcomes, recipes) when is_list(outcomes) and is_list(recipes) do + build_qualified(outcomes, recipes, Path.expand(Hypatia.Paths.verisimdb_data())) + end + + defp build_qualified(outcomes, recipes, source) do + # Qualify once before every graph consumer. Filtering only cross-repo + # edges would still let unsupported observations affect direct edges and + # trust normalization through extra nodes. + outcomes = + outcomes + |> Enum.filter(fn outcome -> + is_map(outcome) and OutcomeTracker.qualified_outcome?(outcome) and + outcome_identities?(outcome) + end) + |> Enum.map(fn outcome -> Map.put(outcome, "recipe_id", outcome_recipe_id(outcome)) end) nodes = build_nodes(outcomes, recipes) edges = build_edges(outcomes) @@ -54,7 +87,7 @@ defmodule Hypatia.Neural.GraphOfTrust do cross_repo_edges = build_cross_repo_edges(outcomes) # Build language clusters: group repos by primary language family - language_clusters = build_language_clusters(nodes) + language_clusters = build_language_clusters(nodes, source) graph = %__MODULE__{ nodes: nodes, @@ -150,11 +183,37 @@ defmodule Hypatia.Neural.GraphOfTrust do end defp compute_trust(%__MODULE__{nodes: nodes, edges: edges} = graph) do - n = map_size(nodes) + # Cataloguing a recipe, or observing only failures, is not positive + # evidence. PageRank's teleportation mass must not promote such nodes to + # 1.0 through maximum-score normalization or perturb supported rankings. + neutral_scores = Map.new(nodes, fn {id, _type} -> {id, 0.5} end) + + supported_edges = + Enum.filter(edges, fn {source, target, weight} -> + is_number(weight) and weight > 0 and Map.has_key?(nodes, source) and + Map.has_key?(nodes, target) + end) - # Initialize all nodes to 1/n + supported_nodes = + supported_edges + |> Enum.flat_map(fn {source, target, _weight} -> [source, target] end) + |> MapSet.new() + + supported_scores = compute_supported_trust(supported_nodes, supported_edges) + + %{ + graph + | trust_scores: Map.merge(neutral_scores, supported_scores), + last_computed: DateTime.utc_now() + } + end + + defp compute_supported_trust(supported_nodes, edges) do + n = MapSet.size(supported_nodes) + + # Only the qualified positive-support subgraph receives ranking mass. initial_score = 1.0 / max(n, 1) - scores = Map.new(Map.keys(nodes), fn id -> {id, initial_score} end) + scores = Map.new(supported_nodes, fn id -> {id, initial_score} end) # Build adjacency: source -> [{target, weight}] adjacency = @@ -163,9 +222,7 @@ defmodule Hypatia.Neural.GraphOfTrust do end) # Iterative convergence - final_scores = iterate_trust(scores, adjacency, n, 0) - - %{graph | trust_scores: final_scores, last_computed: DateTime.utc_now()} + if n == 0, do: %{}, else: iterate_trust(scores, adjacency, n, 0) end defp iterate_trust(scores, _adjacency, _n, iteration) when iteration >= @max_iterations do @@ -239,9 +296,22 @@ defmodule Hypatia.Neural.GraphOfTrust do # --- Graph Construction --- + # Evidence must identify each actual graph subject. A qualified verdict + # alone cannot authorize a fabricated "unknown" node or partial edge. + # Canonical pattern is an explicit recipe identity, not a synthesized default. + defp outcome_identities?(outcome) do + Enum.all?( + [Map.get(outcome, "repo"), Map.get(outcome, "bot"), outcome_recipe_id(outcome)], + fn value -> is_binary(value) and String.trim(value) != "" end + ) + end + + defp outcome_recipe_id(outcome), + do: Map.get(outcome, "recipe_id") || Map.get(outcome, "pattern") + defp build_nodes(outcomes, recipes) do - repo_nodes = outcomes |> Enum.map(fn o -> {Map.get(o, "repo"), :repo} end) - bot_nodes = outcomes |> Enum.map(fn o -> {Map.get(o, "bot", "unknown"), :bot} end) + repo_nodes = outcomes |> Enum.map(fn o -> {Map.fetch!(o, "repo"), :repo} end) + bot_nodes = outcomes |> Enum.map(fn o -> {Map.fetch!(o, "bot"), :bot} end) recipe_nodes = recipes |> Enum.map(fn r -> {Map.get(r, "id"), :recipe} end) (repo_nodes ++ bot_nodes ++ recipe_nodes) @@ -265,9 +335,9 @@ defmodule Hypatia.Neural.GraphOfTrust do Enum.flat_map(outcomes, fn outcome -> case Map.get(outcome, "outcome") do "success" -> - repo = Map.get(outcome, "repo", "unknown") - bot = Map.get(outcome, "bot", "unknown") - recipe = Map.get(outcome, "recipe_id", Map.get(outcome, "pattern", "unknown")) + repo = Map.fetch!(outcome, "repo") + bot = Map.fetch!(outcome, "bot") + recipe = Map.fetch!(outcome, "recipe_id") [ # recipe fixes repo @@ -294,16 +364,15 @@ defmodule Hypatia.Neural.GraphOfTrust do repo_recipes = outcomes |> Enum.filter(&(Map.get(&1, "outcome") == "success")) - |> Enum.group_by(&Map.get(&1, "repo", "unknown")) + |> Enum.group_by(&Map.fetch!(&1, "repo")) |> Enum.map(fn {repo, repo_outcomes} -> recipes = repo_outcomes - |> Enum.map(&Map.get(&1, "recipe_id", Map.get(&1, "pattern", "unknown"))) + |> Enum.map(&Map.fetch!(&1, "recipe_id")) |> MapSet.new() {repo, recipes} end) - |> Enum.reject(fn {repo, _} -> repo == "unknown" end) # Build pairwise edges based on recipe overlap (Jaccard similarity) for {repo_a, recipes_a} <- repo_recipes, @@ -326,7 +395,7 @@ defmodule Hypatia.Neural.GraphOfTrust do # Build language clusters from repo scan data. # Groups repos by their detected primary language family. - defp build_language_clusters(nodes) do + defp build_language_clusters(nodes, source) do language_families = Hypatia.CrossRepoLearning.language_families() repos = @@ -336,7 +405,7 @@ defmodule Hypatia.Neural.GraphOfTrust do repos |> Enum.reduce(%{}, fn repo, clusters -> - lang = detect_repo_language(repo) + lang = detect_repo_language(repo, source) family = Map.get(language_families, lang, :unknown) if family != :unknown do @@ -348,8 +417,8 @@ defmodule Hypatia.Neural.GraphOfTrust do end # Detect primary language for a repo from scan data - defp detect_repo_language(repo) do - scan_path = Path.join([Path.expand(@verisimdb_data_path), "scans", "#{repo}.json"]) + defp detect_repo_language(repo, source) do + scan_path = Path.join([source, "scans", "#{repo}.json"]) case File.read(scan_path) do {:ok, content} -> @@ -367,8 +436,8 @@ defmodule Hypatia.Neural.GraphOfTrust do end end - defp load_all_outcomes do - outcomes_dir = Path.join(Path.expand(@verisimdb_data_path), "outcomes") + defp load_all_outcomes(source) do + outcomes_dir = Path.join(source, "outcomes") case File.ls(outcomes_dir) do {:ok, files} -> @@ -396,8 +465,8 @@ defmodule Hypatia.Neural.GraphOfTrust do end end - defp load_all_recipes do - recipes_dir = Path.join(Path.expand(@verisimdb_data_path), "recipes") + defp load_all_recipes(source) do + recipes_dir = Path.join(source, "recipes") case File.ls(recipes_dir) do {:ok, files} -> diff --git a/lib/neural/persistence.ex b/lib/neural/persistence.ex index 254d5656..9fc9f701 100644 --- a/lib/neural/persistence.ex +++ b/lib/neural/persistence.ex @@ -17,54 +17,144 @@ defmodule Hypatia.Neural.Persistence do On startup: 1. Load saved state from flat files - 2. Initialize networks with saved weights (or defaults) + 2. Initialize learned networks only with matching source-labelled weights + (or defaults); unbound and foreign snapshots remain diagnostic + 3. Rebuild trust from current qualified outcomes; its snapshots are diagnostic + records and are never restored as authoritative scores """ require Logger - @state_dir Application.compile_env(:hypatia, :verisimdb_data_path, "data/verisim") - |> Path.join("neural-states") - - @doc "Save all neural network states" - def save_all(coordinator_state) do - ensure_state_dir() - - save_trust_graph(coordinator_state.trust_graph) - save_moe(coordinator_state.moe) - save_lsm_summary(coordinator_state.lsm) - save_esn_summary(coordinator_state.esn) - save_rbf(coordinator_state.rbf) - - # M17 — new networks (GNN / VAE / Seq) are supervised GenServers, - # not fields on the coordinator's struct. Pull their state via the - # documented `state/0` accessor and persist alongside the original - # five. Best-effort: missing process / dead network skips silently - # so persistence can't kill the coordinator on shutdown. - save_named_network("gnn", Hypatia.Neural.GraphNeuralNetwork) - save_named_network("vae", Hypatia.Neural.VariationalAutoencoder) - save_named_network("sequence", Hypatia.Neural.SequenceModel) - - Logger.info("Neural states persisted (cycle #{coordinator_state.cycle_count})") - end + alias Hypatia.Neural.SourceStore - @doc "Load all neural network states (returns map of loaded data or :not_found)" - def load_all do + def default_networks do %{ - trust: load_state("trust"), - moe: load_state("moe"), - lsm: load_state("lsm"), - esn: load_state("esn"), - rbf: load_state("rbf"), - gnn: load_state("gnn"), - vae: load_state("vae"), - sequence: load_state("sequence") + gnn: Hypatia.Neural.GraphNeuralNetwork, + vae: Hypatia.Neural.VariationalAutoencoder, + sequence: Hypatia.Neural.SequenceModel + } + end + + @doc """ + Save a source-bound coordinator and all three matching named networks. + + Every named snapshot is collected and checked, and every document encoded, + before any file is written. A refusal or missing process returns {:error, + reason}. Writes use the captured source directory, never a freshly resolved + runtime destination. Filesystem writes can still fail partway through a + bundle; such an error is returned, not logged as successful persistence. + """ + def save_all(coordinator_state) when is_map(coordinator_state) do + source = Map.get(coordinator_state, :source_store) + networks = Map.get(coordinator_state, :named_networks, default_networks()) + + with :ok <- SourceStore.check(coordinator_state, source), + {:ok, named} <- collect_named(source, networks), + {:ok, encoded} <- encode_states(coordinator_state, named), + :ok <- SourceStore.check(coordinator_state, source), + :ok <- write_states(source, encoded) do + Logger.info("Neural states persisted (cycle #{coordinator_state.cycle_count})") + :ok + else + {:error, reason} = error -> + Logger.warning("Neural persistence refused: #{inspect(reason)}") + error + end + end + + def save_all(_), do: {:error, :unbound_source_store} + + @doc """ + Read raw diagnostic snapshots from one captured directory. + + This does not qualify a model for hydration. Legacy and foreign-labelled + snapshots are retained and returned verbatim for diagnosis. + """ + def load_all(source \\ SourceStore.current()) do + for name <- [:trust, :moe, :lsm, :esn, :rbf, :gnn, :vae, :sequence], into: %{} do + {name, load_state(source, Atom.to_string(name))} + end + end + + @doc false + def collect_named(source, networks) when is_map(networks) do + Enum.reduce_while([:gnn, :vae, :sequence], {:ok, %{}}, fn name, {:ok, snapshots} -> + result = + try do + case Map.fetch(networks, name) do + {:ok, server} -> GenServer.call(server, {:snapshot, source}) + :error -> {:error, :missing_network} + end + catch + :exit, reason -> {:error, {:process_unavailable, reason}} + end + + case result do + {:ok, snapshot} when is_map(snapshot) -> + if SourceStore.snapshot_matches?(snapshot, source) do + {:cont, {:ok, Map.put(snapshots, Atom.to_string(name), snapshot)}} + else + {:halt, {:error, {:cohort_source_mismatch, name, :unqualified_snapshot}}} + end + + {:error, reason} -> + {:halt, {:error, {:cohort_source_mismatch, name, reason}}} + + other -> + {:halt, {:error, {:cohort_source_mismatch, name, {:unexpected_reply, other}}}} + end + end) + end + + def collect_named(_source, _networks), do: {:error, {:cohort_source_mismatch, :all, :invalid_cohort}} + + defp encode_states(state, named) do + snapshots = %{ + "trust" => snapshot_trust_graph(state.trust_graph), + "moe" => snapshot_moe(state.moe), + "lsm" => snapshot_lsm_summary(state.lsm), + "esn" => snapshot_esn_summary(state.esn), + "rbf" => snapshot_rbf(state.rbf) } + + snapshots + |> Map.new(fn {name, data} -> {name, SourceStore.tag(data, state)} end) + |> Map.merge(named) + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.reduce_while({:ok, []}, fn {name, data}, {:ok, encoded} -> + case Jason.encode(data, pretty: true) do + {:ok, json} -> {:cont, {:ok, [{name, json} | encoded]}} + {:error, reason} -> {:halt, {:error, {:snapshot_encoding, name, reason}}} + end + end) + rescue + error -> {:error, {:snapshot_encoding, Exception.message(error)}} + end + + defp write_states(source, encoded) do + directory = Path.join(source, "neural-states") + + with :ok <- File.mkdir_p(directory) do + Enum.reduce_while(encoded, :ok, fn {name, json}, :ok -> + path = Path.join(directory, "#{name}.json") + + case File.write(path, json) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, {:snapshot_write, name, reason}}} + end + end) + else + {:error, reason} -> {:error, {:snapshot_directory, reason}} + end end # --- Trust Graph --- - defp save_trust_graph(graph) do + defp snapshot_trust_graph(graph) do data = %{ + "schema_version" => 2, + "score_basis" => "qualified_positive_support_subgraph", + "restore_policy" => "recompute_from_current_qualified_outcomes", "nodes" => Enum.map(graph.nodes, fn {id, type} -> %{"id" => id, "type" => to_string(type)} end), "edges" => @@ -76,12 +166,12 @@ defmodule Hypatia.Neural.Persistence do if(graph.last_computed, do: DateTime.to_iso8601(graph.last_computed), else: nil) } - save_state("trust", data) + data end # --- Mixture of Experts --- - defp save_moe(moe) do + defp snapshot_moe(moe) do data = %{ "gating_weights" => Enum.map(moe.gating_weights, fn {k, v} -> %{"domain" => to_string(k), "weight" => v} end), @@ -101,12 +191,12 @@ defmodule Hypatia.Neural.Persistence do "last_trained" => if(moe.last_trained, do: DateTime.to_iso8601(moe.last_trained), else: nil) } - save_state("moe", data) + data end # --- LSM (save summary, not full reservoir) --- - defp save_lsm_summary(lsm) do + defp snapshot_lsm_summary(lsm) do summary = Hypatia.Neural.LiquidStateMachine.temporal_summary(lsm) anomalies = Hypatia.Neural.LiquidStateMachine.detect_anomalies(lsm) @@ -117,12 +207,12 @@ defmodule Hypatia.Neural.Persistence do "trained" => lsm.trained } - save_state("lsm", data) + data end # --- ESN (save accuracy and drift) --- - defp save_esn_summary(esn) do + defp snapshot_esn_summary(esn) do accuracy = Hypatia.Neural.EchoStateNetwork.accuracy_report(esn) drift = Hypatia.Neural.EchoStateNetwork.detect_drift(esn) @@ -133,12 +223,12 @@ defmodule Hypatia.Neural.Persistence do "predictions_count" => length(esn.predictions) } - save_state("esn", data) + data end # --- RBF --- - defp save_rbf(rbf) do + defp snapshot_rbf(rbf) do data = %{ "centers" => rbf.centers, "widths" => rbf.widths, @@ -149,41 +239,13 @@ defmodule Hypatia.Neural.Persistence do "training_stats" => rbf.training_stats } - save_state("rbf", data) - end - - # --- M17 helper: save a GenServer-backed network by name --- - - defp save_named_network(filename, module) do - case Process.whereis(module) do - nil -> - :ok - - _ -> - try do - snap = module.state() - save_state(filename, snap) - rescue - e -> Logger.warning("Persistence: #{module}.state/0 failed: #{Exception.message(e)}") - catch - _, _ -> :ok - end - end + data end - # --- Generic Save/Load --- + # --- Raw Diagnostic Read --- - defp save_state(network, data) do - path = Path.join(Path.expand(@state_dir), "#{network}.json") - - case Jason.encode(data, pretty: true) do - {:ok, json} -> File.write(path, json) - _ -> :error - end - end - - defp load_state(network) do - path = Path.join(Path.expand(@state_dir), "#{network}.json") + defp load_state(source, network) do + path = Path.join([source, "neural-states", "#{network}.json"]) case File.read(path) do {:ok, content} -> @@ -196,9 +258,4 @@ defmodule Hypatia.Neural.Persistence do :not_found end end - - defp ensure_state_dir do - dir = Path.expand(@state_dir) - File.mkdir_p(dir) - end end diff --git a/lib/neural/rebalancer.ex b/lib/neural/rebalancer.ex index d3d3e76e..cc57b7b5 100644 --- a/lib/neural/rebalancer.ex +++ b/lib/neural/rebalancer.ex @@ -33,6 +33,8 @@ defmodule Hypatia.Neural.Rebalancer do `:erlang.system_time(:millisecond)` so every training run varies. """ + alias Hypatia.{OutcomeTracker, Paths} + @doc """ Inject synthetic regression events into a confidence time series. @@ -194,8 +196,8 @@ defmodule Hypatia.Neural.Rebalancer do Strategy C: build failure templates from real outcome records and inject them into a confidence time series. - Reads all `outcomes/*.jsonl` files from `data_path` (defaults to - `data/verisim`). For each real failure record, derives a dip template: + Reads all `outcomes/*.jsonl` files from `data_path` (defaults to the + runtime verisim data store). For each qualified failure record, derives a dip template: depth is drawn from the empirical distribution of failure frequencies per recipe, and duration is mapped from the failure reason. @@ -204,7 +206,7 @@ defmodule Hypatia.Neural.Rebalancer do Options: * `:data_path` — path to the verisim data directory - (default `"data/verisim"`) + (default: the runtime verisim data store) * `:max_events` — cap on injected events (default `5`) * `:seed` — integer seed for reproducibility """ @@ -232,10 +234,12 @@ defmodule Hypatia.Neural.Rebalancer do Build failure templates from the real outcome corpus. Returns a (possibly empty) list of `%{depth: float, duration: integer}` - maps, one per failure record found in `data_path/outcomes/*.jsonl`. + maps, one per failure or false-positive record qualified by OutcomeTracker + in `data_path/outcomes/*.jsonl`. An explicit data_path overrides the runtime + store. Unqualified historical rows remain audit data, not training evidence. Pass this list to `corpus_esn_series/3`. """ - def load_failure_templates(data_path \\ "data/verisim") do + def load_failure_templates(data_path \\ Paths.verisimdb_data()) do outcomes_dir = Path.join(Path.expand(data_path), "outcomes") case File.ls(outcomes_dir) do @@ -253,7 +257,11 @@ defmodule Hypatia.Neural.Rebalancer do case Jason.decode(line) do {:ok, %{"outcome" => outcome} = record} when outcome in ["failure", "false_positive"] -> - [failure_template(record)] + if OutcomeTracker.qualified_outcome?(record) do + [failure_template(record)] + else + [] + end _ -> [] diff --git a/lib/neural/sequence_model.ex b/lib/neural/sequence_model.ex index a0969785..ae2004cd 100644 --- a/lib/neural/sequence_model.ex +++ b/lib/neural/sequence_model.ex @@ -21,6 +21,8 @@ defmodule Hypatia.Neural.SequenceModel do use GenServer + alias Hypatia.Neural.{Blackboard, SourceStore} + defstruct [ # current hidden state :hidden_state, @@ -31,7 +33,10 @@ defmodule Hypatia.Neural.SequenceModel do # maximum sequence length :max_sequence, # trained weights (nil until trained) - :weights + :weights, + source_store: nil, + source_status: :ready, + source_error: nil ] def start_link(opts \\ []) do @@ -41,6 +46,7 @@ defmodule Hypatia.Neural.SequenceModel do @impl true def init(_opts) do state = %__MODULE__{ + source_store: SourceStore.current(), hidden_state: nil, hidden_dim: 32, sequence_buffer: [], @@ -53,12 +59,12 @@ defmodule Hypatia.Neural.SequenceModel do @doc "Predict the next decision based on trace history." def predict(finding) do - GenServer.call(__MODULE__, {:predict, finding}) + GenServer.call(__MODULE__, {:predict, SourceStore.current(), finding, Blackboard}) end @doc "Append a decision to the sequence buffer (for training)." def observe(decision) do - GenServer.cast(__MODULE__, {:observe, decision}) + GenServer.call(__MODULE__, {:observe, SourceStore.current(), decision}) end @doc """ @@ -72,40 +78,84 @@ defmodule Hypatia.Neural.SequenceModel do @doc """ Restore network state from a previously snapshotted map. Fields - not present in the map keep their fresh-init defaults. Silently - ignored if no data is provided. + not present in the map keep their fresh-init defaults. Unbound or + foreign-source maps are refused and remain available through raw diagnostics. """ def restore(map) when is_map(map) do - GenServer.call(__MODULE__, {:restore, map}) + GenServer.call(__MODULE__, {:restore, SourceStore.current(), map}) end - def restore(_), do: :ok + def restore(_), do: {:error, :unbound_snapshot} @impl true - def handle_call({:predict, _finding}, _from, state) do - # Read full blackboard context - _blackboard = Hypatia.Neural.Blackboard.snapshot() - - # Predict from sequence buffer (placeholder -- needs training) - prediction = - case state.sequence_buffer do - [] -> %{next_step: "unknown", confidence: 0.0} - [last | _] -> %{next_step: last, confidence: 0.3} - end - - result = %{ - predicted_next: prediction.next_step, - confidence: prediction.confidence, - sequence_length: length(state.sequence_buffer), - model_trained: state.weights != nil - } + def handle_call({:predict, source, _finding, blackboard}, _from, state) do + SourceStore.reply(state, source, fn -> + # Read full blackboard context + _context = SourceStore.accept!(Blackboard.snapshot_source(source, blackboard)) + + # Predict from sequence buffer (placeholder -- needs training) + prediction = + case state.sequence_buffer do + [] -> %{next_step: "unknown", confidence: 0.0} + [last | _] -> %{next_step: last, confidence: 0.3} + end + + result = %{ + predicted_next: prediction.next_step, + confidence: prediction.confidence, + sequence_length: length(state.sequence_buffer), + model_trained: state.weights != nil + } + + SourceStore.accept!(Blackboard.write_source(source, :sequence, :prediction, result, blackboard)) + + {result, state} + end) + end - Hypatia.Neural.Blackboard.write(:sequence, :prediction, result) + def handle_call(:get_state, _from, state) do + state = SourceStore.observe(state) + {:reply, snapshot(state), state} + end - {:reply, result, state} + def handle_call({:snapshot, source}, _from, state) do + SourceStore.reply(state, source, fn -> {{:ok, snapshot(state)}, state} end) end - def handle_call(:get_state, _from, state) do + def handle_call({:restore, source, map}, _from, state) do + SourceStore.reply(state, source, fn -> + unless SourceStore.snapshot_matches?(map, source), + do: raise(SourceStore.Refusal, reason: :unbound_or_foreign_snapshot) + + restored = %{ + state + | hidden_dim: Map.get(map, "hidden_dim", state.hidden_dim), + max_sequence: Map.get(map, "max_sequence", state.max_sequence), + sequence_buffer: Map.get(map, "sequence_buffer", state.sequence_buffer), + weights: Map.get(map, "weights", state.weights) + } + + {:ok, restored} + end) + end + + def handle_call({:observe, source, decision}, _from, state) do + SourceStore.reply(state, source, fn -> + buffer = [decision | state.sequence_buffer] |> Enum.take(state.max_sequence) + {:ok, %{state | sequence_buffer: buffer}} + end) + end + + def handle_call(_unbound_request, _from, state) do + {:reply, {:error, :unbound_request}, SourceStore.refuse(state, :unbound_request)} + end + + @impl true + def handle_cast(_unbound_request, state) do + {:noreply, SourceStore.refuse(state, :unbound_request)} + end + + defp snapshot(state) do snap = %{ "hidden_dim" => state.hidden_dim, "max_sequence" => state.max_sequence, @@ -114,27 +164,7 @@ defmodule Hypatia.Neural.SequenceModel do "trained" => state.weights != nil } - {:reply, snap, state} + SourceStore.tag(snap, state) end - def handle_call({:restore, map}, _from, state) do - restored = %{ - state - | hidden_dim: Map.get(map, "hidden_dim", state.hidden_dim), - max_sequence: Map.get(map, "max_sequence", state.max_sequence), - sequence_buffer: Map.get(map, "sequence_buffer", state.sequence_buffer), - weights: Map.get(map, "weights", state.weights) - } - - {:reply, :ok, restored} - end - - @impl true - def handle_cast({:observe, decision}, state) do - buffer = - [decision | state.sequence_buffer] - |> Enum.take(state.max_sequence) - - {:noreply, %{state | sequence_buffer: buffer}} - end end diff --git a/lib/neural/source_store.ex b/lib/neural/source_store.ex new file mode 100644 index 00000000..7e6529ad --- /dev/null +++ b/lib/neural/source_store.ex @@ -0,0 +1,185 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +defmodule Hypatia.Neural.SourceStore do + @moduledoc """ + Source-store lifecycle checks for in-memory neural state. + + The identity is the expanded configured data-root pathname. It is not a + content digest, an attestation, or an identity for an in-place replacement + of that directory. A process keeps the identity captured at initialization. + An observed root change blocks that lifecycle until the process is restarted. + A runtime peer process outage refuses the operation without qualifying a + mismatched source or permanently blocking an already initialized lifecycle. + + Snapshot labels preserve this binding; they do not authenticate filesystem + contents or promote diagnostic trust-score snapshots to model authority. + """ + + defmodule Refusal do + defexception [:reason] + def message(error), do: "neural source refused: #{inspect(error.reason)}" + end + + require Logger + + def current, do: Hypatia.Paths.verisimdb_data() |> Path.expand() + + def fields do + %{source_store: current(), source_status: :ready, source_error: nil} + end + + def check(state, requested) do + source = Map.get(state, :source_store) + current = current() + + cond do + not is_binary(source) or source == "" or Path.expand(source) != source -> + {:error, :unbound_source_store} + + Map.get(state, :source_status) != :ready -> + {:error, {:source_lifecycle_blocked, Map.get(state, :source_error)}} + + source != current -> + {:error, {:source_store_changed, source, current}} + + requested != source -> + {:error, {:request_source_mismatch, requested, source}} + + true -> + :ok + end + end + + def observe(state) do + case check(state, Map.get(state, :source_store)) do + :ok -> state + {:error, reason} -> refuse(state, reason) + end + end + + def refuse(state, reason) do + if Map.get(state, :source_status) == :blocked do + state + else + status = if lifecycle_error?(reason), do: :blocked, else: Map.get(state, :source_status) + state |> Map.put(:source_status, status) |> Map.put(:source_error, reason) + end + end + + defp lifecycle_error?(:unbound_source_store), do: true + defp lifecycle_error?({:source_store_changed, _, _}), do: true + # A live lifecycle may retry after a peer restarts or its call times out. + # This exception does not qualify a foreign, unbound or malformed snapshot. + defp lifecycle_error?({:cohort_source_mismatch, _, {:process_unavailable, _}}), do: false + defp lifecycle_error?({:cohort_source_mismatch, _, _}), do: true + defp lifecycle_error?({:source_identity_changed, _, _}), do: true + defp lifecycle_error?({:source_lifecycle_blocked, _}), do: true + defp lifecycle_error?(_), do: false + + @doc """ + Run one operation with entry and exit checks, retaining original model state + on refusal. The operation must resolve its reads/writes against the captured + source; these checks alone cannot pin filesystem reads. + """ + def transact(state, requested, operation) when is_function(operation, 0) do + with :ok <- check(state, requested) do + try do + {reply, updated} = operation.() + + case reply do + {:error, reason} -> raise Refusal, reason: reason + _ -> :ok + end + + if Map.get(updated, :source_store) != Map.get(state, :source_store) do + raise Refusal, + reason: + {:source_identity_changed, Map.get(state, :source_store), + Map.get(updated, :source_store)} + end + + case check(updated, requested) do + :ok -> {:ok, reply, Map.put(updated, :source_error, nil)} + {:error, reason} -> {:error, reason, refuse(state, reason)} + end + rescue + error in Refusal -> + {:error, error.reason, refuse(state, error.reason)} + + error -> + reason = {:neural_operation_failed, Exception.message(error)} + {:error, reason, refuse(state, reason)} + catch + :exit, reason -> + failure = {:neural_process_unavailable, reason} + {:error, failure, refuse(state, failure)} + end + else + {:error, reason} -> {:error, reason, refuse(state, reason)} + end + end + + def reply(state, requested, operation) do + case transact(state, requested, operation) do + {:ok, result, updated} -> {:reply, result, updated} + {:error, reason, refused} -> + Logger.warning("Neural source refused: #{inspect(reason)}") + {:reply, {:error, reason}, refused} + end + end + + def noreply(state, requested, operation) do + case transact(state, requested, operation) do + {:ok, _result, updated} -> {:noreply, updated} + {:error, reason, refused} -> + Logger.warning("Neural source refused: #{inspect(reason)}") + {:noreply, refused} + end + end + + def accept!(:ok), do: :ok + def accept!({:ok, value}), do: value + def accept!(%{} = value), do: value + def accept!({:error, reason}), do: raise(Refusal, reason: reason) + def accept!(other), do: raise(Refusal, reason: {:unexpected_neural_reply, other}) + + def call!(server, message) do + server + |> GenServer.call(message) + |> accept!() + end + + def tag(snapshot, state) do + Map.merge(snapshot, %{ + "source_schema" => 1, + "source_store" => state.source_store, + "source_status" => Atom.to_string(state.source_status) + }) + end + + def snapshot_matches?(snapshot, source) when is_map(snapshot) do + snapshot["source_schema"] == 1 and snapshot["source_store"] == source and + snapshot["source_status"] == "ready" + end + + def snapshot_matches?(_, _), do: false + + @doc "Whether a returned report is bound to the current ready source." + def ready_report?(%{source_store: source, source_status: :ready}) + when is_binary(source) do + source == current() + end + + def ready_report?(_), do: false + + def diagnostic(state) do + %{ + source_store: Map.get(state, :source_store), + current_store: current(), + source_status: Map.get(state, :source_status), + source_error: Map.get(state, :source_error), + restart_required: Map.get(state, :source_status) == :blocked + } + end +end diff --git a/lib/neural/training_pipeline.ex b/lib/neural/training_pipeline.ex index ff66003d..2f3c4c0d 100644 --- a/lib/neural/training_pipeline.ex +++ b/lib/neural/training_pipeline.ex @@ -15,7 +15,7 @@ defmodule Hypatia.Neural.TrainingPipeline do require Logger - alias Hypatia.OutcomeTracker + alias Hypatia.{OutcomeTracker, Paths} alias Hypatia.Neural.{ EchoStateNetwork, @@ -23,8 +23,6 @@ defmodule Hypatia.Neural.TrainingPipeline do Rebalancer } - @verisimdb_data_path Application.compile_env(:hypatia, :verisimdb_data_path, "data/verisim") - # --- ESN Training --- @doc """ @@ -46,8 +44,11 @@ defmodule Hypatia.Neural.TrainingPipeline do JSON lines and unreadable files are ignored; if the directory cannot be listed or no recipe meets the threshold, returns an empty list. """ - def build_esn_training_data do - outcomes_dir = Path.join(Path.expand(@verisimdb_data_path), "outcomes") + def build_esn_training_data, do: build_esn_training_data(Paths.verisimdb_data()) + + @doc false + def build_esn_training_data(data_root) when is_binary(data_root) do + outcomes_dir = Path.join(Path.expand(data_root), "outcomes") outcomes = case File.ls(outcomes_dir) do @@ -81,7 +82,6 @@ defmodule Hypatia.Neural.TrainingPipeline do build_esn_training_data(outcomes) end - @doc false def build_esn_training_data(outcomes) when is_list(outcomes) do # Use the same evidence qualification as Bayesian and cross-repo learning. # Exit-zero-only, incomplete scans and contradictory successes remain audit @@ -149,7 +149,8 @@ defmodule Hypatia.Neural.TrainingPipeline do (e.g. `[rate: 0.05, dip_depth: 0.4, seed: 42]`). """ def train_esn(esn, opts \\ []) do - training_data = build_esn_training_data() + data_root = Keyword.get(opts, :data_path, Paths.verisimdb_data()) + training_data = build_esn_training_data(data_root) if training_data == [] do Logger.warning("TrainingPipeline: no ESN training data available") @@ -179,7 +180,7 @@ defmodule Hypatia.Neural.TrainingPipeline do "TrainingPipeline: rebalancing ESN series for #{primary.recipe_id} (corpus dips)" ) - data_path = Keyword.get(rebalance_opts, :data_path, "data/verisim") + data_path = Keyword.get(rebalance_opts, :data_path, data_root) templates = Rebalancer.load_failure_templates(data_path) if templates == [] do @@ -243,8 +244,8 @@ defmodule Hypatia.Neural.TrainingPipeline do Returns {vectors, targets} where vectors is a list of 8-element lists and targets is a list of floats. """ - def build_rbf_training_data do - registry_path = Path.join(Path.expand(@verisimdb_data_path), "patterns/registry.json") + def build_rbf_training_data(data_root \\ Paths.verisimdb_data()) do + registry_path = Path.join([Path.expand(data_root), "patterns", "registry.json"]) patterns = case File.read(registry_path) do @@ -307,7 +308,8 @@ defmodule Hypatia.Neural.TrainingPipeline do (e.g. `[count: 30, seed: 42]`). """ def train_rbf(rbf, opts \\ []) do - {real_vectors, real_targets} = build_rbf_training_data() + data_root = Keyword.get(opts, :data_path, Paths.verisimdb_data()) + {real_vectors, real_targets} = build_rbf_training_data(data_root) {vectors, targets} = if Keyword.get(opts, :rebalance, true) and real_vectors != [] do @@ -345,6 +347,8 @@ defmodule Hypatia.Neural.TrainingPipeline do Initializes fresh network instances, trains both on available data from verisim-data, and returns a map with `:esn` and `:rbf` keys. + `:data_path` captures the source for training reads. An explicit + `:rebalance_opts` data path remains a deliberate Strategy C corpus override. Options are forwarded to `train_esn/2` and `train_rbf/2` — see those functions for the `:rebalance` and `:rebalance_opts` knobs. """ diff --git a/lib/neural/variational_autoencoder.ex b/lib/neural/variational_autoencoder.ex index 3131a23e..1bfec718 100644 --- a/lib/neural/variational_autoencoder.ex +++ b/lib/neural/variational_autoencoder.ex @@ -22,6 +22,8 @@ defmodule Hypatia.Neural.VariationalAutoencoder do use GenServer + alias Hypatia.Neural.{Blackboard, SourceStore} + defstruct [ # input → latent (mu, sigma) :encoder_weights, @@ -32,7 +34,10 @@ defmodule Hypatia.Neural.VariationalAutoencoder do # learned cluster centers :clusters, # human-readable cluster names - :cluster_labels + :cluster_labels, + source_store: nil, + source_status: :ready, + source_error: nil ] def start_link(opts \\ []) do @@ -42,6 +47,7 @@ defmodule Hypatia.Neural.VariationalAutoencoder do @impl true def init(_opts) do state = %__MODULE__{ + source_store: SourceStore.current(), encoder_weights: nil, decoder_weights: nil, latent_dim: 8, @@ -54,42 +60,83 @@ defmodule Hypatia.Neural.VariationalAutoencoder do @doc "Cluster an interpretation based on full blackboard context." def cluster(finding) do - GenServer.call(__MODULE__, {:cluster, finding}) + GenServer.call(__MODULE__, {:cluster, SourceStore.current(), finding, Blackboard}) end @doc "Snapshot serialisable state for M17 persistence." def state, do: GenServer.call(__MODULE__, :get_state) @doc "Restore state from a previously snapshotted map." - def restore(map) when is_map(map), do: GenServer.call(__MODULE__, {:restore, map}) - def restore(_), do: :ok + def restore(map) when is_map(map), do: GenServer.call(__MODULE__, {:restore, SourceStore.current(), map}) + def restore(_), do: {:error, :unbound_snapshot} @impl true - def handle_call({:cluster, _finding}, _from, state) do - # Read full blackboard context - blackboard = Hypatia.Neural.Blackboard.snapshot() - - # Encode to latent space (placeholder -- needs training) - latent = List.duplicate(0.0, state.latent_dim) - - # Find nearest cluster - cluster_id = 0 - label = Map.get(state.cluster_labels, cluster_id, "unclustered") - - result = %{ - latent: latent, - cluster_id: cluster_id, - label: label, - context_size: map_size(blackboard.networks) - } + def handle_call({:cluster, source, _finding, blackboard}, _from, state) do + SourceStore.reply(state, source, fn -> + # Read full blackboard context + context = SourceStore.accept!(Blackboard.snapshot_source(source, blackboard)) + + # Encode to latent space (placeholder -- needs training) + latent = List.duplicate(0.0, state.latent_dim) + + # Find nearest cluster + cluster_id = 0 + label = Map.get(state.cluster_labels, cluster_id, "unclustered") + + result = %{ + latent: latent, + cluster_id: cluster_id, + label: label, + context_size: map_size(context.networks) + } + + SourceStore.accept!(Blackboard.write_source(source, :vae, :interpretation, result, blackboard)) + SourceStore.accept!(Blackboard.write_source(source, :vae, :latent, latent, blackboard)) + + {result, state} + end) + end - Hypatia.Neural.Blackboard.write(:vae, :interpretation, result) - Hypatia.Neural.Blackboard.write(:vae, :latent, latent) + def handle_call(:get_state, _from, state) do + state = SourceStore.observe(state) + {:reply, snapshot(state), state} + end - {:reply, result, state} + def handle_call({:snapshot, source}, _from, state) do + SourceStore.reply(state, source, fn -> {{:ok, snapshot(state)}, state} end) end - def handle_call(:get_state, _from, state) do + def handle_call({:restore, source, map}, _from, state) do + SourceStore.reply(state, source, fn -> + unless SourceStore.snapshot_matches?(map, source), + do: raise(SourceStore.Refusal, reason: :unbound_or_foreign_snapshot) + + restored = %{ + state + | encoder_weights: Map.get(map, "encoder_weights", state.encoder_weights), + decoder_weights: Map.get(map, "decoder_weights", state.decoder_weights), + latent_dim: Map.get(map, "latent_dim", state.latent_dim), + clusters: Map.get(map, "clusters", state.clusters), + cluster_labels: + map + |> Map.get("cluster_labels", %{}) + |> Map.new(fn {k, v} -> + {case Integer.parse(to_string(k)) do + {n, ""} -> n + _ -> k + end, v} + end) + } + + {:ok, restored} + end) + end + + def handle_call(_unbound_request, _from, state) do + {:reply, {:error, :unbound_request}, SourceStore.refuse(state, :unbound_request)} + end + + defp snapshot(state) do snap = %{ "encoder_weights" => state.encoder_weights, "decoder_weights" => state.decoder_weights, @@ -99,27 +146,7 @@ defmodule Hypatia.Neural.VariationalAutoencoder do "trained" => state.encoder_weights != nil } - {:reply, snap, state} + SourceStore.tag(snap, state) end - def handle_call({:restore, map}, _from, state) do - restored = %{ - state - | encoder_weights: Map.get(map, "encoder_weights", state.encoder_weights), - decoder_weights: Map.get(map, "decoder_weights", state.decoder_weights), - latent_dim: Map.get(map, "latent_dim", state.latent_dim), - clusters: Map.get(map, "clusters", state.clusters), - cluster_labels: - map - |> Map.get("cluster_labels", %{}) - |> Map.new(fn {k, v} -> - {case Integer.parse(to_string(k)) do - {n, ""} -> n - _ -> k - end, v} - end) - } - - {:reply, :ok, restored} - end end diff --git a/lib/outcome_tracker.ex b/lib/outcome_tracker.ex index 679d1466..7922e58a 100644 --- a/lib/outcome_tracker.ex +++ b/lib/outcome_tracker.ex @@ -12,14 +12,7 @@ defmodule Hypatia.OutcomeTracker do require Logger - alias Hypatia.ConfidenceAnnealing - - @verisimdb_data_path Application.compile_env(:hypatia, :verisimdb_data_path, "data/verisim") - @fleet_path Application.compile_env( - :hypatia, - :fleet_path, - "~/Documents/hyperpolymath-repos/gitbot-fleet" - ) + alias Hypatia.{ConfidenceAnnealing, Paths} # Bayesian confidence updating via Beta distribution # Prior strength controls how much weight initial confidence carries @@ -31,13 +24,6 @@ defmodule Hypatia.OutcomeTracker do # without this marker are NOT trusted even if the counts happen to match. @annealing_evidence_version 1 - # Path for persisted annealing states (per-recipe temperature tracking) - @annealing_state_path Application.compile_env( - :hypatia, - :annealing_state_path, - "data/verisim/annealing-states" - ) - @doc """ Record an outcome from a fix attempt. @@ -79,7 +65,7 @@ defmodule Hypatia.OutcomeTracker do a scheduler restart cannot turn one attempt into repeated training examples. """ def imported_fleet_ids do - outcomes_dir = Path.join(Path.expand(@verisimdb_data_path), "outcomes") + outcomes_dir = Path.expand(Paths.outcomes()) case File.ls(outcomes_dir) do {:ok, files} -> @@ -101,8 +87,11 @@ defmodule Hypatia.OutcomeTracker do end end) - {:error, :enoent} -> {:ok, MapSet.new()} - {:error, reason} -> {:error, {:outcomes_directory, reason}} + {:error, :enoent} -> + {:ok, MapSet.new()} + + {:error, reason} -> + {:error, {:outcomes_directory, reason}} end end @@ -125,6 +114,7 @@ defmodule Hypatia.OutcomeTracker do end defp persist_outcome(recipe_id, repo, file, outcome, metadata, publish_to_fleet?) do + store = capture_outcome_store() now = DateTime.utc_now() |> DateTime.to_iso8601() outcome_str = Atom.to_string(outcome) @@ -140,14 +130,24 @@ defmodule Hypatia.OutcomeTracker do record = Map.merge(metadata, base) - case write_outcome_log(record) do + case write_outcome_log(record, store) do :ok -> if publish_to_fleet?, do: write_fleet_outcome(record) - if qualified_outcome?(record), do: update_annealing_state(recipe_id, outcome) + + if qualified_outcome?(record) do + case do_update_annealing_state(recipe_id, outcome, store, fn -> :ok end) do + {:ok, _state} -> + :ok + + {:error, reason} -> + Logger.warning("Outcome logged; annealing update pending: #{inspect(reason)}") + end + end + # The event is committed even when the derived recipe summary is # temporarily unavailable. The LearningScheduler retries that recipe # from the durable log on the next cycle. - case update_recipe_confidence(recipe_id) do + case do_update_recipe_confidence(recipe_id, store, fn -> :ok end) do {:error, reason} -> Logger.warning("Outcome logged; recipe update pending: #{inspect(reason)}") @@ -378,118 +378,133 @@ defmodule Hypatia.OutcomeTracker do @doc """ Recalculate confidence for a recipe based on all recorded outcomes. Updates the recipe JSON file in verisim-data/recipes/. + + Captures the data root and explicit annealing override once. All evidence and + write paths remain bound to those directories. An observed configuration + change returns {:error, {:outcome_store_changed, captured, current}}. + Write failures remain errors. The annealing and recipe writes are not an + atomic multi-file transaction; a later refusal can leave an earlier A write, + but cannot redirect that operation to B. """ def update_recipe_confidence(recipe_id) do - outcomes = load_outcomes_for_recipe(recipe_id) + update_recipe_confidence(recipe_id, fn -> :ok end) + end - if Enum.empty?(outcomes) do - :no_outcomes - else + # The barrier is a deterministic control seam; production uses the same + # transaction with a no-op barrier. It runs after evidence/encoding and before + # the first write, and cannot bypass the captured-source checks. + @doc false + def update_recipe_confidence(recipe_id, before_persist) when is_function(before_persist, 0) do + do_update_recipe_confidence(recipe_id, capture_outcome_store(), before_persist) + end + + defp do_update_recipe_confidence(recipe_id, store, before_persist) do + with :ok <- ensure_outcome_store(store) do + outcomes = load_outcomes_for_recipe(recipe_id, store.data_root) + + if Enum.empty?(outcomes) do + :no_outcomes + else + case find_recipe_file(recipe_id, store.data_root) do + nil -> + Logger.warning("No recipe file found for #{recipe_id}") + :recipe_not_found + + recipe_path -> + update_confidence_file(recipe_id, recipe_path, outcomes, store, before_persist) + end + end + end + end + + defp update_confidence_file(recipe_id, recipe_path, outcomes, store, before_persist) do + with {:ok, content} <- File.read(recipe_path), + {:ok, recipe} when is_map(recipe) <- Jason.decode(content) do counts = qualified_outcome_counts(outcomes) successes = counts.successes failures = counts.failures false_positives = counts.false_positives + qualified_total = qualified_count(counts) - recipe_path = find_recipe_file(recipe_id) + # Re-evaluation keeps the fixed prior and the same qualified evidence + # snapshot for both confidence and annealing. + base_confidence = Map.get(recipe, "confidence_prior", Map.get(recipe, "confidence", 0.5)) - cond do + raw_confidence = + if qualified_total == 0 do + base_confidence + else + bayesian_update(base_confidence, successes, failures + false_positives) + end - recipe_path == nil -> - Logger.warning("No recipe file found for #{recipe_id}") - :recipe_not_found - - true -> - case File.read(recipe_path) do - {:ok, content} -> - case Jason.decode(content) do - {:ok, recipe} -> - # Keep a fixed prior: re-evaluation must not treat the previous - # posterior as a fresh prior and amplify the same evidence. - base_confidence = - Map.get(recipe, "confidence_prior", Map.get(recipe, "confidence", 0.5)) - - qualified_total = successes + failures + false_positives - - raw_confidence = - if qualified_total == 0 do - base_confidence - else - bayesian_update(base_confidence, successes, failures + false_positives) - end - - # Apply temperature-based annealing to the raw confidence. - # This flattens confidence toward 0.5 when the recipe has - # few outcomes, and lets it sharpen as evidence accumulates. - annealing_state = load_annealing_state(recipe_id) - new_confidence = - if qualified_total == 0 do - base_confidence - else - ConfidenceAnnealing.anneal(raw_confidence, annealing_state) - end - annealing_summary = ConfidenceAnnealing.summary(annealing_state) - - updated_recipe = - recipe - |> Map.put("confidence_prior", base_confidence) - |> Map.put("confidence", Float.round(new_confidence, 4)) - |> Map.put("confidence_raw", Float.round(raw_confidence, 4)) - |> Map.put("successful_fixes", successes) - |> Map.put("failed_fixes", failures) - |> Map.put("false_positives", false_positives) - |> Map.put("total_attempts", length(outcomes)) - |> Map.put("unqualified_attempts", counts.ignored) - |> Map.put("annealing", annealing_summary) - - case Jason.encode(updated_recipe, pretty: true) do - {:ok, json} -> - case File.write(recipe_path, json <> "\n") do - :ok -> - Logger.info( - "Updated #{recipe_id} confidence: #{base_confidence} -> #{new_confidence}" - ) - - {:ok, new_confidence} - - {:error, reason} -> - Logger.warning("Failed to persist recipe confidence: #{inspect(reason)}") - {:error, reason} - end - - {:error, reason} -> - Logger.error("Failed to encode recipe: #{inspect(reason)}") - {:error, reason} - end - - {:error, reason} -> - Logger.error("Failed to decode recipe: #{inspect(reason)}") - {:error, reason} - end + saved = read_saved_annealing_state(recipe_id, store) + annealing_state = reconcile_annealing_state(saved, counts) - {:error, reason} -> - Logger.error("Failed to read recipe file: #{inspect(reason)}") - {:error, reason} - end + new_confidence = + if qualified_total == 0 do + base_confidence + else + ConfidenceAnnealing.anneal(raw_confidence, annealing_state) + end + + updated_recipe = + recipe + |> Map.put("confidence_prior", base_confidence) + |> Map.put("confidence", Float.round(new_confidence, 4)) + |> Map.put("confidence_raw", Float.round(raw_confidence, 4)) + |> Map.put("successful_fixes", successes) + |> Map.put("failed_fixes", failures) + |> Map.put("false_positives", false_positives) + |> Map.put("total_attempts", length(outcomes)) + |> Map.put("unqualified_attempts", counts.ignored) + |> Map.put("annealing", ConfidenceAnnealing.summary(annealing_state)) + + with {:ok, json} <- Jason.encode(updated_recipe, pretty: true), + :ok <- before_store_persist(store, before_persist), + :ok <- persist_reconciled_annealing(recipe_id, saved, annealing_state, counts, store), + :ok <- ensure_outcome_store(store), + :ok <- File.write(recipe_path, json <> "\n"), + :ok <- ensure_outcome_store(store) do + Logger.info("Updated #{recipe_id} confidence: #{base_confidence} -> #{new_confidence}") + {:ok, new_confidence} + else + {:error, reason} = error -> + Logger.warning("Failed to persist recipe confidence: #{inspect(reason)}") + error end + else + {:ok, _non_object} -> + {:error, :invalid_recipe_object} + + {:error, reason} = error -> + Logger.error("Failed to read recipe confidence input: #{inspect(reason)}") + error end end @doc false def qualified_outcome_counts(outcomes) when is_list(outcomes) do - Enum.reduce(outcomes, %{successes: 0, failures: 0, false_positives: 0, ignored: 0}, fn row, acc -> + Enum.reduce(outcomes, %{successes: 0, failures: 0, false_positives: 0, ignored: 0}, fn row, + acc -> case row do %{"outcome" => "success", "verification" => "verified"} -> %{acc | successes: acc.successes + 1} - %{"outcome" => "failure", "verification" => v} when v in ["failed", "incomplete", "verified"] -> + %{"outcome" => "failure", "verification" => v} + when v in ["failed", "incomplete", "verified"] -> %{acc | failures: acc.failures + 1} - %{"outcome" => "false_positive", "verification" => "rollback_requested", - "caused_by" => "batch_rollback", "batch_id" => batch_id} + %{ + "outcome" => "false_positive", + "verification" => "rollback_requested", + "caused_by" => "batch_rollback", + "batch_id" => batch_id + } when is_binary(batch_id) and batch_id != "" -> %{acc | false_positives: acc.false_positives + 1} - %{"outcome" => "false_positive", "verification" => v} when v in ["still_present", "verified"] -> + %{"outcome" => "false_positive", "verification" => v} + when v in ["still_present", "verified"] -> %{acc | false_positives: acc.false_positives + 1} _ -> @@ -796,7 +811,7 @@ defmodule Hypatia.OutcomeTracker do # --- Private --- defp all_recipe_ids_with_outcomes do - outcomes_dir = Path.join(Path.expand(@verisimdb_data_path), "outcomes") + outcomes_dir = Path.expand(Paths.outcomes()) case File.ls(outcomes_dir) do {:ok, files} -> @@ -823,13 +838,15 @@ defmodule Hypatia.OutcomeTracker do end end - defp write_outcome_log(record) do + defp write_outcome_log(record, store) do now = Date.utc_today() month_str = String.pad_leading("#{now.month}", 2, "0") - path = Path.join([Path.expand(@verisimdb_data_path), "outcomes", "#{now.year}-#{month_str}.jsonl"]) + path = Path.join([store.data_root, "outcomes", "#{now.year}-#{month_str}.jsonl"]) - with :ok <- File.mkdir_p(Path.dirname(path)), - {:ok, json} <- Jason.encode(record) do + with :ok <- ensure_outcome_store(store), + :ok <- File.mkdir_p(Path.dirname(path)), + {:ok, json} <- Jason.encode(record), + :ok <- ensure_outcome_store(store) do assert_h6_monotone(path, Map.get(record, "timestamp")) File.write(path, json <> "\n", [:append, :utf8]) end @@ -870,7 +887,7 @@ defmodule Hypatia.OutcomeTracker do defp write_fleet_outcome(record) do path = Path.join([ - Path.expand(@fleet_path), + Path.expand(Paths.fleet()), "shared-context/learning/fix-outcomes.jsonl" ]) @@ -885,7 +902,7 @@ defmodule Hypatia.OutcomeTracker do case Jason.encode(fleet_record) do {:ok, json} -> - # Same silent-drop class as write_outcome_log/1: File.write with :append + # Same silent-drop class as write_outcome_log/2: File.write with :append # fails :enoent on a missing dir, dropping the fleet learning signal. File.mkdir_p!(Path.dirname(path)) @@ -899,8 +916,8 @@ defmodule Hypatia.OutcomeTracker do end end - defp load_outcomes_for_recipe(recipe_id) do - outcomes_dir = Path.join(Path.expand(@verisimdb_data_path), "outcomes") + defp load_outcomes_for_recipe(recipe_id, data_root \\ Paths.verisimdb_data()) do + outcomes_dir = Path.join(Path.expand(data_root), "outcomes") case File.ls(outcomes_dir) do {:ok, files} -> @@ -934,8 +951,8 @@ defmodule Hypatia.OutcomeTracker do end end - defp find_recipe_file(recipe_id) do - recipes_dir = Path.join(Path.expand(@verisimdb_data_path), "recipes") + defp find_recipe_file(recipe_id, data_root \\ Paths.verisimdb_data()) do + recipes_dir = Path.join(Path.expand(data_root), "recipes") case File.ls(recipes_dir) do {:ok, files} -> @@ -970,14 +987,34 @@ defmodule Hypatia.OutcomeTracker do @doc """ Get the annealing state for a recipe, computing the max dispatch tier. - Returns {:ok, strategy} where strategy is the annealing-clamped - dispatch tier for the given raw strategy. + Returns the annealing-clamped dispatch-tier atom for the given raw strategy. + Reconciliation is read-only; explicit update transactions persist state. """ def annealed_strategy(recipe_id, raw_strategy) do state = load_annealing_state(recipe_id) ConfidenceAnnealing.clamp_strategy(raw_strategy, state) end + @doc """ + Clamp a neural recommendation using qualified outcomes from its captured store. + + This read-only path derives the existing maturity stage from qualified counts. + It neither trusts a persisted stage nor reads/writes a runtime annealing + override. Temperature and reheats do not determine the existing strategy cap. + """ + def annealed_strategy_from_store(recipe_id, raw_strategy, data_root) do + counts = qualified_outcome_counts(load_outcomes_for_recipe(recipe_id, data_root)) + + state = + ConfidenceAnnealing.from_existing( + counts.successes, + counts.failures, + counts.false_positives + ) + + ConfidenceAnnealing.clamp_strategy(raw_strategy, state) + end + @doc """ Get annealing-adjusted confidence for a recipe in a cross-repo context. @@ -994,29 +1031,41 @@ defmodule Hypatia.OutcomeTracker do load_annealing_state(recipe_id) end - defp update_annealing_state(recipe_id, outcome) do - # At this point the outcome was already appended to the canonical log. - # One additional qualified observation can be incorporated incrementally; - # any legacy mismatch is rebuilt from qualified evidence instead. - counts = qualified_outcome_counts(load_outcomes_for_recipe(recipe_id)) - qualified_total = qualified_count(counts) + # Exercise the actual incremental transaction against a canonical log that + # already contains the observation. Production calls it with the store + # captured before appending that same observation. + @doc false + def update_annealing_state(recipe_id, outcome, before_persist) + when is_function(before_persist, 0) do + do_update_annealing_state(recipe_id, outcome, capture_outcome_store(), before_persist) + end - state = - case read_saved_annealing_state(recipe_id) do - %{outcome_count: previous} = prior when previous + 1 == qualified_total -> - ConfidenceAnnealing.record_outcome(prior, outcome) + defp do_update_annealing_state(recipe_id, outcome, store, before_persist) do + with :ok <- ensure_outcome_store(store) do + counts = qualified_outcome_counts(load_outcomes_for_recipe(recipe_id, store.data_root)) + qualified_total = qualified_count(counts) - %{outcome_count: current} = prior when current == qualified_total -> - prior + state = + case read_saved_annealing_state(recipe_id, store) do + %{outcome_count: previous} = prior when previous + 1 == qualified_total -> + ConfidenceAnnealing.record_outcome(prior, outcome) - _ -> - ConfidenceAnnealing.from_existing( - counts.successes, counts.failures, counts.false_positives - ) - end + %{outcome_count: current} = prior when current == qualified_total -> + prior - save_annealing_state(recipe_id, state) - state + _ -> + ConfidenceAnnealing.from_existing( + counts.successes, + counts.failures, + counts.false_positives + ) + end + + with :ok <- before_store_persist(store, before_persist), + :ok <- save_annealing_state(recipe_id, state, store) do + {:ok, state} + end + end end defp qualified_count(counts), @@ -1027,28 +1076,38 @@ defmodule Hypatia.OutcomeTracker do expected = qualified_count(counts) case stored do - %{outcome_count: ^expected} -> stored + %{outcome_count: ^expected} -> + stored + _ -> ConfidenceAnnealing.from_existing( - counts.successes, counts.failures, counts.false_positives + counts.successes, + counts.failures, + counts.false_positives ) end end defp load_annealing_state(recipe_id) do - # Read-time check is essential: already-persisted veteran stages may have - # been computed from thousands of legacy, unverified "success" rows. - counts = qualified_outcome_counts(load_outcomes_for_recipe(recipe_id)) - saved = read_saved_annealing_state(recipe_id) - current = reconcile_annealing_state(saved, counts) - - if current != saved and (saved != nil or qualified_count(counts) > 0), - do: save_annealing_state(recipe_id, current) - current + # Diagnostics and numeric/strategy callers reconcile in memory. They do not + # acquire a hidden persistence transaction or depend on a disk-write result. + # Only the explicit confidence/incremental transactions persist this state. + store = capture_outcome_store() + counts = qualified_outcome_counts(load_outcomes_for_recipe(recipe_id, store.data_root)) + saved = read_saved_annealing_state(recipe_id, store) + reconcile_annealing_state(saved, counts) end - defp read_saved_annealing_state(recipe_id) do - case File.read(annealing_state_path(recipe_id)) do + defp persist_reconciled_annealing(recipe_id, saved, current, counts, store) do + if current != saved and (saved != nil or qualified_count(counts) > 0) do + save_annealing_state(recipe_id, current, store) + else + :ok + end + end + + defp read_saved_annealing_state(recipe_id, store) do + case File.read(annealing_state_path(recipe_id, store)) do {:ok, content} -> case Jason.decode(content) do {:ok, %{"qualification_version" => @annealing_evidence_version} = data} -> @@ -1058,15 +1117,13 @@ defmodule Hypatia.OutcomeTracker do nil end - {:error, _} -> nil + {:error, _} -> + nil end end - - defp save_annealing_state(recipe_id, state) do - path = annealing_state_path(recipe_id) - dir = Path.dirname(path) - File.mkdir_p!(dir) + defp save_annealing_state(recipe_id, state, store) do + path = annealing_state_path(recipe_id, store) serialized = %{ "qualification_version" => @annealing_evidence_version, @@ -1079,9 +1136,48 @@ defmodule Hypatia.OutcomeTracker do "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601() } - case Jason.encode(serialized, pretty: true) do - {:ok, json} -> File.write!(path, json <> "\n") - {:error, reason} -> Logger.error("Failed to save annealing state: #{inspect(reason)}") + with {:ok, json} <- Jason.encode(serialized, pretty: true), + :ok <- ensure_outcome_store(store), + :ok <- File.mkdir_p(Path.dirname(path)), + :ok <- ensure_outcome_store(store), + :ok <- File.write(path, json <> "\n"), + :ok <- ensure_outcome_store(store) do + :ok + else + {:error, {:outcome_store_changed, _captured, _current}} = error -> + error + + {:error, reason} -> + {:error, {:annealing_state_write, path, reason}} + end + end + + defp capture_outcome_store do + data_root = Path.expand(Paths.verisimdb_data()) + + # Resolve the default from the captured root, not a second runtime read. + annealing_dir = + Application.get_env(:hypatia, :annealing_state_path, Path.join(data_root, "annealing-states")) + |> Path.expand() + + %{data_root: data_root, annealing_dir: annealing_dir} + end + + defp ensure_outcome_store(captured) do + current = capture_outcome_store() + + if current == captured do + :ok + else + {:error, {:outcome_store_changed, captured, current}} + end + end + + defp before_store_persist(store, before_persist) do + case before_persist.() do + :ok -> ensure_outcome_store(store) + {:error, _reason} = error -> error + other -> {:error, {:invalid_store_barrier_result, other}} end end @@ -1113,9 +1209,10 @@ defmodule Hypatia.OutcomeTracker do } end - defp annealing_state_path(recipe_id) do + defp annealing_state_path(recipe_id, store) do # Sanitise recipe_id for filesystem safety safe_id = String.replace(recipe_id, ~r/[^a-zA-Z0-9_\-]/, "_") - Path.join(Path.expand(@annealing_state_path), "#{safe_id}.json") + + Path.join(store.annealing_dir, "#{safe_id}.json") end end diff --git a/lib/paths.ex b/lib/paths.ex index 52954a0c..4fe9275e 100644 --- a/lib/paths.ex +++ b/lib/paths.ex @@ -41,6 +41,20 @@ defmodule Hypatia.Paths do @doc "Returns the dispatch subdirectory within verisimdb data." def dispatch, do: Path.join(verisimdb_data(), "dispatch") + @doc """ + Returns the runtime annealing-state directory. + + An explicit :annealing_state_path overrides the directory within the + currently selected verisimdb data store. + """ + def annealing_states do + Application.get_env( + :hypatia, + :annealing_state_path, + Path.join(verisimdb_data(), "annealing-states") + ) + end + @doc "Returns the neural-states subdirectory within verisimdb data." def neural_states, do: Path.join(verisimdb_data(), "neural-states") diff --git a/lib/pattern_registry.ex b/lib/pattern_registry.ex index 391d186f..68596b46 100644 --- a/lib/pattern_registry.ex +++ b/lib/pattern_registry.ex @@ -46,7 +46,7 @@ defmodule Hypatia.PatternRegistry do require Logger - @verisimdb_data_path Application.compile_env(:hypatia, :verisimdb_data_path, "data/verisim") + alias Hypatia.Paths @pa_rule_map %{ "UncheckedAllocation" => "PA001", @@ -102,11 +102,14 @@ defmodule Hypatia.PatternRegistry do Takes a list of scan results (from VerisimConnector.fetch_all_scans/0) and rebuilds the per-repo pattern state for every repo in the batch. - See the moduledoc for the full sync semantics. + See the moduledoc for the full sync semantics. One expanded data root is + captured for every read and write. An observed root change or write failure + returns {:error, reason}; synchronization never redirects into a new store. """ def sync_from_scans(scans) do - registry = load_registry() - substitutions = load_substitutions() + source = current_source() + registry = load_registry(source) + substitutions = load_substitutions(source) now = DateTime.utc_now() |> DateTime.to_iso8601() scanned_repos = MapSet.new(scans, & &1.repo) @@ -125,13 +128,15 @@ defmodule Hypatia.PatternRegistry do |> Map.put("patterns", patterns) |> Map.put("last_updated", now) - save_registry(updated_registry) - - active = Enum.count(Map.values(patterns), &(Map.get(&1, "occurrences", 0) > 0)) - resolved = map_size(patterns) - active - Logger.info("Pattern registry synced: #{active} active, #{resolved} resolved patterns") + with :ok <- check_source(source), + :ok <- save_registry(updated_registry, source), + :ok <- check_source(source) do + active = Enum.count(Map.values(patterns), &(Map.get(&1, "occurrences", 0) > 0)) + resolved = map_size(patterns) - active + Logger.info("Pattern registry synced: #{active} active, #{resolved} resolved patterns") - {:ok, updated_registry} + {:ok, updated_registry} + end end @doc "Get a single pattern by ID." @@ -310,16 +315,24 @@ defmodule Hypatia.PatternRegistry do end end - defp registry_path do - Path.join(Path.expand(@verisimdb_data_path), "patterns/registry.json") + defp current_source, do: Paths.verisimdb_data() |> Path.expand() + + defp check_source(source) do + current = current_source() + + if current == source, + do: :ok, + else: {:error, {:pattern_store_changed, source, current}} end - defp substitutions_path do - Path.join(Path.expand(@verisimdb_data_path), "recipes/proven-substitutions.json") + defp registry_path(source), do: Path.join([source, "patterns", "registry.json"]) + + defp substitutions_path(source) do + Path.join([source, "recipes", "proven-substitutions.json"]) end - defp load_registry do - case File.read(registry_path()) do + defp load_registry(source \\ current_source()) do + case File.read(registry_path(source)) do {:ok, content} -> case Jason.decode(content) do {:ok, data} -> data @@ -331,15 +344,21 @@ defmodule Hypatia.PatternRegistry do end end - defp save_registry(registry) do + defp save_registry(registry, source) do case Jason.encode(registry, pretty: true) do - {:ok, json} -> File.write!(registry_path(), json <> "\n") - {:error, reason} -> Logger.error("Failed to save registry: #{inspect(reason)}") + {:ok, json} -> + case File.write(registry_path(source), json <> "\n") do + :ok -> :ok + {:error, reason} -> {:error, {:registry_write_failed, reason}} + end + + {:error, reason} -> + {:error, {:registry_encoding_failed, reason}} end end - defp load_substitutions do - case File.read(substitutions_path()) do + defp load_substitutions(source) do + case File.read(substitutions_path(source)) do {:ok, content} -> case Jason.decode(content) do {:ok, data} -> Map.get(data, "substitutions", []) diff --git a/lib/rules/code_safety.ex b/lib/rules/code_safety.ex index 63f82096..8b8a8873 100644 --- a/lib/rules/code_safety.ex +++ b/lib/rules/code_safety.ex @@ -316,7 +316,10 @@ defmodule Hypatia.Rules.CodeSafety do %{ id: :agda_postulate, severity: :critical, - pattern: ~r/\bpostulate\b/, + # Agda names can contain hyphens, apostrophes and Unicode symbols. + # A declaration keyword is a complete token, not a regex word inside + # e.g. `no-unconditional-postulate-promotion`. + pattern: ~r/(? Enum.flat_map(fn rule -> subject = - if Map.get(rule, :context) == :runtime_path, do: runtime_only, else: scannable + cond do + rule.id == :agda_postulate -> agda_declaration_subject(content) + Map.get(rule, :context) == :runtime_path -> runtime_only + true -> scannable + end # Some patterns need line context to distinguish executable code from # quoted guidance or comments. Apply the same central suppression oracle @@ -1148,6 +1155,65 @@ defmodule Hypatia.Rules.CodeSafety do defp strip_inline_test_blocks(content, _other), do: content + # Agda's declaration keyword needs lexical boundaries: comments separate + # tokens, nest, and can follow code on the same line. String literals can + # contain comment markers and the word "postulate" without declaring one. + # Mask only non-code text for this rule, preserving every newline for + # source locations and inline-suppression checks. Other rules keep their + # existing subjects. Reference: Agda manual, language/lexical-structure.html. + defp agda_declaration_subject(content) do + content + |> String.replace_prefix("\uFEFF", " ") + |> mask_agda_noncode(:code, []) + |> IO.iodata_to_binary() + end + + defp mask_agda_noncode(<<>>, _state, acc), do: Enum.reverse(acc) + + defp mask_agda_noncode("--" <> rest, :code, acc), + do: mask_agda_noncode(rest, :line_comment, [" " | acc]) + + defp mask_agda_noncode("{-" <> rest, :code, acc), + do: mask_agda_noncode(rest, {:block_comment, 1}, [" " | acc]) + + # A double quote used as a character must not open a string and hide + # declarations after it. Other character literals contain no bare quote. + defp mask_agda_noncode(<<39, 34, 39, rest::binary>>, :code, acc), + do: mask_agda_noncode(rest, :code, [" " | acc]) + + defp mask_agda_noncode(<<39, 92, 34, 39, rest::binary>>, :code, acc), + do: mask_agda_noncode(rest, :code, [" " | acc]) + + defp mask_agda_noncode(<<34, rest::binary>>, :code, acc), + do: mask_agda_noncode(rest, :string, [" " | acc]) + + defp mask_agda_noncode("\n" <> rest, :line_comment, acc), + do: mask_agda_noncode(rest, :code, ["\n" | acc]) + + defp mask_agda_noncode("{-" <> rest, {:block_comment, depth}, acc), + do: mask_agda_noncode(rest, {:block_comment, depth + 1}, [" " | acc]) + + defp mask_agda_noncode("-}" <> rest, {:block_comment, 1}, acc), + do: mask_agda_noncode(rest, :code, [" " | acc]) + + defp mask_agda_noncode("-}" <> rest, {:block_comment, depth}, acc), + do: mask_agda_noncode(rest, {:block_comment, depth - 1}, [" " | acc]) + + defp mask_agda_noncode(<<92, byte, rest::binary>>, :string, acc), + do: mask_agda_noncode(rest, :string, [[" ", agda_mask_byte(byte)] | acc]) + + defp mask_agda_noncode(<<34, rest::binary>>, :string, acc), + do: mask_agda_noncode(rest, :code, [" " | acc]) + + defp mask_agda_noncode(<>, :code, acc), + do: mask_agda_noncode(rest, :code, [<> | acc]) + + defp mask_agda_noncode(<>, state, acc), + do: mask_agda_noncode(rest, state, [agda_mask_byte(byte) | acc]) + + defp agda_mask_byte(byte) when byte in [10, 13], do: <> + defp agda_mask_byte(_byte), do: " " + defp strip_after_cfg_test(content) do case :binary.match(content, "#[cfg(test)]") do :nomatch -> content diff --git a/lib/rules/workflow_hardening.ex b/lib/rules/workflow_hardening.ex index cd5d3bf5..b6f5da15 100644 --- a/lib/rules/workflow_hardening.ex +++ b/lib/rules/workflow_hardening.ex @@ -1560,54 +1560,502 @@ defmodule Hypatia.Rules.WorkflowHardening do end) end + # WH014 is a bounded literal-path check, not a shell interpreter. Tokens + # keep quoted data separate from control operators. Command substitutions + # are analysed as commands; quoted examples and comments are not commands. + @wh014_report_path ~r/\A[A-Za-z0-9_.\/-]+\.(?:json|sarif)\z/ + @wh014_upload ~r/\Agithub\/codeql-action\/upload-sarif@[^ \t]+\z/ + @wh014_workspace "/__wh014_workspace__" + + defp wh014_masked_upload?(body, inherited_directory) do + [preamble | _] = String.split(body, ~r/^[ \t]*steps:/m, parts: 2) + directory = wh014_default_directory(preamble, inherited_directory) + + body + |> step_bodies() + |> Enum.reduce_while(MapSet.new(), fn step, tainted -> + upload = wh014_step_value(step, "uses") + report = wh014_step_input(step, "sarif_file") + report = wh014_path(report, @wh014_workspace) + + if is_binary(upload) and Regex.match?(@wh014_upload, upload) and + not is_nil(report) and MapSet.member?(tainted, report) do + {:halt, :masked_upload} + else + step_directory = + case wh014_step_value(step, "working-directory") do + nil -> directory + value -> wh014_directory(value, @wh014_workspace) + end + + next = + if is_nil(step_directory) do + tainted + else + step + |> scan_run_blocks() + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.reduce(tainted, fn {_line, runs}, paths -> + # scan_run_blocks returns the scalar header and its body at + # the same line. Use the complete body once, in source order. + run = Enum.max_by(runs, &byte_size/1) + + case wh014_run_tokens(run) do + {:ok, tokens} -> wh014_follow_commands(tokens, paths, step_directory) + :unknown -> paths + end + end) + end + + {:cont, next} + end + end) + |> Kernel.==(:masked_upload) + end + + defp wh014_step_lines(step) do + [item | rest] = String.split(step, "\n") + + if Regex.match?(~r/^[ \t]*-[ \t]*(?:#.*)?$/, item) do + # A bare sequence marker has no key column. Read it from the first + # real mapping entry, keeping nested scalar text at its own indentation. + case Enum.reject(rest, &blank_or_comment?/1) do + [first | _] -> + column = indent_of(first) + if column > indent_of(item), do: {column, rest}, else: {nil, []} + + [] -> + {nil, []} + end + else + case Regex.run(~r/^([ \t]*-[ \t]+)(.*)$/, item) do + [_, prefix, value] -> + column = String.length(prefix) + {column, [String.duplicate(" ", column) <> value | rest]} + + _ -> + {nil, []} + end + end + end + + defp wh014_step_value(step, key) do + {column, lines} = wh014_step_lines(step) + wh014_yaml_value(lines, column, key) + end + + defp wh014_step_input(step, key) do + {column, lines} = wh014_step_lines(step) + + case Enum.drop_while(lines, fn line -> + indent_of(line) != column or not Regex.match?(~r/^[ \t]*with:[ \t]*$/, line) + end) do + [_with | rest] -> + inputs = Enum.take_while(rest, &(blank_or_comment?(&1) or indent_of(&1) > column)) + + case Enum.reject(inputs, &blank_or_comment?/1) do + [first | _] -> wh014_yaml_value(inputs, indent_of(first), key) + [] -> nil + end + + [] -> + nil + end + end + + defp wh014_yaml_value(lines, column, key) do + pattern = Regex.compile!("^[ \\t]*" <> Regex.escape(key) <> ":[ \\t]*(.*)$") + + Enum.find_value(lines, fn line -> + if indent_of(line) == column do + case Regex.run(pattern, line) do + [_, value] -> wh014_literal_scalar(value) + _ -> nil + end + end + end) + end + + defp wh014_literal_scalar(value) do + case Regex.run(~r/\A(?:"([^"]*)"|'([^']*)'|([^ \t#]+))[ \t]*(?:#.*)?\z/, String.trim(value)) do + nil -> nil + captures -> captures |> tl() |> Enum.find(&(&1 != "")) + end + end + + defp wh014_default_directory(content, inherited) do + if Regex.match?(~r/^[ \t]*working-directory:/m, content) do + case Regex.run(@working_directory, content) do + [_, value] -> wh014_directory(value, @wh014_workspace) + _ -> nil + end + else + inherited + end + end + + defp wh014_directory(value, base) do + if is_binary(value) and Regex.match?(~r/\A[A-Za-z0-9_.\/-]+\z/, value), + do: Path.expand(value, base), + else: nil + end + + defp wh014_path(value, directory) do + if is_binary(value) and Regex.match?(@wh014_report_path, value), + do: Path.expand(value, directory), + else: nil + end + + defp wh014_run_tokens(run) do + [header | lines] = String.split(run, "\n") + + with [_, value] <- Regex.run(~r/^[ \t]*(?:-[ \t]+)?run:[ \t]*(.*)$/, header) do + script = + cond do + Regex.match?(~r/\A\|[-+]?[ \t]*(?:#.*)?\z/, value) -> Enum.join(lines, "\n") + String.starts_with?(value, [">", "'", "\""]) -> nil + true -> value + end + + if is_binary(script) do + case wh014_lex(script, :plain, nil, [], false) do + {:ok, tokens, ""} -> {:ok, tokens} + _ -> :unknown + end + else + :unknown + end + else + _ -> :unknown + end + end + + defp wh014_flush(tokens, nil), do: tokens + defp wh014_flush(tokens, word), do: [{:word, word} | tokens] + + defp wh014_lex("", :plain, word, tokens, false), + do: {:ok, Enum.reverse(wh014_flush(tokens, word)), ""} + + defp wh014_lex("", _quote, _word, _tokens, _nested), do: :unknown + + defp wh014_lex(<<")", rest::binary>>, :plain, word, tokens, true), + do: {:ok, Enum.reverse(wh014_flush(tokens, word)), rest} + + defp wh014_lex(<<"'", rest::binary>>, :single, word, tokens, nested), + do: wh014_lex(rest, :plain, word, tokens, nested) + + defp wh014_lex(<<"'", rest::binary>>, :plain, word, tokens, nested), + do: wh014_lex(rest, :single, word || "", tokens, nested) + + defp wh014_lex(<<"\"", rest::binary>>, :double, word, tokens, nested), + do: wh014_lex(rest, :plain, word, tokens, nested) + + defp wh014_lex(<<"\"", rest::binary>>, :plain, word, tokens, nested), + do: wh014_lex(rest, :double, word || "", tokens, nested) + + defp wh014_lex(<<"\\\r\n", rest::binary>>, quote, word, tokens, nested) + when quote in [:plain, :double], + do: wh014_lex(rest, quote, word, tokens, nested) + + defp wh014_lex(<<"\\\n", rest::binary>>, quote, word, tokens, nested) + when quote in [:plain, :double], + do: wh014_lex(rest, quote, word, tokens, nested) + + defp wh014_lex(<<"\\", char::utf8, rest::binary>>, :plain, word, tokens, nested), + do: wh014_lex(rest, :plain, (word || "") <> <>, tokens, nested) + + defp wh014_lex(<<"\\", char::utf8, rest::binary>>, :double, word, tokens, nested) + when char in [36, 96, 34, 92], + do: wh014_lex(rest, :double, (word || "") <> <>, tokens, nested) + + # $$ expands to the shell PID. Its second dollar is not the start of a + # command substitution in printed text such as "$$(jq ... || echo 0)". + defp wh014_lex(<<"$$", rest::binary>>, quote, word, tokens, nested) + when quote in [:plain, :double], + do: wh014_lex(rest, quote, (word || "") <> "$$", tokens, nested) + + # Arithmetic expansion has different token rules and is outside the + # command-substitution grammar. Do not interpret its data as commands. + defp wh014_lex(<<"$((", _rest::binary>>, quote, _word, _tokens, _nested) + when quote in [:plain, :double], + do: :unknown + + defp wh014_lex(<<"$(", rest::binary>>, quote, word, tokens, nested) + when quote in [:plain, :double] do + case wh014_lex(rest, :plain, nil, [], true) do + {:ok, inner, tail} -> + wh014_lex(tail, quote, (word || "") <> "$()", [{:nested, inner} | tokens], nested) + + _ -> + :unknown + end + end + + defp wh014_lex(<<"(", rest::binary>>, :plain, word, tokens, nested) do + case wh014_lex(rest, :plain, nil, [], true) do + {:ok, inner, tail} -> + wh014_lex(tail, :plain, nil, [{:nested, inner} | wh014_flush(tokens, word)], nested) + + _ -> + :unknown + end + end + + # A here-document needs its own grammar. Do not read its data as shell + # commands or claim that this run's dynamic flow has been analysed. + defp wh014_lex(<<"<<", _rest::binary>>, :plain, _word, _tokens, _nested), do: :unknown + + defp wh014_lex(<<"#", rest::binary>>, :plain, nil, tokens, nested) do + case String.split(rest, "\n", parts: 2) do + [_comment, tail] -> wh014_lex("\n" <> tail, :plain, nil, tokens, nested) + [_comment] -> wh014_lex("", :plain, nil, tokens, nested) + end + end + + defp wh014_lex(<>, :plain, word, tokens, nested) + when char in [?\s, ?\t, ?\r], + do: wh014_lex(rest, :plain, nil, wh014_flush(tokens, word), nested) + + defp wh014_lex(<>, :plain, word, tokens, nested) + when char in [?\n, ?;], + do: wh014_lex(rest, :plain, nil, [:sequence | wh014_flush(tokens, word)], nested) + + defp wh014_lex(<<"||", rest::binary>>, :plain, word, tokens, nested), + do: wh014_lex(rest, :plain, nil, [:or | wh014_flush(tokens, word)], nested) + + defp wh014_lex(<<"&&", rest::binary>>, :plain, word, tokens, nested), + do: wh014_lex(rest, :plain, nil, [:and | wh014_flush(tokens, word)], nested) + + defp wh014_lex(<<"&", rest::binary>>, :plain, word, tokens, nested), + do: wh014_lex(rest, :plain, nil, [:sequence | wh014_flush(tokens, word)], nested) + + defp wh014_lex(<<"|", rest::binary>>, :plain, word, tokens, nested), + do: wh014_lex(rest, :plain, nil, [:pipe | wh014_flush(tokens, word)], nested) + + # Descriptor duplication is part of this command, not a background '&'. + # Preserve the boundary without inferring a dynamic descriptor-to-file flow. + defp wh014_lex(<>, :plain, word, tokens, nested) + when redirect in [?<, ?>] do + words = + if is_binary(word) and Regex.match?(~r/\A[0-9]+\z/, word), + do: tokens, + else: wh014_flush(tokens, word) + + wh014_lex(rest, :plain, nil, [:fd_duplicate | words], nested) + end + + defp wh014_lex(<<">", rest::binary>>, :plain, word, tokens, nested) do + {kind, rest} = + if String.starts_with?(rest, ">"), + do: {:append, String.slice(rest, 1..-1//1)}, + else: {:out, rest} + + {descriptor, words} = + if word in ["1", "2"], + do: {word, tokens}, + else: {"1", wh014_flush(tokens, word)} + + wh014_lex(rest, :plain, nil, [{kind, descriptor} | words], nested) + end + + defp wh014_lex(<<"<", rest::binary>>, :plain, word, tokens, nested), + do: wh014_lex(rest, :plain, nil, [:input | wh014_flush(tokens, word)], nested) + + defp wh014_lex(<>, quote, word, tokens, nested), + do: wh014_lex(rest, quote, (word || "") <> <>, tokens, nested) + + defp wh014_segments(tokens) do + {segments, current} = + Enum.reduce(tokens, {[], []}, fn token, {segments, current} -> + if token in [:sequence, :and, :or, :pipe] do + if current == [] do + {segments, current} + else + {[{Enum.reverse(current), token} | segments], []} + end + else + {segments, [token | current]} + end + end) + + Enum.reverse([{Enum.reverse(current), :sequence} | segments]) + |> Enum.reject(&(elem(&1, 0) == [])) + end + + defp wh014_follow_commands(tokens, tainted, directory) do + segments = wh014_segments(tokens) + + segments + |> Enum.with_index() + |> Enum.reduce({tainted, []}, fn {{command, separator}, index}, {paths, piped_inputs} -> + paths = + Enum.reduce(command, paths, fn + {:nested, inner}, acc -> wh014_follow_commands(inner, acc, directory) + _, acc -> acc + end) + + {words, redirects} = wh014_command_parts(command) + {inputs, copy_target} = wh014_inputs(words) + inputs = + if List.first(words) in ["jq", "cat"] do + redirected = for {:input, "0", path} <- redirects, do: path + inputs ++ redirected + else + inputs + end + + inputs = Enum.map(inputs, &wh014_path(&1, directory)) |> Enum.reject(&is_nil/1) + inputs = if List.first(words) in ["jq", "cat"], do: inputs ++ piped_inputs, else: inputs + + outputs = + for {kind, "1", value} <- redirects, + path = wh014_path(value, directory), + not is_nil(path), + do: {kind, path} + + outputs = + case wh014_path(copy_target, directory) do + nil -> outputs + path -> [{:out, path} | outputs] + end + + next_words = + case Enum.at(segments, index + 1) do + {next, _} -> next |> wh014_command_parts() |> elem(0) + nil -> [] + end + + count_mask? = + List.first(words) == "jq" and separator == :or and next_words == ["echo", "0"] + + paths = + if count_mask?, do: Enum.reduce(inputs, paths, &MapSet.put(&2, &1)), else: paths + + inherited? = Enum.any?(inputs, &MapSet.member?(paths, &1)) + output_mask? = separator == :or and next_words == ["true"] + + paths = + Enum.reduce(outputs, paths, fn {kind, path}, acc -> + # Outputs have already passed the JSON/SARIF literal-path boundary. + if inherited? or output_mask? or (kind == :append and MapSet.member?(acc, path)), + do: MapSet.put(acc, path), + else: MapSet.delete(acc, path) + end) + + {paths, if(separator == :pipe, do: inputs, else: [])} + end) + |> elem(0) + end + + defp wh014_command_parts(tokens), do: wh014_command_parts(tokens, [], []) + + defp wh014_command_parts([{kind, descriptor}, {:word, path} | rest], words, redirects) + when kind in [:out, :append], + do: wh014_command_parts(rest, words, [{kind, descriptor, path} | redirects]) + + # The target of >& / <& is a descriptor, not an input/output filename. + defp wh014_command_parts([:fd_duplicate, {:word, _descriptor} | rest], words, redirects), + do: wh014_command_parts(rest, words, redirects) + + defp wh014_command_parts([:input, {:word, path} | rest], words, redirects), + do: wh014_command_parts(rest, words, [{:input, "0", path} | redirects]) + + defp wh014_command_parts([{:word, word} | rest], words, redirects), + do: wh014_command_parts(rest, [word | words], redirects) + + defp wh014_command_parts([_ | rest], words, redirects), + do: wh014_command_parts(rest, words, redirects) + + defp wh014_command_parts([], words, redirects), + do: {Enum.reverse(words), Enum.reverse(redirects)} + + defp wh014_inputs(["jq" | args]), do: {wh014_jq_inputs(args, false, []), nil} + defp wh014_inputs(["cat" | args]), + do: {Enum.reject(args, &String.starts_with?(&1, "-")), nil} + + defp wh014_inputs([command | args]) when command in ["cp", "mv"] do + case Enum.reject(args, &(&1 in ["-f", "-v", "--"])) do + [source, target] -> {[source], target} + _ -> {[], nil} + end + end + + defp wh014_inputs(_), do: {[], nil} + + defp wh014_jq_inputs([option, _name, path | rest], filter?, inputs) + when option in ["--slurpfile", "--rawfile"], + do: wh014_jq_inputs(rest, filter?, [path | inputs]) + + defp wh014_jq_inputs([option, _name, _value | rest], filter?, inputs) + when option in ["--arg", "--argjson"], + do: wh014_jq_inputs(rest, filter?, inputs) + + defp wh014_jq_inputs([option, _program | rest], _filter?, inputs) + when option in ["-f", "--from-file"], + do: wh014_jq_inputs(rest, true, inputs) + + defp wh014_jq_inputs([option | _], _filter?, _inputs) + when option in ["--args", "--jsonargs"], + do: [] + + defp wh014_jq_inputs([arg | rest], filter?, inputs) do + cond do + String.starts_with?(arg, "-") -> wh014_jq_inputs(rest, filter?, inputs) + not filter? -> wh014_jq_inputs(rest, true, inputs) + true -> wh014_jq_inputs(rest, true, [arg | inputs]) + end + end + + defp wh014_jq_inputs([], _filter?, inputs), do: Enum.reverse(inputs) + # ── WH014 ─────────────────────────────────────────────────────────────── # Measured 2026-09-14: this shape DELETED 84 real code-scanning alerts from # hyperpolymath/academic-workflow-suite while every run reported success. - @sarif_upload_re ~r{codeql-action/upload-sarif} - # A scanner invocation whose failure is swallowed: `… > something.json || true` - @masked_scan_re ~r/>\s*[^\s|;&]*\.json\s*(?:2>[^\s|;&]*\s*)?\|\|\s*true/ - # A count that manufactures a zero when the artefact is unparseable. - @masked_count_re ~r/\|\|\s*echo\s+0\b/ - # An assertion that the findings artefact is a NON-EMPTY array. `jq -e` sets a - # non-zero exit on false/null, and `length > 0` is the estate's canonical form - # (standards/.github/workflows/hypatia-scan-reusable.yml). - @findings_assertion_re ~r/length\s*>\s*0|jq\s+-e/ - @doc """ - WH014 — a scanner whose failure is masked, feeding an upload to GitHub code - scanning, with nothing asserting the findings artefact is non-empty. - - This is not merely a permissive gate. It **deletes security alerts**. + WH014 — a scanner or findings-count failure is masked in a workflow that + uploads SARIF to GitHub code scanning. - GitHub reconciles each code-scanning upload against the previous analysis - carrying the same tool name and category: any alert absent from the new - analysis is AUTO-CLOSED. So when a scanner crashes and its failure is - swallowed: + A failed or incomplete scan must not become a complete empty analysis. + GitHub reconciles code-scanning uploads against previous analyses for the + same tool and category, so an empty analysis can close existing alerts. + For example: hypatia-cli.sh scan . --exit-zero > hypatia-findings.json || true FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0) - the workflow proceeds with a count of zero, renders a syntactically valid - SARIF containing **zero results**, uploads it, and every previously-open - alert for that category is closed. The job is green at every step. Nothing - in `statusCheckRollup`, the run conclusion, or the check rollup shows that - anything went wrong — the only visible trace is `results_count` falling to 0 - in `code-scanning/analyses`. + can proceed with a manufactured zero, render a valid SARIF with no results, + and upload it despite the scanner having failed. Measured on 2026-09-14 in `hyperpolymath/academic-workflow-suite`: an analysis carrying **84** findings on 09-11 was followed by six green runs uploading **0** results, closing all 84. - The rule fires only when all three hold, which keeps it conservative: - - 1. the workflow uploads SARIF to code scanning; - 2. a scanner invocation or its count is masked (`|| true`, `|| echo 0`); - 3. nothing asserts the findings artefact is a non-empty array. - - Condition 3 is what exempts a correctly-written caller. The estate's - `hypatia-scan-reusable.yml` performs exactly that assertion - (`type == "array" and length > 0`, `exit 2` otherwise) and so never fires. + The rule reports a masked JSON/SARIF-producing invocation or count (`|| true`, + `|| echo 0`) only when a visible literal-path chain carries that report + to the named `sarif_file` of a later code-scanning upload in the same job. + A masked count of unrelated optional metadata does not establish that + relationship. Neither does an external converter's name alone. + + Neither `jq -e` nor a non-empty array establishes scanner completion: the + assertion may inspect unrelated data, or a failed scan may leave partial + valid findings. Conversely, a complete clean scan may legitimately produce + an empty array. Preserve the scanner's exit status and findings, reject + execution or coverage errors, then validate the expected report shape. + + This report-only rule follows literal JSON/SARIF paths through `jq`, `cat`, + `cp`, `mv` and shell redirections, in extracted step order. It recognises + `$(...)` command substitutions and backslash-newline continuations, keeps + independent commands separate, and ignores mask text inside comments or quoted data. + A known overwrite replaces the earlier path relationship. Literal working + directories are resolved relative to the workspace. + + This is deliberately bounded: dynamic paths, external script internals, + cross-job artefacts, arithmetic expansions, here-documents, folded or quoted + YAML run scalars and general shell control flow are not analysed. Missing literal-flow evidence + means this rule cannot establish its pattern; it is never a scan-completion + or safety verdict. Do not treat absence of WH014 as assurance. """ def wh014_masked_scanner_upload(repo_path) do repo_path @@ -1616,31 +2064,38 @@ defmodule Hypatia.Rules.WorkflowHardening do content = File.read!(path) rel = Path.relative_to(path, repo_path) - uploads? = Regex.match?(@sarif_upload_re, content) - masked? = Regex.match?(@masked_scan_re, content) or Regex.match?(@masked_count_re, content) - asserts? = Regex.match?(@findings_assertion_re, content) + affected_jobs = + content + |> extract_job_blocks() + |> Enum.filter(fn {_job, _line, body} -> + directory = wh014_default_directory(workflow_preamble(content), @wh014_workspace) + wh014_masked_upload?(body, directory) + end) - if uploads? and masked? and not asserts? do + if affected_jobs != [] do [ %{ rule: "WH014", file: rel, severity: :high, reason: - "workflow #{rel} uploads SARIF to code scanning but masks the scanner's " <> - "failure (`|| true` / `|| echo 0`) and never asserts the findings " <> - "artefact is a non-empty array. When the scanner fails, this uploads a " <> - "SARIF with zero results, and GitHub AUTO-CLOSES every previously-open " <> - "alert for that category — silently, with the job green.", + "workflow #{rel} has a literal report path from a masked JSON/SARIF-producing " <> + "command or findings count (`|| true` / `|| echo 0`) to a SARIF upload. A failed or " <> + "incomplete scan can become an empty SARIF upload that closes existing " <> + "alerts. An unrelated assertion or non-empty findings array does not " <> + "establish scanner completion.", action: :report, fix_recipe: "assert-findings-before-sarif-upload", detail: %{ + jobs: Enum.map(affected_jobs, fn {job, _line, _body} -> job end), + analysis_scope: "literal report paths in the same job; not scan-completion evidence", fix: - "Remove the `|| true` and the `|| echo 0` fallback, then validate before " <> - "uploading: `jq -e 'type == \"array\" and length > 0' findings.json` and " <> - "exit non-zero if it fails. Prefer calling " <> - "hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml, " <> - "which already performs this assertion." + "Preserve the scanner exit status and reject execution or coverage " <> + "errors before uploading. For Hypatia, exit 0 is a successful scan, " <> + "exit 1 carries findings, and exit 2 is an error; retain real findings. " <> + "Then validate `jq -e 'type == \"array\"' findings.json`, allowing an " <> + "empty array from a complete scan. Remove `|| true` / `|| echo 0`; " <> + "never turn a scanner or parsing failure into zero findings." } } ] diff --git a/lib/vcl/client.ex b/lib/vcl/client.ex index 7e14ff3a..e7d99be5 100644 --- a/lib/vcl/client.ex +++ b/lib/vcl/client.ex @@ -96,8 +96,15 @@ defmodule Hypatia.VCL.Client do @impl true def handle_call({:query, query_string, opts}, _from, state) do - # Check cache first - cache_key = :erlang.phash2({query_string, opts}) + # Cache and execute against the same selected root. A runtime store + # change must not return a still-live cache entry from the old store. + data_path = + opts + |> Keyword.get(:data_path_override, Hypatia.Paths.verisimdb_data()) + |> Path.expand() + + opts = Keyword.put(opts, :data_path_override, data_path) + cache_key = {query_string, opts} now = System.monotonic_time(:millisecond) case Map.get(state.cache, cache_key) do diff --git a/lib/vcl/file_executor.ex b/lib/vcl/file_executor.ex index cb4e81cc..79afd5bb 100644 --- a/lib/vcl/file_executor.ex +++ b/lib/vcl/file_executor.ex @@ -32,7 +32,7 @@ defmodule Hypatia.VCL.FileExecutor do require Logger - @verisimdb_data_path Application.compile_env(:hypatia, :verisimdb_data_path, "data/verisim") + alias Hypatia.Paths @store_map %{ "scans" => "scans", @@ -65,6 +65,9 @@ defmodule Hypatia.VCL.FileExecutor do cloned path as the data root. """ def execute(ast, opts \\ []) do + # Resolve once per query. Remote clones replace this explicit local root. + opts = Keyword.put(opts, :data_path_override, expand_path(opts)) + case ast.source do {:store, store_id} -> execute_store_query(store_id, ast, opts) @@ -87,13 +90,13 @@ defmodule Hypatia.VCL.FileExecutor do # Store Queries # --------------------------------------------------------------------------- - defp execute_store_query(store_id, ast, _opts) do + defp execute_store_query(store_id, ast, opts) do case Map.get(@store_map, store_id) do nil -> {:error, "Unknown store: #{store_id}"} dir -> - base_path = Path.join(expand_path(), dir) + base_path = Path.join(expand_path(opts), dir) results = case store_id do @@ -118,7 +121,7 @@ defmodule Hypatia.VCL.FileExecutor do # Federation Queries (cross-store) # --------------------------------------------------------------------------- - defp execute_federation_query(pattern, drift_policy, ast, _opts) do + defp execute_federation_query(pattern, drift_policy, ast, opts) do # Federation patterns like /scans/*, /recipes/*, etc. store_name = pattern @@ -131,11 +134,11 @@ defmodule Hypatia.VCL.FileExecutor do # Query across ALL stores results = Enum.flat_map(["scans", "patterns", "recipes", "outcomes"], fn store -> - base_path = Path.join(expand_path(), store) + base_path = Path.join(expand_path(opts), store) case store do "scans" -> load_json_directory(base_path, ".json") - "patterns" -> load_single_json(Path.join(expand_path(), "patterns/registry.json")) + "patterns" -> load_single_json(Path.join(expand_path(opts), "patterns/registry.json")) "recipes" -> load_json_directory(base_path, ".json", "recipe-") "outcomes" -> load_jsonl_directory(base_path) _ -> [] @@ -143,12 +146,12 @@ defmodule Hypatia.VCL.FileExecutor do end) filtered = apply_where(results, ast.where) - drifted = apply_drift_policy(filtered, drift_policy) + drifted = apply_drift_policy(filtered, drift_policy, opts) paginated = apply_pagination(drifted, ast.limit, ast.offset) {:ok, paginated} dir -> - base_path = Path.join(expand_path(), dir) + base_path = Path.join(expand_path(opts), dir) results = cond do @@ -158,7 +161,7 @@ defmodule Hypatia.VCL.FileExecutor do end filtered = apply_where(results, ast.where) - drifted = apply_drift_policy(filtered, drift_policy) + drifted = apply_drift_policy(filtered, drift_policy, opts) paginated = apply_pagination(drifted, ast.limit, ast.offset) {:ok, paginated} end @@ -205,7 +208,7 @@ defmodule Hypatia.VCL.FileExecutor do # Execute a query with an explicit data path override. Dispatches to the # same store/federation/hexad logic but resolves paths against the override - # directory instead of the default @verisimdb_data_path. + # directory instead of the current local verisim data store. defp execute_with_path(ast, opts) do case ast.source do {:store, store_id} -> @@ -221,7 +224,7 @@ defmodule Hypatia.VCL.FileExecutor do # Store query against an arbitrary base path (used for remote clones). defp execute_store_query_at(store_id, ast, opts) do - base = Keyword.get(opts, :data_path_override, expand_path()) + base = expand_path(opts) case Map.get(@store_map, store_id) do nil -> @@ -251,7 +254,7 @@ defmodule Hypatia.VCL.FileExecutor do # Federation query against an arbitrary base path (used for remote clones). defp execute_federation_query_at(pattern, drift_policy, ast, opts) do - base = Keyword.get(opts, :data_path_override, expand_path()) + base = expand_path(opts) store_name = pattern @@ -276,7 +279,7 @@ defmodule Hypatia.VCL.FileExecutor do end) filtered = apply_where(results, ast.where) - drifted = apply_drift_policy(filtered, drift_policy) + drifted = apply_drift_policy(filtered, drift_policy, opts) paginated = apply_pagination(drifted, ast.limit, ast.offset) {:ok, paginated} @@ -291,7 +294,7 @@ defmodule Hypatia.VCL.FileExecutor do end filtered = apply_where(results, ast.where) - drifted = apply_drift_policy(filtered, drift_policy) + drifted = apply_drift_policy(filtered, drift_policy, opts) paginated = apply_pagination(drifted, ast.limit, ast.offset) {:ok, paginated} end @@ -301,21 +304,21 @@ defmodule Hypatia.VCL.FileExecutor do # Hexad Queries (single entity by ID) # --------------------------------------------------------------------------- - defp execute_hexad_query(entity_id, ast, _opts) do + defp execute_hexad_query(entity_id, ast, opts) do # Entity ID could be a repo name, pattern ID, or recipe ID result = cond do # Try as scan (repo name) - File.exists?(scan_path(entity_id)) -> - load_single_json(scan_path(entity_id)) + File.exists?(scan_path(entity_id, opts)) -> + load_single_json(scan_path(entity_id, opts)) # Try as recipe - File.exists?(recipe_path(entity_id)) -> - load_single_json(recipe_path(entity_id)) + File.exists?(recipe_path(entity_id, opts)) -> + load_single_json(recipe_path(entity_id, opts)) # Try as pattern ID in registry true -> - case load_single_json(Path.join(expand_path(), "patterns/registry.json")) do + case load_single_json(Path.join(expand_path(opts), "patterns/registry.json")) do [%{"patterns" => patterns}] -> case Map.get(patterns, entity_id) do nil -> [] @@ -471,10 +474,10 @@ defmodule Hypatia.VCL.FileExecutor do # | "aggressive" | No discount (trust cross-repo equally) | # | "language_aware"| Full within language family, 70% discount otherwise | - defp apply_drift_policy(results, nil), do: results - defp apply_drift_policy(results, ""), do: results + defp apply_drift_policy(results, nil, _opts), do: results + defp apply_drift_policy(results, "", _opts), do: results - defp apply_drift_policy(results, policy_str) when is_binary(policy_str) do + defp apply_drift_policy(results, policy_str, opts) when is_binary(policy_str) do policy = parse_drift_policy(policy_str) case policy do @@ -489,14 +492,14 @@ defmodule Hypatia.VCL.FileExecutor do apply_confidence_discount(results, 0.75) :language_aware -> - apply_language_aware_discount(results) + apply_language_aware_discount(results, opts) _ -> results end end - defp apply_drift_policy(results, _), do: results + defp apply_drift_policy(results, _, _opts), do: results defp parse_drift_policy(str) do case String.downcase(String.trim(str)) do @@ -540,12 +543,12 @@ defmodule Hypatia.VCL.FileExecutor do # For language-aware policy, group results by repo language family # and apply full confidence within-family, discounted cross-family. # Results without a detectable repo get moderate discount. - defp apply_language_aware_discount(results) do + defp apply_language_aware_discount(results, opts) do language_families = Hypatia.CrossRepoLearning.language_families() Enum.map(results, fn item -> repo = Map.get(item, "repo", "") - lang = detect_item_language(item, repo) + lang = detect_item_language(item, repo, opts) family = Map.get(language_families, lang, :unknown) # Tag each result with its language family for downstream use @@ -559,15 +562,15 @@ defmodule Hypatia.VCL.FileExecutor do end # Try to determine language from the item itself or from scan data - defp detect_item_language(item, repo) do + defp detect_item_language(item, repo, opts) do cond do Map.has_key?(item, "primary_language") -> String.downcase(Map.get(item, "primary_language", "unknown")) repo != "" -> - Hypatia.CrossRepoLearning.drift_discount(:language_aware, repo, repo) - # We just need the language, not the discount -- detect via scan - scan_path = Path.join([expand_path(), "scans", "#{repo}.json"]) + # Use the same selected source as the queried outcomes, including + # an explicit remote-clone root. No second local-store lookup. + scan_path = Path.join([expand_path(opts), "scans", "#{repo}.json"]) case File.read(scan_path) do {:ok, content} -> @@ -718,13 +721,17 @@ defmodule Hypatia.VCL.FileExecutor do # Path Helpers # --------------------------------------------------------------------------- - defp expand_path, do: Path.expand(@verisimdb_data_path) + defp expand_path(opts) do + opts + |> Keyword.get(:data_path_override, Paths.verisimdb_data()) + |> Path.expand() + end - defp scan_path(repo_name) do - Path.join([expand_path(), "scans", "#{repo_name}.json"]) + defp scan_path(repo_name, opts) do + Path.join([expand_path(opts), "scans", "#{repo_name}.json"]) end - defp recipe_path(recipe_id) do - Path.join([expand_path(), "recipes", "#{recipe_id}.json"]) + defp recipe_path(recipe_id, opts) do + Path.join([expand_path(opts), "recipes", "#{recipe_id}.json"]) end end diff --git a/lib/vcl/query.ex b/lib/vcl/query.ex index 62dead4b..e3beb0c3 100644 --- a/lib/vcl/query.ex +++ b/lib/vcl/query.ex @@ -124,11 +124,7 @@ defmodule Hypatia.VCL.Query do @doc "Fetch proven substitutions." def fetch_substitutions do - path = - Path.join( - Application.get_env(:hypatia, :verisimdb_data_path, "data/verisim"), - "recipes/proven-substitutions.json" - ) + path = Path.join(Path.expand(Hypatia.Paths.recipes()), "proven-substitutions.json") case File.read(path) do {:ok, content} -> diff --git a/lib/verisim_connector.ex b/lib/verisim_connector.ex index 81146375..af13140e 100644 --- a/lib/verisim_connector.ex +++ b/lib/verisim_connector.ex @@ -21,10 +21,9 @@ defmodule Hypatia.VerisimConnector do Direct file reads preserved as fallback for graceful degradation. """ + alias Hypatia.Paths alias Hypatia.VCL.Query, as: VCL - @verisimdb_data_path Application.compile_env(:hypatia, :verisimdb_data_path, "data/verisim") - require Logger # ==================================================================== @@ -185,8 +184,9 @@ defmodule Hypatia.VerisimConnector do # Fallback: Direct File I/O (graceful degradation) # ==================================================================== - defp fetch_all_scans_fallback do - scans_path = Path.join(expand_path(@verisimdb_data_path), "scans") + @doc false + def fetch_all_scans_fallback do + scans_path = Path.expand(Paths.scans()) case File.ls(scans_path) do {:ok, files} -> @@ -202,7 +202,7 @@ defmodule Hypatia.VerisimConnector do end defp load_scan_fallback(filename) do - path = Path.join([expand_path(@verisimdb_data_path), "scans", filename]) + path = Path.join(Path.expand(Paths.scans()), filename) repo_name = String.replace(filename, ".json", "") with {:ok, content} <- File.read(path), @@ -215,8 +215,9 @@ defmodule Hypatia.VerisimConnector do end end - defp fetch_pattern_registry_fallback do - path = Path.join(expand_path(@verisimdb_data_path), "patterns/registry.json") + @doc false + def fetch_pattern_registry_fallback do + path = Path.join(Path.expand(Paths.patterns()), "registry.json") case File.read(path) do {:ok, content} -> @@ -230,8 +231,9 @@ defmodule Hypatia.VerisimConnector do end end - defp fetch_all_recipes_fallback do - recipes_path = Path.join(expand_path(@verisimdb_data_path), "recipes") + @doc false + def fetch_all_recipes_fallback do + recipes_path = Path.expand(Paths.recipes()) case File.ls(recipes_path) do {:ok, files} -> @@ -248,8 +250,9 @@ defmodule Hypatia.VerisimConnector do end end - defp fetch_substitutions_fallback do - path = Path.join(expand_path(@verisimdb_data_path), "recipes/proven-substitutions.json") + @doc false + def fetch_substitutions_fallback do + path = Path.join(Path.expand(Paths.recipes()), "proven-substitutions.json") case load_json(path) do nil -> [] @@ -257,8 +260,9 @@ defmodule Hypatia.VerisimConnector do end end - defp fetch_index_fallback do - path = Path.join(expand_path(@verisimdb_data_path), "index.json") + @doc false + def fetch_index_fallback do + path = Path.join(Path.expand(Paths.verisimdb_data()), "index.json") case load_json(path) do nil -> {:error, :not_found} @@ -266,8 +270,9 @@ defmodule Hypatia.VerisimConnector do end end - defp fetch_all_outcomes_fallback do - outcomes_path = Path.join(expand_path(@verisimdb_data_path), "outcomes") + @doc false + def fetch_all_outcomes_fallback do + outcomes_path = Path.expand(Paths.outcomes()) case File.ls(outcomes_path) do {:ok, files} -> @@ -308,8 +313,4 @@ defmodule Hypatia.VerisimConnector do nil end end - - defp expand_path(path) do - Path.expand(path) - end end diff --git a/test/anomaly_detector_test.exs b/test/anomaly_detector_test.exs index f99bcabe..d2063d69 100644 --- a/test/anomaly_detector_test.exs +++ b/test/anomaly_detector_test.exs @@ -10,139 +10,149 @@ defmodule Hypatia.Watcher.AnomalyDetectorTest do setup do case Process.whereis(Hypatia.Watcher.PubSub) do nil -> - {:ok, pid} = Registry.start_link(keys: :duplicate, name: Hypatia.Watcher.PubSub) - on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) + start_supervised!({Registry, keys: :duplicate, name: Hypatia.Watcher.PubSub}) _ -> :ok end case Process.whereis(Hypatia.Watcher) do - nil -> - {:ok, pid} = Hypatia.Watcher.start_link([]) - on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) - - _ -> - :ok + nil -> start_supervised!(Hypatia.Watcher) + _ -> :ok end - case Process.whereis(AnomalyDetector) do - nil -> - {:ok, pid} = AnomalyDetector.start_link([]) - on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) + # Finish earlier watcher work before subscribing a fresh detector. + assert %{counts: _} = Hypatia.Watcher.snapshot() - _ -> - :ok - end + # The application singleton retains its window and alert deduplication + # state across tests. Exercise the real callbacks in an unnamed, + # test-supervised instance instead of assuming that singleton is fresh. + detector = + start_supervised!(%{ + id: AnomalyDetector, + start: {GenServer, :start_link, [AnomalyDetector, []]} + }) + + assert GenServer.call(detector, :window) == [] + %{detector: detector} + end + + defp record_outcomes(outcomes) do + Enum.each(outcomes, fn outcome -> + T.outcome_recorded( + recipe_id: "r", + repo: "x", + outcome: outcome, + verification: if(outcome == "success", do: "verified", else: "unverified") + ) + end) + end + + defp delivered_window(detector) do + caller = self() + reference = make_ref() + + # Telemetry casts and this system request are sent by the test process + # to the Watcher in order. Run the second barrier IN the Watcher: its + # Registry broadcasts and this detector call then have the same sender. + # A call directly from the test to the detector would not order the two + # mailboxes. Return the Watcher's state unchanged. + :sys.replace_state(Hypatia.Watcher, fn state -> + window = GenServer.call(detector, :window) + send(caller, {reference, window}) + state + end) + + assert_receive {^reference, window}, 1000 + window + end + + defp capture_anomalies(detector) do + handler = {__MODULE__, make_ref()} - :ok + :ok = + :telemetry.attach( + handler, + [:hypatia, :anomaly, :detected], + &__MODULE__.handle_anomaly/4, + {self(), detector} + ) + + on_exit(fn -> :telemetry.detach(handler) end) + end + + @doc false + def handle_anomaly(_event, measurements, metadata, {caller, detector}) do + # The supervised application detector can also emit events. Only this + # test's detector and its statistical success-rate signal are evidence + # for these tests; independent ESN drift is a different alert path. + if self() == detector and Map.get(metadata, :kind) == :success_rate_drop do + send(caller, {:caught, measurements, metadata}) + end end describe "outcome ingestion" do - test "rolling window accumulates outcome.recorded events" do - for _ <- 1..5 do - T.outcome_recorded( - recipe_id: "r", - repo: "x", - outcome: "success", - verification: "verified" - ) - end - - Process.sleep(100) - - window = AnomalyDetector.window() - assert length(window) >= 5 + test "rolling window accumulates outcome.recorded events", %{detector: detector} do + record_outcomes(List.duplicate("success", 5)) + assert delivered_window(detector) == List.duplicate(true, 5) end end describe "evaluate (insufficient data)" do - test "below min_outcomes_for_alert, tick is a no-op" do - # Fresh detector — fewer than 30 outcomes - assert :ok = AnomalyDetector.tick_now() + test "below min_outcomes_for_alert, tick emits no statistical alert", %{detector: detector} do + capture_anomalies(detector) + assert delivered_window(detector) == [] + assert :ok = GenServer.call(detector, :tick_now) + refute_receive {:caught, _, _}, 200 end end describe "evaluate (clear regression)" do - test "emits hypatia.anomaly.detected when recent rate drops 100% → 0%" do - # Capture the test pid OUTSIDE the handler closure: self() inside the - # function body evaluates when the handler runs — in the *emitting* - # process (the detector) — so send(self(), ...) posted to the detector's - # own mailbox and this test's assert_receive always timed out. - test_pid = self() - - :ok = - :telemetry.attach( - "anomaly-test-handler", - [:hypatia, :anomaly, :detected], - fn _event, measurements, metadata, _config -> - send(test_pid, {:caught, measurements, metadata}) - end, - nil - ) - - on_exit(fn -> :telemetry.detach("anomaly-test-handler") end) - - # 170 baseline successes + 30 recent failures = clear regression - for _ <- 1..170 do - T.outcome_recorded( - recipe_id: "r", - repo: "x", - outcome: "success", - verification: "verified" - ) - end - - for _ <- 1..30 do - T.outcome_recorded( - recipe_id: "r", - repo: "x", - outcome: "failure", - verification: "unverified" - ) - end - - # Let the cast queue drain - Process.sleep(200) - - :ok = AnomalyDetector.tick_now() + test "emits hypatia.anomaly.detected when recent rate drops 100% → 0%", %{ + detector: detector + } do + capture_anomalies(detector) + + record_outcomes(List.duplicate("success", 170) ++ List.duplicate("failure", 30)) + assert delivered_window(detector) == List.duplicate(false, 30) ++ List.duplicate(true, 170) + :ok = GenServer.call(detector, :tick_now) assert_receive {:caught, measurements, metadata}, 1000 - assert measurements.recent_rate < 0.5 - assert measurements.baseline_rate > 0.5 + assert measurements.recent_rate == 0.0 + assert measurements.baseline_rate == 1.0 assert measurements.sigma_distance > 2.0 assert metadata.kind == :success_rate_drop + assert metadata.recent_count == 30 + assert metadata.baseline_count == 170 end - end - - describe "evaluate (stable history)" do - test "does NOT emit when recent rate matches baseline" do - caller = self() - :ok = - :telemetry.attach( - "anomaly-stable-handler", - [:hypatia, :anomaly, :detected], - fn _event, _measurements, _metadata, _config -> - send(caller, :unexpected_anomaly) - end, - nil - ) + test "a repeated evaluation deduplicates the same observed regression", %{detector: detector} do + capture_anomalies(detector) + record_outcomes(List.duplicate("success", 170) ++ List.duplicate("failure", 30)) + expected = List.duplicate(false, 30) ++ List.duplicate(true, 170) + assert delivered_window(detector) == expected - on_exit(fn -> :telemetry.detach("anomaly-stable-handler") end) + assert :ok = GenServer.call(detector, :tick_now) + assert_receive {:caught, _, %{kind: :success_rate_drop}}, 1000 - # Mixed but stable history: 80% success across both halves. - for i <- 1..200 do - outcome = if rem(i, 5) == 0, do: "failure", else: "success" - T.outcome_recorded(recipe_id: "r", repo: "x", outcome: outcome, verification: "verified") - end + assert :ok = GenServer.call(detector, :tick_now) + assert GenServer.call(detector, :window) == expected + refute_receive {:caught, _, _}, 200 + end + end - Process.sleep(200) + describe "evaluate (stable history)" do + test "does NOT emit when recent rate matches baseline", %{detector: detector} do + capture_anomalies(detector) - :ok = AnomalyDetector.tick_now() + outcomes = for i <- 1..200, do: if(rem(i, 5) == 0, do: "failure", else: "success") + record_outcomes(outcomes) + expected = outcomes |> Enum.reverse() |> Enum.map(&(&1 == "success")) + assert delivered_window(detector) == expected - refute_receive :unexpected_anomaly, 200 + :ok = GenServer.call(detector, :tick_now) + refute_receive {:caught, _, _}, 200 end end end diff --git a/test/code_safety_test.exs b/test/code_safety_test.exs index 258f9034..4f77f67f 100644 --- a/test/code_safety_test.exs +++ b/test/code_safety_test.exs @@ -332,6 +332,92 @@ defmodule Hypatia.Rules.CodeSafetyTest do assert Enum.any?(findings, &(&1.rule == :agda_postulate)) end + test "Agda identifiers containing postulate are not declaration keywords" do + for name <- [ + "no-unconditional-postulate-promotion", + "postulate-proof", + "proof-postulate", + "postulate?", + "postulate'", + "postulate₁", + "⊢postulate" + ] do + code = "#{name} : Set₁\n#{name} = Set\n" + findings = CodeSafety.scan_content(code, "agda") + refute Enum.any?(findings, &(&1.rule == :agda_postulate)), name + end + end + + test "Agda postulate declarations retain critical severity across layout forms" do + for code <- [ + "postulate A : Set\n", + "postulate\n A : Set\n", + "private postulate A : Set\n", + "module M where postulate A : Set\n", + "f : Set₁\nf = A\n where\n postulate A : Set₁\n", + "postulate{- declaration separator -} A : Set\n", + "\uFEFFpostulate A : Set\n" + ] do + assert [%{rule: :agda_postulate, severity: :critical, occurrences: 1}] = + CodeSafety.scan_content(code, "agda") + end + end + + test "Agda inline and nested block comments are not declarations" do + code = """ + module M where + harmless : Set₁ -- postulate A : Set + harmless = Set + {- outer comment + {- inner comment -} postulate B : Set + -} + """ + + refute Enum.any?(CodeSafety.scan_content(code, "agda"), &(&1.rule == :agda_postulate)) + end + + test "Agda strings cannot create a postulate finding or start comments" do + code = ~S""" + text = "postulate A : Set -- {- text -}" + escaped = "\" postulate B : Set \"" + """ + + refute Enum.any?(CodeSafety.scan_content(code, "agda"), &(&1.rule == :agda_postulate)) + end + + test "Agda comments and strings preserve the line of a real postulate" do + code = ~S""" + {- outer + {- nested -} postulate not-code : Set + -} + text = "{- postulate also-not-code : Set" + postulate A : Set + """ + + assert [%{rule: :agda_postulate, severity: :critical, occurrences: 1, lines: [5]}] = + CodeSafety.scan_content(code, "agda") + end + + test "Agda double-quote character literals cannot hide later declarations" do + for literal <- [~S('"'), ~S('\"')] do + code = "quote = #{literal}\npostulate A : Set\n" + + assert [%{rule: :agda_postulate, severity: :critical, occurrences: 1, lines: [2]}] = + CodeSafety.scan_content(code, "agda") + end + end + + test "Agda real postulates remain visible beside the original safe identifier" do + code = """ + no-unconditional-postulate-promotion : Set₁ + no-unconditional-postulate-promotion = Set + postulate A : Set + """ + + assert [%{rule: :agda_postulate, severity: :critical, occurrences: 1, lines: [3]}] = + CodeSafety.scan_content(code, "agda") + end + test "Idris2 `||` boolean OR is NOT a docstring (regex requires 3+ pipes)" do code = """ andThen : Bool -> Bool -> Bool diff --git a/test/graph_of_trust_qualification_test.exs b/test/graph_of_trust_qualification_test.exs new file mode 100644 index 00000000..2fc7511d --- /dev/null +++ b/test/graph_of_trust_qualification_test.exs @@ -0,0 +1,204 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +defmodule Hypatia.Neural.GraphOfTrustQualificationTest do + use ExUnit.Case, async: true + + alias Hypatia.Neural.GraphOfTrust + alias Hypatia.OutcomeTracker + + @recipes [ + %{"id" => "recipe-shared-fixture"}, + %{"id" => "recipe-a-only-fixture"}, + %{"id" => "recipe-b-only-fixture"} + ] + + defp observation(repo, recipe, outcome, verification) do + %{ + "repo" => repo, + "bot" => "bot-qualification-fixture", + "recipe_id" => recipe, + "outcome" => outcome, + "verification" => verification + } + end + + defp verified(repo, recipe \\ "recipe-shared-fixture") do + observation(repo, recipe, "success", "verified") + end + + defp graph(rows), do: GraphOfTrust.build(rows, @recipes) + defp without_time(graph), do: %{graph | last_computed: nil} + + test "verified shared repairs create bidirectional positive cross-repo edges" do + graph = graph([verified("repo-a-fixture"), verified("repo-b-fixture")]) + + assert MapSet.new(graph.cross_repo_edges) == + MapSet.new([ + {"repo-a-fixture", "repo-b-fixture", 1.0}, + {"repo-b-fixture", "repo-a-fixture", 1.0} + ]) + + assert {"recipe-shared-fixture", "repo-a-fixture", 1.0} in graph.edges + assert map_size(graph.trust_scores) > 0 + assert Enum.all?(graph.trust_scores, fn {_id, score} -> score >= 0.0 and score <= 1.0 end) + end + + test "unqualified and contradictory successes cannot create repository trust relationships" do + rows = + for repo <- ["repo-a-fixture", "repo-b-fixture"], + verification <- [ + "unverified", + "scan_failed", + "scan_skipped", + "still_present", + "incomplete", + nil + ] do + observation(repo, "recipe-shared-fixture", "success", verification) + end + + missing_verification = verified("repo-a-fixture") |> Map.delete("verification") + graph = graph([missing_verification | rows]) + + assert graph.cross_repo_edges == [] + assert graph.edges == [] + refute Map.has_key?(graph.nodes, "repo-a-fixture") + refute Map.has_key?(graph.nodes, "repo-b-fixture") + assert GraphOfTrust.trusted_bots(graph) == [] + end + + test "ignored observations cannot alter a qualified graph through nodes or direct edges" do + qualified = [verified("repo-a-fixture")] + baseline = qualified |> graph() |> without_time() + + ignored = [ + verified("repo-b-fixture") |> Map.put("verification", "unverified"), + verified("repo-c-fixture") |> Map.delete("verification"), + nil, + [], + 42, + "not-an-outcome-object" + ] + + assert qualified |> Kernel.++(ignored) |> graph() |> without_time() == baseline + end + + test "qualified failures and false positives retain the existing non-positive-edge policy" do + negatives = [ + observation("repo-b-fixture", "recipe-shared-fixture", "failure", "failed"), + observation("repo-b-fixture", "recipe-shared-fixture", "failure", "incomplete"), + observation("repo-b-fixture", "recipe-shared-fixture", "failure", "verified"), + observation("repo-b-fixture", "recipe-shared-fixture", "false_positive", "still_present"), + observation("repo-b-fixture", "recipe-shared-fixture", "false_positive", "verified"), + observation( + "repo-b-fixture", + "recipe-shared-fixture", + "false_positive", + "rollback_requested" + ) + |> Map.merge(%{"caused_by" => "batch_rollback", "batch_id" => "batch-fixture"}) + ] + + assert Enum.all?(negatives, &OutcomeTracker.qualified_outcome?/1) + baseline = graph([verified("repo-a-fixture")]) + graph = graph([verified("repo-a-fixture") | negatives]) + + assert graph.nodes["repo-b-fixture"] == :repo + assert graph.cross_repo_edges == [] + assert graph.edges == baseline.edges + assert Enum.all?(graph.edges, fn {_from, _to, weight} -> weight >= 0.0 end) + end + + test "Jaccard weights use only qualified shared recipe sets" do + qualified = [ + verified("repo-a-fixture"), + verified("repo-a-fixture", "recipe-a-only-fixture"), + verified("repo-b-fixture"), + verified("repo-b-fixture", "recipe-b-only-fixture") + ] + + ignored = [ + verified("repo-a-fixture", "recipe-b-only-fixture") + |> Map.put("verification", "unverified"), + verified("repo-c-fixture") |> Map.put("verification", "scan_failed") + ] + + expected = + MapSet.new([ + {"repo-a-fixture", "repo-b-fixture", 1 / 3}, + {"repo-b-fixture", "repo-a-fixture", 1 / 3} + ]) + + assert MapSet.new(graph(qualified ++ ignored).cross_repo_edges) == expected + assert MapSet.new(graph(qualified ++ qualified).cross_repo_edges) == expected + end + + test "the historical pattern identity remains supported for qualified records" do + rows = + for repo <- ["repo-a-fixture", "repo-b-fixture"] do + verified(repo) + |> Map.put("pattern", "recipe-shared-fixture") + |> Map.delete("recipe_id") + end + + assert MapSet.new(graph(rows).cross_repo_edges) == + MapSet.new([ + {"repo-a-fixture", "repo-b-fixture", 1.0}, + {"repo-b-fixture", "repo-a-fixture", 1.0} + ]) + end + + test "recipe inventory and unqualified-only histories remain neutral" do + for rows <- [ + [], + [observation("repo-a-fixture", "recipe-shared-fixture", "success", "unverified")] + ] do + result = graph(rows) + assert result.edges == [] + + for recipe <- @recipes do + assert GraphOfTrust.trust_score(result, recipe["id"]) == 0.5 + end + + assert Enum.all?(GraphOfTrust.trusted_recipes(result), fn {_id, score} -> score == 0.5 end) + end + end + + test "a qualified negative-only graph cannot award positive trust" do + for {outcome, verification} <- [{"failure", "failed"}, {"false_positive", "still_present"}] do + result = + graph([observation("negative-only-repo", "recipe-shared-fixture", outcome, verification)]) + + assert result.edges == [] + assert result.nodes["negative-only-repo"] == :repo + assert Enum.all?(result.trust_scores, fn {_id, score} -> score == 0.5 end) + end + end + + test "neutral recipe inventory and negative-only nodes cannot perturb supported ranking" do + supported = [verified("repo-a-fixture")] + baseline = GraphOfTrust.build(supported, [%{"id" => "recipe-shared-fixture"}]) + assert baseline.trust_scores["recipe-shared-fixture"] > 0.5 + extra_recipes = for n <- 1..500, do: %{"id" => "unsupported-#{n}"} + + negatives = [ + observation("negative-only-repo", "unused-negative-recipe", "failure", "failed") + |> Map.put("bot", "negative-only-bot") + ] + + expanded = + GraphOfTrust.build(supported ++ negatives, [ + %{"id" => "recipe-shared-fixture"} | extra_recipes + ]) + + for {id, score} <- baseline.trust_scores do + assert expanded.trust_scores[id] == score + end + + assert expanded.trust_scores["negative-only-repo"] == 0.5 + assert expanded.trust_scores["negative-only-bot"] == 0.5 + assert Enum.all?(extra_recipes, fn recipe -> expanded.trust_scores[recipe["id"]] == 0.5 end) + assert GraphOfTrust.trust_score(expanded, "unknown-entity") == 0.5 + end +end diff --git a/test/learning_runtime_store_test.exs b/test/learning_runtime_store_test.exs new file mode 100644 index 00000000..65fc93f5 --- /dev/null +++ b/test/learning_runtime_store_test.exs @@ -0,0 +1,815 @@ +# SPDX-License-Identifier: MPL-2.0 +# Synthetic store-boundary controls. No estate corpus or actuator is used. +defmodule Hypatia.LearningRuntimeStoreTest do + use ExUnit.Case, async: false + + alias Hypatia.{LearningScheduler, OutcomeTracker, Paths, PatternRegistry, VerisimConnector} + + alias Hypatia.Neural.{ + EchoStateNetwork, + GraphOfTrust, + LiquidStateMachine, + MixtureOfExperts, + Persistence, + RadialNeuralNetwork, + Rebalancer, + TrainingPipeline, + SourceStore + } + + alias Hypatia.VCL.{Client, FileExecutor} + + setup do + old_root = Application.fetch_env!(:hypatia, :verisimdb_data_path) |> Path.expand() + assert String.contains?(old_root, "/_build/test/verisim-data") + keys = [:verisimdb_data_path, :annealing_state_path, :fleet_path] + original = Map.new(keys, fn key -> {key, Application.fetch_env(:hypatia, key)} end) + root = Path.join(System.tmp_dir!(), "hypatia-store-cycle-#{System.unique_integer([:positive])}") + first = Path.join(root, "first") + second = Path.join(root, "second") + fleet = Path.join(root, "fleet") + + for store <- [first, second], part <- ["outcomes", "recipes", "patterns", "scans"] do + File.mkdir_p!(Path.join(store, part)) + end + + for module <- [LearningScheduler, Hypatia.Watcher.AnomalyDetector] do + pid = Process.whereis(module) + assert is_pid(pid) + :ok = :sys.suspend(pid) + + on_exit(fn -> + # Restore every overridden path before a queued timer may run. + for {key, value} <- original do + case value do + {:ok, previous} -> Application.put_env(:hypatia, key, previous) + :error -> Application.delete_env(:hypatia, key) + end + end + + if Process.alive?(pid), do: :sys.resume(pid) + end) + end + + Application.put_env(:hypatia, :verisimdb_data_path, first) + Application.delete_env(:hypatia, :annealing_state_path) + Application.put_env(:hypatia, :fleet_path, fleet) + + on_exit(fn -> + for {key, value} <- original do + case value do + {:ok, previous} -> Application.put_env(:hypatia, key, previous) + :error -> Application.delete_env(:hypatia, key) + end + end + + File.rm_rf!(root) + end) + + %{root: root, first: first, second: second, fleet: fleet} + end + + test "unchanged fleet bytes are imported once into each runtime outcome store", ctx do + line = + Jason.encode!(%{ + "recipe_id" => "recipe-store-cache", + "repo" => "synthetic", + "file" => ".", + "bot" => "fixture", + "outcome" => "failure", + "verdict" => "failed", + "exit_code" => 1 + }) + + fleet_file = write_fleet(ctx.fleet, line <> "\n") + source_id = LearningScheduler.fleet_source_id(line, 1) + + assert {1, first_signature} = LearningScheduler.ingest_fleet_outcomes(nil) + assert {:ok, ids} = OutcomeTracker.imported_fleet_ids() + assert ids == MapSet.new([source_id]) + first_bytes = directory_bytes(Path.join(ctx.first, "outcomes")) + assert map_size(first_bytes) == 1 + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + assert {:ok, empty} = OutcomeTracker.imported_fleet_ids() + assert empty == MapSet.new() + assert {1, second_signature} = LearningScheduler.ingest_fleet_outcomes(first_signature) + refute second_signature == first_signature + assert {:ok, ids} = OutcomeTracker.imported_fleet_ids() + assert ids == MapSet.new([source_id]) + second_bytes = directory_bytes(Path.join(ctx.second, "outcomes")) + assert map_size(second_bytes) == 1 + + assert {0, ^second_signature} = LearningScheduler.ingest_fleet_outcomes(second_signature) + assert directory_bytes(Path.join(ctx.second, "outcomes")) == second_bytes + assert directory_bytes(Path.join(ctx.first, "outcomes")) == first_bytes + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.first) + assert {0, ^first_signature} = LearningScheduler.ingest_fleet_outcomes(second_signature) + assert directory_bytes(Path.join(ctx.first, "outcomes")) == first_bytes + assert File.read!(fleet_file) == line <> "\n" + end + + test "a legacy content-only cache signature cannot acknowledge an empty destination", ctx do + line = + Jason.encode!(%{ + "recipe_id" => "recipe-legacy-cache", + "repo" => "synthetic", + "file" => ".", + "bot" => "fixture", + "outcome" => "failure", + "verdict" => "failed", + "exit_code" => 1 + }) + + content = line <> "\n" + fleet_file = write_fleet(ctx.fleet, content) + legacy_signature = :crypto.hash(:sha256, content) + + assert {1, signature} = LearningScheduler.ingest_fleet_outcomes(legacy_signature) + refute signature == legacy_signature + assert {0, ^signature} = LearningScheduler.ingest_fleet_outcomes(signature) + assert File.read!(fleet_file) == content + end + + test "annealing writer and report follow the same runtime default directory", ctx do + record_failures("recipe-first-stage", 1) + first_path = Path.join([ctx.first, "annealing-states", "recipe-first-stage.json"]) + first_bytes = File.read!(first_path) + assert Jason.decode!(first_bytes)["qualification_version"] == 1 + assert LearningScheduler.report_annealing_state() == stage_report(1, 0, 0) + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + assert LearningScheduler.report_annealing_state() == stage_report(0, 0, 0) + record_failures("recipe-second-stage", 5) + second_path = Path.join([ctx.second, "annealing-states", "recipe-second-stage.json"]) + assert Jason.decode!(File.read!(second_path))["outcome_count"] == 5 + assert LearningScheduler.report_annealing_state() == stage_report(0, 1, 1) + assert File.read!(first_path) == first_bytes + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.first) + assert LearningScheduler.report_annealing_state() == stage_report(1, 0, 0) + end + + test "an explicit runtime annealing override is shared by writer and report", ctx do + first = Path.join(ctx.root, "annealing-first") + second = Path.join(ctx.root, "annealing-second") + Application.put_env(:hypatia, :annealing_state_path, first) + assert Paths.annealing_states() == first + record_failures("recipe-override-first", 1) + first_bytes = directory_bytes(first) + assert LearningScheduler.report_annealing_state() == stage_report(1, 0, 0) + + Application.put_env(:hypatia, :annealing_state_path, second) + assert Paths.annealing_states() == second + assert LearningScheduler.report_annealing_state() == stage_report(0, 0, 0) + record_failures("recipe-override-second", 5) + assert LearningScheduler.report_annealing_state() == stage_report(0, 1, 1) + assert directory_bytes(first) == first_bytes + refute File.exists?(Path.join(ctx.first, "annealing-states")) + + Application.put_env(:hypatia, :annealing_state_path, first) + assert LearningScheduler.report_annealing_state() == stage_report(1, 0, 0) + end + + test "confidence persists one qualified evidence snapshot without touching the other store", ctx do + recipe = "recipe-confidence-stable" + paths = confidence_fixture(ctx, recipe) + second_before = store_tree_bytes(ctx.second) + outcomes_before = directory_bytes(Path.join(ctx.first, "outcomes")) + + assert {:ok, confidence} = OutcomeTracker.update_recipe_confidence(recipe) + assert is_float(confidence) + + updated = Jason.decode!(File.read!(paths.recipe)) + assert updated["confidence_prior"] == 0.8 + assert updated["successful_fixes"] == 0 + assert updated["failed_fixes"] == 1 + assert updated["total_attempts"] == 2 + assert updated["unqualified_attempts"] == 1 + assert updated["confidence"] == Float.round(confidence, 4) + assert Jason.decode!(File.read!(paths.annealing))["outcome_count"] == 1 + assert store_tree_bytes(ctx.second) == second_before + assert directory_bytes(Path.join(ctx.first, "outcomes")) == outcomes_before + end + + test "confidence refuses an A-to-B switch after evidence and before either write", ctx do + recipe = "recipe-confidence-switch" + confidence_fixture(ctx, recipe) + before = store_tree_bytes(ctx.root) + + assert {:error, {:outcome_store_changed, captured, current}} = + OutcomeTracker.update_recipe_confidence(recipe, fn -> + send(self(), :confidence_evidence_collected) + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + end) + + assert_received :confidence_evidence_collected + assert captured.data_root == ctx.first + assert current.data_root == ctx.second + assert captured.annealing_dir == Path.join(ctx.first, "annealing-states") + assert current.annealing_dir == Path.join(ctx.second, "annealing-states") + assert store_tree_bytes(ctx.root) == before + end + + test "both explicit transactions refuse an annealing override change without redirecting writes", + ctx do + recipe = "recipe-confidence-override" + confidence_fixture(ctx, recipe) + first_override = Path.join(ctx.root, "override-first") + second_override = Path.join(ctx.root, "override-second") + + for transaction <- [:confidence, :annealing] do + Application.put_env(:hypatia, :annealing_state_path, first_override) + before = store_tree_bytes(ctx.root) + barrier = fn -> Application.put_env(:hypatia, :annealing_state_path, second_override) end + + result = + case transaction do + :confidence -> OutcomeTracker.update_recipe_confidence(recipe, barrier) + :annealing -> OutcomeTracker.update_annealing_state(recipe, :failure, barrier) + end + + assert {:error, {:outcome_store_changed, captured, current}} = result + assert captured.data_root == ctx.first + assert current.data_root == ctx.first + assert captured.annealing_dir == first_override + assert current.annealing_dir == second_override + assert store_tree_bytes(ctx.root) == before + refute File.exists?(first_override) + refute File.exists?(second_override) + end + end + + test "incremental annealing advances A but refuses a later switch before persistence", ctx do + recipe = "recipe-incremental-switch" + paths = confidence_fixture(ctx, recipe) + second_before = store_tree_bytes(ctx.second) + + assert {:ok, first} = OutcomeTracker.update_annealing_state(recipe, :failure, fn -> :ok end) + assert first.outcome_count == 1 + + write_rows(ctx.first, List.duplicate(outcome(recipe, "failure", "failed"), 2)) + assert {:ok, second} = OutcomeTracker.update_annealing_state(recipe, :failure, fn -> :ok end) + assert second.outcome_count == 2 + assert :failure in second.recent_outcomes + assert Jason.decode!(File.read!(paths.annealing))["outcome_count"] == 2 + assert store_tree_bytes(ctx.second) == second_before + + write_rows(ctx.first, List.duplicate(outcome(recipe, "failure", "failed"), 3)) + before = store_tree_bytes(ctx.root) + + assert {:error, {:outcome_store_changed, captured, current}} = + OutcomeTracker.update_annealing_state(recipe, :failure, fn -> + send(self(), :incremental_evidence_collected) + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + end) + + assert_received :incremental_evidence_collected + assert captured.data_root == ctx.first + assert current.data_root == ctx.second + assert store_tree_bytes(ctx.root) == before + end + + test "a stable explicit override receives the state and leaves the default absent", ctx do + recipe = "recipe-confidence-stable-override" + paths = confidence_fixture(ctx, recipe) + override = Path.join(ctx.root, "override-stable") + Application.put_env(:hypatia, :annealing_state_path, override) + second_before = store_tree_bytes(ctx.second) + + assert {:ok, _confidence} = OutcomeTracker.update_recipe_confidence(recipe) + state_path = Path.join(override, recipe <> ".json") + assert Jason.decode!(File.read!(state_path))["outcome_count"] == 1 + refute File.exists?(paths.annealing) + assert store_tree_bytes(ctx.second) == second_before + end + + test "annealing I/O refusal is returned before the recipe summary is written", ctx do + recipe = "recipe-confidence-io" + confidence_fixture(ctx, recipe) + blocker = Path.join(ctx.root, "not-a-directory") + File.write!(blocker, "synthetic I/O refusal") + override = Path.join(blocker, "states") + Application.put_env(:hypatia, :annealing_state_path, override) + before = store_tree_bytes(ctx.root) + + assert {:error, {:annealing_state_write, path, reason}} = + OutcomeTracker.update_recipe_confidence(recipe) + + assert path == Path.join(override, recipe <> ".json") + assert reason in [:enotdir, :eexist] + assert store_tree_bytes(ctx.root) == before + + assert {:error, {:annealing_state_write, ^path, second_reason}} = + OutcomeTracker.update_annealing_state(recipe, :failure, fn -> :ok end) + + assert second_reason in [:enotdir, :eexist] + assert store_tree_bytes(ctx.root) == before + end + + test "diagnostic and numeric annealing reads reconcile without a hidden disk write", ctx do + recipe = "recipe-read-only-reconcile" + paths = confidence_fixture(ctx, recipe) + + write_json(paths.annealing, %{ + "qualification_version" => 1, + "temperature" => 0.1, + "outcome_count" => 1000, + "stage" => "veteran", + "recent_outcomes" => ["success"], + "last_reheat" => nil, + "created_at" => "2026-11-04T00:00:00Z", + "updated_at" => "2026-11-04T00:00:00Z" + }) + + before = store_tree_bytes(ctx.root) + state = OutcomeTracker.get_annealing_state(recipe) + assert state.outcome_count == 1 + assert state.stage == :nascent + assert OutcomeTracker.annealed_strategy(recipe, :auto_execute) == :report_only + assert is_float(OutcomeTracker.cross_repo_confidence(recipe, 0.9)) + assert OutcomeTracker.get_annealing_state("recipe-absent").outcome_count == 0 + assert store_tree_bytes(ctx.root) == before + refute File.exists?(Path.join([ctx.first, "annealing-states", "recipe-absent.json"])) + end + + test "a durable raw outcome remains committed when derived persistence is refused", ctx do + recipe = "recipe-durable-derived-refusal" + paths = confidence_fixture(ctx, recipe) + recipe_before = File.read!(paths.recipe) + second_before = store_tree_bytes(ctx.second) + blocker = Path.join(ctx.root, "derived-write-blocker") + File.write!(blocker, "synthetic I/O refusal") + Application.put_env(:hypatia, :annealing_state_path, Path.join(blocker, "states")) + + assert {:ok, record} = + OutcomeTracker.record_fleet_outcome( + recipe, + "synthetic", + ".", + :failure, + "durable-derived-refusal", + "failed" + ) + + assert record["fleet_import_id"] == "durable-derived-refusal" + assert {:ok, ids} = OutcomeTracker.imported_fleet_ids() + assert MapSet.member?(ids, "durable-derived-refusal") + assert File.read!(paths.recipe) == recipe_before + assert store_tree_bytes(ctx.second) == second_before + refute File.exists?(paths.annealing) + end + + test "ESN input switches outcomes without promoting historical successes", ctx do + first = outcome("recipe-first-series", "failure", "failed") + second = outcome("recipe-second-series", "success", "verified") + legacy = outcome("recipe-legacy-series", "success", "unverified") + write_rows(ctx.first, List.duplicate(first, 10) ++ List.duplicate(legacy, 20)) + write_rows(ctx.second, List.duplicate(second, 10) ++ List.duplicate(legacy, 20)) + + expected_first = [ + %{recipe_id: "recipe-first-series", count: 10, series: List.duplicate(0.0, 10)} + ] + + expected_second = [ + %{recipe_id: "recipe-second-series", count: 10, series: List.duplicate(1.0, 10)} + ] + assert TrainingPipeline.build_esn_training_data() == expected_first + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + assert TrainingPipeline.build_esn_training_data() == expected_second + Application.put_env(:hypatia, :verisimdb_data_path, ctx.first) + assert TrainingPipeline.build_esn_training_data() == expected_first + end + + test "an empty replacement cannot fall back to the preceding training store", ctx do + write_rows(ctx.first, List.duplicate(outcome("recipe-present", "failure", "failed"), 10)) + write_substitution(ctx.first, "eliminate") + assert {:ok, _} = PatternRegistry.sync_from_scans([scan("first", "High")]) + assert [%{count: 10}] = TrainingPipeline.build_esn_training_data() + assert {[_], [1.0]} = TrainingPipeline.build_rbf_training_data() + assert length(Rebalancer.load_failure_templates()) == 10 + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + assert TrainingPipeline.build_esn_training_data() == [] + assert TrainingPipeline.build_rbf_training_data() == {[], []} + assert Rebalancer.load_failure_templates() == [] + assert PatternRegistry.all_patterns() == [] + end + + test "registry writer, substitutions and RBF reader share the selected store", ctx do + write_substitution(ctx.first, "control") + write_substitution(ctx.second, "eliminate") + first_scan = scan("first", "Low") + second_scan = scan("second", "High") + + assert {:ok, first_registry} = PatternRegistry.sync_from_scans([first_scan]) + [first_pattern] = Map.values(first_registry["patterns"]) + assert first_pattern["triangle_tier"] == "control" + assert first_pattern["repos_affected_list"] == ["first"] + assert {[first_vector], first_targets} = TrainingPipeline.build_rbf_training_data() + assert first_targets == [0.0] + assert length(first_vector) == 8 + first_bytes = directory_bytes(Path.join(ctx.first, "patterns")) + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + assert PatternRegistry.all_patterns() == [] + assert {:ok, second_registry} = PatternRegistry.sync_from_scans([second_scan]) + [second_pattern] = Map.values(second_registry["patterns"]) + assert second_pattern["triangle_tier"] == "eliminate" + assert second_pattern["repos_affected_list"] == ["second"] + assert {[second_vector], [1.0]} = TrainingPipeline.build_rbf_training_data() + assert length(second_vector) == 8 + refute second_vector == first_vector + assert directory_bytes(Path.join(ctx.first, "patterns")) == first_bytes + assert PatternRegistry.get_pattern(first_pattern["id"]) == nil + assert {:ok, ^second_registry} = VerisimConnector.fetch_pattern_registry_fallback() + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.first) + assert PatternRegistry.get_pattern(second_pattern["id"]) == nil + assert PatternRegistry.all_patterns() == [first_pattern] + assert {[^first_vector], restored_targets} = TrainingPipeline.build_rbf_training_data() + assert restored_targets == [0.0] + end + + test "registry synchronization refuses an observed root change without overwriting either store", ctx do + write_substitution(ctx.first, "control") + write_substitution(ctx.second, "eliminate") + assert {:ok, _} = PatternRegistry.sync_from_scans([scan("first-original", "Low")]) + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + assert {:ok, _} = PatternRegistry.sync_from_scans([scan("second-original", "High")]) + first_before = directory_bytes(Path.join(ctx.first, "patterns")) + second_before = directory_bytes(Path.join(ctx.second, "patterns")) + Application.put_env(:hypatia, :verisimdb_data_path, ctx.first) + + scans = + Stream.map([scan("late-first", "High")], fn entry -> + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + entry + end) + + assert {:error, {:pattern_store_changed, first, second}} = + PatternRegistry.sync_from_scans(scans) + assert {first, second} == {ctx.first, ctx.second} + assert directory_bytes(Path.join(ctx.first, "patterns")) == first_before + assert directory_bytes(Path.join(ctx.second, "patterns")) == second_before + end + + test "registry encoding and filesystem failures cannot return synchronized success", ctx do + assert {:ok, _} = PatternRegistry.sync_from_scans([scan("original", "Low")]) + path = Path.join([ctx.first, "patterns", "registry.json"]) + before = File.read!(path) + malformed = + put_in(scan("unencodable", "High"), [:scan, "weak_points", Access.at(0), "description"], + fn -> :not_json end) + + assert {:error, {:registry_encoding_failed, _}} = + PatternRegistry.sync_from_scans([malformed]) + assert File.read!(path) == before + + File.rm!(path) + File.mkdir!(path) + assert {:error, {:registry_write_failed, _}} = + PatternRegistry.sync_from_scans([scan("unwritable", "High")]) + assert File.dir?(path) + assert File.ls!(path) == [] + refute File.exists?(Path.join([ctx.second, "patterns", "registry.json"])) + end + + test "corpus templates follow runtime defaults and retain an explicit store override", ctx do + first = + Map.put(outcome("recipe-first-corpus", "failure", "failed"), "failure_reason", "timeout") + + second = + Map.put( + outcome("recipe-second-corpus", "failure", "failed"), + "failure_reason", + "merge_conflict" + ) + legacy = outcome("recipe-legacy-corpus", "failure", "unverified") + write_rows(ctx.first, [first, legacy]) + write_rows(ctx.second, [second, second, legacy]) + + assert [%{duration: 2}] = Rebalancer.load_failure_templates() + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + assert [%{duration: 4}, %{duration: 4}] = Rebalancer.load_failure_templates() + assert [%{duration: 2}] = Rebalancer.load_failure_templates(ctx.first) + assert Application.fetch_env!(:hypatia, :verisimdb_data_path) == ctx.second + end + + test "strategy C follows the runtime corpus and honors an explicit override", ctx do + success = outcome("recipe-corpus-training", "success", "verified") + failure = outcome("recipe-corpus-training", "failure", "failed") + write_rows(ctx.first, List.duplicate(success, 70) ++ [failure]) + write_rows(ctx.second, [failure, failure]) + + # A small actual ESN keeps this a real training call without an expensive + # default-size reservoir. No coordinator or dispatch callback is invoked. + esn = EchoStateNetwork.init(reservoir_size: 4, seed: 17) + + default_log = + ExUnit.CaptureLog.capture_log(fn -> + trained = + TrainingPipeline.train_esn(esn, rebalance_strategy: :c, rebalance_opts: [seed: 17]) + assert trained.trained + end) + + assert default_log =~ "1 failure templates loaded" + refute default_log =~ "falling back to Strategy A" + + override_log = + ExUnit.CaptureLog.capture_log(fn -> + trained = + TrainingPipeline.train_esn(esn, + rebalance_strategy: :c, + rebalance_opts: [seed: 17, data_path: ctx.second] + ) + + assert trained.trained + end) + + assert override_log =~ "2 failure templates loaded" + refute override_log =~ "falling back to Strategy A" + assert Application.fetch_env!(:hypatia, :verisimdb_data_path) == ctx.first + end + + test "VCL and direct file backend read each selected root", ctx do + for {store, marker} <- [{ctx.first, "first"}, {ctx.second, "second"}] do + write_backend_fixture(store, marker) + end + + case Process.whereis(Client) do + nil -> start_supervised!(Client) + _pid -> :ok + end + + for {store, marker} <- [{ctx.first, "first"}, {ctx.second, "second"}, {ctx.first, "first"}] do + Application.put_env(:hypatia, :verisimdb_data_path, store) + assert [%{repo: "fixture", scan: %{"marker" => ^marker}}] = VerisimConnector.fetch_all_scans() + assert VerisimConnector.fetch_all_scans_fallback() == VerisimConnector.fetch_all_scans() + assert {:ok, registry} = VerisimConnector.fetch_pattern_registry() + assert registry["patterns"]["fixture-pattern"]["marker"] == marker + assert {:ok, ^registry} = VerisimConnector.fetch_pattern_registry_fallback() + assert [%{"id" => "recipe-fixture", "marker" => ^marker}] = + VerisimConnector.fetch_all_recipes() + assert VerisimConnector.fetch_all_recipes_fallback() == VerisimConnector.fetch_all_recipes() + assert [%{"marker" => ^marker}] = VerisimConnector.fetch_substitutions() + assert VerisimConnector.fetch_substitutions_fallback() == + VerisimConnector.fetch_substitutions() + assert {:ok, %{"marker" => ^marker}} = VerisimConnector.fetch_index() + assert VerisimConnector.fetch_index_fallback() == VerisimConnector.fetch_index() + assert [%{"recipe_id" => "recipe-fixture", "marker" => ^marker}] = + VerisimConnector.fetch_all_outcomes() + assert VerisimConnector.fetch_all_outcomes_fallback() == VerisimConnector.fetch_all_outcomes() + end + end + + test "VCL live cache cannot answer a new root with the prior store's results", ctx do + write_backend_fixture(ctx.first, "first") + write_backend_fixture(ctx.second, "second") + {:ok, state} = Client.init([]) + query = "SELECT DOCUMENT FROM STORE scans" + from = {self(), make_ref()} + assert {:reply, {:ok, [first]}, state} = Client.handle_call({:query, query, []}, from, state) + assert first["marker"] == "first" + assert state.query_count == 1 + assert {:reply, {:ok, [^first]}, state} = Client.handle_call({:query, query, []}, from, state) + assert state.query_count == 1 + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + assert {:reply, {:ok, [second]}, state} = Client.handle_call({:query, query, []}, from, state) + assert second["marker"] == "second" + assert state.query_count == 2 + + assert {:reply, {:ok, [^first]}, state} = + Client.handle_call({:query, query, [data_path_override: ctx.first]}, from, state) + + assert state.query_count == 2 + assert map_size(state.cache) == 2 + Application.put_env(:hypatia, :verisimdb_data_path, ctx.first) + assert {:reply, {:ok, [^first]}, _state} = Client.handle_call({:query, query, []}, from, state) + end + + test "explicit file roots govern store, federation and all hexad lookup paths", ctx do + write_backend_fixture(ctx.first, "first") + write_backend_fixture(ctx.second, "second") + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + + sources = [ + {:store, "scans"}, + {:federation, "/scans/*", nil}, + {:hexad, "fixture"}, + {:hexad, "recipe-fixture"}, + {:hexad, "fixture-pattern"} + ] + + for source <- sources do + assert {:ok, [default]} = FileExecutor.execute(ast(source)) + assert default["marker"] == "second" + assert {:ok, [overridden]} = FileExecutor.execute(ast(source), data_path_override: ctx.first) + assert overridden["marker"] == "first" + end + + assert Application.fetch_env!(:hypatia, :verisimdb_data_path) == ctx.second + end + + test "language-aware federation resolves scan context within the selected root", ctx do + for store <- [ctx.first, ctx.second] do + write_rows(store, [%{"recipe_id" => "recipe-language", "repo" => "fixture"}]) + end + + write_json(Path.join([ctx.first, "scans", "fixture.json"]), %{"primary_language" => "rust"}) + write_json(Path.join([ctx.second, "scans", "fixture.json"]), %{"primary_language" => "elixir"}) + query = ast({:federation, "/outcomes/*", "language_aware"}) + assert {:ok, [first]} = FileExecutor.execute(query) + assert first["_language_family"] == "systems" + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + assert {:ok, [second]} = FileExecutor.execute(query) + assert second["_language_family"] == "beam" + assert {:ok, [overridden]} = FileExecutor.execute(query, data_path_override: ctx.first) + assert overridden["_language_family"] == "systems" + assert Application.fetch_env!(:hypatia, :verisimdb_data_path) == ctx.second + end + + test "neural persistence follows runtime stores while trust is rebuilt from source", ctx do + write_json(Path.join([ctx.first, "recipes", "recipe-first.json"]), %{"id" => "recipe-first"}) + write_json(Path.join([ctx.second, "recipes", "recipe-second.json"]), %{"id" => "recipe-second"}) + + first_state = neural_state("first") + assert :ok = Persistence.save_all(first_state) + assert %{trust: {:ok, first_trust}, rbf: {:ok, first_rbf}} = Persistence.load_all() + assert first_trust["restore_policy"] == "recompute_from_current_qualified_outcomes" + assert first_trust["trust_scores"] == %{"recipe-first" => 0.5} + assert first_rbf["training_stats"] == %{"fixture_store" => "first"} + first_bytes = directory_bytes(Path.join(ctx.first, "neural-states")) + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + assert %{trust: :not_found, rbf: :not_found} = Persistence.load_all() + second_state = neural_state("second") + assert :ok = Persistence.save_all(second_state) + assert %{trust: {:ok, second_trust}, rbf: {:ok, second_rbf}} = Persistence.load_all() + assert second_trust["trust_scores"] == %{"recipe-second" => 0.5} + assert second_rbf["training_stats"] == %{"fixture_store" => "second"} + assert directory_bytes(Path.join(ctx.first, "neural-states")) == first_bytes + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.first) + assert %{trust: {:ok, ^first_trust}, rbf: {:ok, ^first_rbf}} = Persistence.load_all() + assert GraphOfTrust.build().trust_scores == %{"recipe-first" => 0.5} + end + + defp confidence_fixture(ctx, recipe) do + for {root, confidence} <- [{ctx.first, 0.8}, {ctx.second, 0.2}] do + write_json(Path.join([root, "recipes", recipe <> ".json"]), %{ + "id" => recipe, + "confidence" => confidence + }) + end + + write_rows(ctx.first, [ + outcome(recipe, "failure", "failed"), + outcome(recipe, "success", "unverified") + ]) + + write_rows(ctx.second, List.duplicate(outcome(recipe, "success", "verified"), 5)) + + %{ + recipe: Path.join([ctx.first, "recipes", recipe <> ".json"]), + annealing: Path.join([ctx.first, "annealing-states", recipe <> ".json"]) + } + end + + defp store_tree_bytes(root) do + root + |> Path.join("**/*") + |> Path.wildcard(match_dot: true) + |> Enum.filter(&File.regular?/1) + |> Map.new(fn path -> {Path.relative_to(path, root), File.read!(path)} end) + end + + defp write_json(path, value) do + File.mkdir_p!(Path.dirname(path)) + File.write!(path, Jason.encode!(value) <> "\n") + end + + defp write_rows(root, rows) do + File.write!( + Path.join([root, "outcomes", "fixture.jsonl"]), + Enum.map_join(rows, "", &(Jason.encode!(&1) <> "\n")) + ) + end + + defp write_fleet(root, content) do + path = Path.join([root, "shared-context", "learning", "fix-outcomes.jsonl"]) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, content) + path + end + + defp directory_bytes(directory) do + directory + |> Path.join("*") + |> Path.wildcard() + |> Map.new(fn path -> {Path.basename(path), File.read!(path)} end) + end + + defp record_failures(recipe, count) do + for index <- 1..count do + assert {:ok, _} = + OutcomeTracker.record_fleet_outcome( + recipe, + "synthetic", + ".", + :failure, + "#{recipe}-#{index}", + "failed" + ) + end + end + + defp stage_report(nascent, adolescent, reheated) do + %{nascent: nascent, adolescent: adolescent, mature: 0, veteran: 0, reheated: reheated} + end + + defp outcome(recipe, result, verification) do + %{ + "recipe_id" => recipe, + "repo" => "synthetic", + "bot" => "fixture", + "timestamp" => "2026-11-04T00:00:00Z", + "outcome" => result, + "verification" => verification + } + end + + defp write_substitution(root, tier) do + write_json(Path.join([root, "recipes", "proven-substitutions.json"]), %{ + "substitutions" => [%{"category" => "CommandInjection", "triangle_tier" => tier}] + }) + end + + defp scan(repo, severity) do + %{ + repo: repo, + scan: %{ + "program_path" => "synthetic/#{repo}", + "weak_points" => [ + %{ + "category" => "CommandInjection", + "description" => "#{repo} pattern", + "severity" => severity + }, + %{ + "category" => "CommandInjection", + "description" => "suppressed", + "severity" => "Critical", + "suppressed" => true + } + ] + } + } + end + + defp write_backend_fixture(root, marker) do + write_json(Path.join([root, "scans", "fixture.json"]), %{"marker" => marker}) + write_json(Path.join([root, "recipes", "recipe-fixture.json"]), %{ + "id" => "recipe-fixture", + "marker" => marker + }) + + write_json(Path.join([root, "patterns", "registry.json"]), %{ + "patterns" => %{"fixture-pattern" => %{"marker" => marker}} + }) + + write_json(Path.join([root, "recipes", "proven-substitutions.json"]), %{ + "substitutions" => [%{"marker" => marker}] + }) + + write_json(Path.join(root, "index.json"), %{"marker" => marker}) + write_rows(root, [%{"recipe_id" => "recipe-fixture", "marker" => marker}]) + end + + defp ast(source) do + %{source: source, modalities: [:document], where: nil, proof: nil, limit: nil, offset: nil} + end + + defp neural_state(marker) do + networks = + Map.new(Persistence.default_networks(), fn {name, module} -> + pid = start_supervised!(%{id: make_ref(), start: {GenServer, :start_link, [module, [], []]}}) + {name, pid} + end) + + %{ + trust_graph: GraphOfTrust.build(), + moe: MixtureOfExperts.init(), + lsm: LiquidStateMachine.init(reservoir_size: 4, seed: 17), + esn: EchoStateNetwork.init(reservoir_size: 4, seed: 17), + rbf: %{RadialNeuralNetwork.init() | training_stats: %{"fixture_store" => marker}}, + cycle_count: 0, + named_networks: networks + } + |> Map.merge(SourceStore.fields()) + end +end diff --git a/test/learning_scheduler_test.exs b/test/learning_scheduler_test.exs index 93182144..f36ab054 100644 --- a/test/learning_scheduler_test.exs +++ b/test/learning_scheduler_test.exs @@ -8,7 +8,7 @@ # - does not double-credit the same event on replay # - credits two distinct attempts with identical payloads separately # - handles missing / unreadable files without silent success -# - normalises legacy integer offsets to the new position map +# - binds offsets to their runtime source and replays legacy cursors safely defmodule Hypatia.LearningSchedulerTest do use ExUnit.Case, async: false @@ -18,7 +18,7 @@ defmodule Hypatia.LearningSchedulerTest do @moduletag timeout: 120_000 # Each test creates an isolated temp directory and overrides the - # @verisimdb_data_path via Application.put_env so ingest_new_outcomes/1 + # :verisimdb_data_path via Application.put_env so ingest_new_outcomes/1 # reads from it. We restore the original on exit. setup do orig = Application.get_env(:hypatia, :verisimdb_data_path) @@ -117,6 +117,35 @@ defmodule Hypatia.LearningSchedulerTest do # ── Two-poll incomplete-tail scenario ─────────────────────────────── + test "runtime store changes do not reuse the prior store's filename cursor", %{tmp: tmp} do + other_store = Path.join(tmp, "other-store") + timestamp = %{"timestamp" => "2026-11-04T00:00:00Z"} + first_row = record("store-a", "failure", timestamp) <> "\n" + second_row = record("store-b", "failure", timestamp) <> "\n" + assert byte_size(first_row) == byte_size(second_row) + write_jsonl(tmp, "outcomes.jsonl", first_row) + write_jsonl(other_store, "outcomes.jsonl", second_row) + + assert {first_positions, 1, first_recipes} = LearningScheduler.ingest_new_outcomes(%{}) + assert first_recipes == MapSet.new(["store-a"]) + + Application.put_env(:hypatia, :verisimdb_data_path, other_store) + + assert {second_positions, 1, second_recipes} = + LearningScheduler.ingest_new_outcomes(first_positions) + + assert second_recipes == MapSet.new(["store-b"]) + + assert second_positions["outcomes.jsonl"].source_path == + Path.join(outcomes_dir(other_store), "outcomes.jsonl") + + assert {^second_positions, 0, _} = LearningScheduler.ingest_new_outcomes(second_positions) + + append_jsonl(other_store, "outcomes.jsonl", record("store-c", "failure", timestamp) <> "\n") + assert {_, 1, appended_recipes} = LearningScheduler.ingest_new_outcomes(second_positions) + assert appended_recipes == MapSet.new(["store-c"]) + end + describe "incomplete-tail handling (two-poll fixture)" do test "poll A with incomplete final record does not advance past it", %{tmp: tmp} do # Write two complete records + one incomplete record (no trailing \n). @@ -155,6 +184,7 @@ defmodule Hypatia.LearningSchedulerTest do # Poll B: producer finishes the record. append_jsonl(tmp, "2026-11.jsonl", completion <> "\n") + {positions_b, count_b, recipes_b} = LearningScheduler.ingest_new_outcomes(positions_a) @@ -166,6 +196,59 @@ defmodule Hypatia.LearningSchedulerTest do pos_b = Map.get(positions_b, "2026-11.jsonl") assert pos_b.incomplete_tail == "" end + + test "unchanged partial writes retain their bytes and offset across polls", %{tmp: tmp} do + complete = record("recipe-a", "success", %{"verification" => "verified"}) <> "\n" + partial = ~s({"recipe_id":"recipe-b","outcome":"suc) + completion = ~s(cess","verification":"verified"}) <> "\n" + write_jsonl(tmp, "partial.jsonl", complete <> partial) + + assert {positions, 1, _} = LearningScheduler.ingest_new_outcomes(%{}) + + assert positions["partial.jsonl"] == %{ + offset: byte_size(complete), + incomplete_tail: partial, + source_path: Path.join(outcomes_dir(tmp), "partial.jsonl") + } + + for _ <- 1..3 do + assert {^positions, 0, recipes} = LearningScheduler.ingest_new_outcomes(positions) + assert recipes == MapSet.new() + end + + append_jsonl(tmp, "partial.jsonl", completion) + assert {completed, 1, recipes} = LearningScheduler.ingest_new_outcomes(positions) + assert recipes == MapSet.new(["recipe-b"]) + assert completed["partial.jsonl"].offset == byte_size(complete <> partial <> completion) + assert completed["partial.jsonl"].incomplete_tail == "" + assert {^completed, 0, _} = LearningScheduler.ingest_new_outcomes(completed) + end + + test "a sole unterminated record stays unacknowledged until its newline arrives", %{tmp: tmp} do + row = record("recipe-a", "failure") + write_jsonl(tmp, "partial.jsonl", row) + + assert {positions, 0, recipes} = LearningScheduler.ingest_new_outcomes(%{}) + assert recipes == MapSet.new() + + assert positions["partial.jsonl"] == %{ + offset: 0, + incomplete_tail: row, + source_path: Path.join(outcomes_dir(tmp), "partial.jsonl") + } + + assert {^positions, 0, _} = LearningScheduler.ingest_new_outcomes(positions) + + append_jsonl(tmp, "partial.jsonl", "\n") + assert {completed, 1, recipes} = LearningScheduler.ingest_new_outcomes(positions) + assert recipes == MapSet.new(["recipe-a"]) + + assert completed["partial.jsonl"] == %{ + offset: byte_size(row) + 1, + incomplete_tail: "", + source_path: Path.join(outcomes_dir(tmp), "partial.jsonl") + } + end end # ── Restart safety ────────────────────────────────────────────────── @@ -265,15 +348,89 @@ defmodule Hypatia.LearningSchedulerTest do {_, count_b, recipes_b} = LearningScheduler.ingest_new_outcomes(positions_a) # File was truncated, so offset reset and the new content is read. - assert count_b >= 1 + assert count_b == 1 assert MapSet.member?(recipes_b, "recipe-c") end + + test "rotation to an empty file resets the position without inventing a row", %{tmp: tmp} do + write_jsonl(tmp, "rotated.jsonl", record("old-recipe", "failure") <> "\n") + assert {positions, 1, _} = LearningScheduler.ingest_new_outcomes(%{}) + + write_jsonl(tmp, "rotated.jsonl", "") + assert {reset, 0, recipes} = LearningScheduler.ingest_new_outcomes(positions) + assert recipes == MapSet.new() + + assert reset["rotated.jsonl"] == %{ + offset: 0, + incomplete_tail: "", + source_path: Path.join(outcomes_dir(tmp), "rotated.jsonl") + } + + new_row = record("new-recipe", "failure") <> "\n" + append_jsonl(tmp, "rotated.jsonl", new_row) + assert {completed, 1, recipes} = LearningScheduler.ingest_new_outcomes(reset) + assert recipes == MapSet.new(["new-recipe"]) + assert completed["rotated.jsonl"].offset == byte_size(new_row) + end + + test "a detected truncation keeps its path-bound reset when the immediate read fails", %{ + tmp: tmp + } do + path = Path.join(outcomes_dir(tmp), "retry.jsonl") + # A directory gives a deterministic successful stat followed by an + # actual read error, including when the suite runs as root. The stale + # cursor is larger than that stat, so this exercises the reset branch. + File.mkdir!(path) + previous_offset = File.stat!(path).size + 100 + + positions = %{ + "retry.jsonl" => %{ + offset: previous_offset, + incomplete_tail: "fragment from the previous file", + source_path: path + } + } + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert {reset, 0, recipes} = LearningScheduler.ingest_new_outcomes(positions) + assert recipes == MapSet.new() + + assert reset["retry.jsonl"] == %{ + offset: 0, + incomplete_tail: "", + source_path: path + } + + # Before the next poll, the replacement grows past the old offset. + # Keeping the old cursor would skip the first complete event. + File.rmdir!(path) + + first = + record("replacement-first", "failure", %{ + "detail" => String.duplicate("x", previous_offset + 100) + }) <> "\n" + + second = record("replacement-second", "failure") <> "\n" + File.write!(path, first <> second) + assert byte_size(first <> second) > previous_offset + + assert {completed, 2, ingested} = LearningScheduler.ingest_new_outcomes(reset) + assert ingested == MapSet.new(["replacement-first", "replacement-second"]) + assert completed["retry.jsonl"].offset == byte_size(first <> second) + assert completed["retry.jsonl"].source_path == path + assert {^completed, 0, _} = LearningScheduler.ingest_new_outcomes(completed) + end) + + assert log =~ "outcome file retry.jsonl truncated" + assert log =~ "cannot read outcome file retry.jsonl" + end end # ── Missing / unreadable files ────────────────────────────────────── describe "missing and unreadable files" do - test "empty outcomes directory returns zero", %{tmp: tmp} do + test "empty outcomes directory returns zero" do # No JSONL files exist. {positions, count, recipes} = LearningScheduler.ingest_new_outcomes(%{}) assert count == 0 @@ -290,6 +447,19 @@ defmodule Hypatia.LearningSchedulerTest do assert MapSet.size(recipes) == 0 assert positions == %{} end + + test "a read error preserves the position and is reported", %{tmp: tmp} do + File.mkdir_p!(Path.join(outcomes_dir(tmp), "unreadable.jsonl")) + positions = %{"unreadable.jsonl" => %{offset: 0, incomplete_tail: "pending"}} + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert {^positions, 0, recipes} = LearningScheduler.ingest_new_outcomes(positions) + assert recipes == MapSet.new() + end) + + assert log =~ "cannot read outcome file unreadable.jsonl" + end end # ── Fleet-origin entries not double-counted ───────────────────────── @@ -351,6 +521,23 @@ defmodule Hypatia.LearningSchedulerTest do # ── Backward compatibility ────────────────────────────────────────── describe "backward compatibility with legacy integer positions" do + test "source-less nonzero cursors replay conservatively before being qualified", %{tmp: tmp} do + first = record("recipe-a", "failure") <> "\n" + second = record("recipe-b", "failure") <> "\n" + path = write_jsonl(tmp, "legacy.jsonl", first <> second) + + for legacy <- [byte_size(first), %{offset: byte_size(first), incomplete_tail: "old-store"}] do + assert {positions, 2, recipes} = + LearningScheduler.ingest_new_outcomes(%{"legacy.jsonl" => legacy}) + + assert recipes == MapSet.new(["recipe-a", "recipe-b"]) + assert positions["legacy.jsonl"].source_path == path + assert positions["legacy.jsonl"].offset == byte_size(first <> second) + assert positions["legacy.jsonl"].incomplete_tail == "" + assert {^positions, 0, _} = LearningScheduler.ingest_new_outcomes(positions) + end + end + test "legacy integer offset is normalised and advanced correctly", %{tmp: tmp} do line1 = record("recipe-a", "success", %{"verification" => "verified"}) write_jsonl(tmp, "2026-11.jsonl", line1 <> "\n") diff --git a/test/merge_orchestration/baton_emitter_test.exs b/test/merge_orchestration/baton_emitter_test.exs index a32d06ac..80ec86d7 100644 --- a/test/merge_orchestration/baton_emitter_test.exs +++ b/test/merge_orchestration/baton_emitter_test.exs @@ -4,60 +4,50 @@ defmodule Hypatia.MergeOrchestration.BatonEmitterTest do use ExUnit.Case, async: true alias Hypatia.MergeOrchestration.BatonEmitter - defp dec(o) do - Map.merge( - %{ - pr: %{repo: "hyperpolymath/a", number: 7}, - method: :squash, - pool: :p2, - route: %{authority_bot: "robot-repo-automaton", contributing_bots: ["ci"]}, - rationale: "chore/object -> arm_auto" - }, - o - ) - end + test "even an armed, revision-bound model decision cannot manufacture a qualified executor" do + decision = %{ + pr: %{repo: "hyperpolymath/a", number: 7}, + safety: :arm_auto, + head_sha: String.duplicate("a", 40), + event_id: "test:baton:1", + method: :squash, + pool: :p2, + route: %{authority_bot: "ci"} + } - test "to_spec builds a submit_planned spec routed to the token-bearing node, gated as mutating" do - s = BatonEmitter.to_spec(dec(%{}), %{"lease_id" => "L1"}) - - assert s.check_id == "merge-hyperpolymath__a-7" - assert s.command == ["gh", "pr", "merge", "7", "--repo", "hyperpolymath/a", "--squash"] - # routes to the only node holding the token capability; the brain lacks it. - # underscore form — the bag-of-actions Zig bridge / Bag.Planner tag (a - # hyphenated tag is unprovable and would route to no node). - assert s.required_cap == "secret_access" - # a merge mutates -> the planner gates it on a verifier (independent re-verify); - # the spec MUST carry one or Bag.Planner returns {:rejected, :mutation_requires_verifier} - assert s.mutating == true - assert s.verifier.by == "git-private-farm:decide_action" - assert s.verifier.reverifies == dec(%{}).route - assert s.risk == :low - assert s.attestation.lease_id == "L1" - assert s.attestation.rationale =~ "arm_auto" - end + assert BatonEmitter.availability() == {:error, :qualified_merge_executor_unavailable} - test "method maps to the gh flag; aggressive pools raise the planner risk" do - # rebase replays commits unsigned: a stale :rebase decision must squash - assert BatonEmitter.to_spec(dec(%{method: :rebase})).command |> List.last() == "--squash" - assert BatonEmitter.to_spec(dec(%{method: :merge_commit})).command |> List.last() == "--merge" - assert BatonEmitter.to_spec(dec(%{pool: :p3})).risk == :high - assert BatonEmitter.to_spec(dec(%{pool: :mass_squash})).risk == :high + assert {:error, :qualified_merge_executor_unavailable} = + BatonEmitter.to_spec(decision, %{"lease_id" => "L1"}) + + for method <- [:rebase, :merge_commit, :unknown] do + assert {:error, :qualified_merge_executor_unavailable} = + BatonEmitter.to_spec(%{decision | method: method}) + end end - test "emit submits one Baton per ARMED entry only; the Bag.Mesh call is injected" do + test "descriptive verifier metadata never invokes submission or authorizes mutation" do + decision = %{ + pr: %{repo: "hyperpolymath/a", number: 7}, + safety: :arm_auto, + verifier: %{by: "git-private-farm:decide_action", verified: true} + } + results = [ - %{gate: {:armed, %{"lease_id" => "L1"}}, decision: dec(%{}), entry: %{}}, - %{gate: :no_gate, decision: dec(%{pool: :p1}), entry: %{}}, - %{gate: {:deferred, %{"holder" => "x"}}, decision: dec(%{}), entry: %{}} + %{gate: {:armed, %{"lease_id" => "L1"}}, decision: decision}, + %{gate: :no_gate, decision: decision}, + %{gate: {:deferred, %{}}, decision: decision} ] - submitted = - BatonEmitter.emit(%{results: results}, - submit: fn spec -> {:pass, "mesh-github-runner", spec.check_id} end - ) + refused = + BatonEmitter.emit(%{results: results}, submit: fn _ -> flunk("mutation submitted") end) - assert length(submitted) == 1 - assert hd(submitted).spec.required_cap == "secret_access" - assert {:pass, "mesh-github-runner", "merge-hyperpolymath__a-7"} = hd(submitted).result + assert [ + %{ + pr: %{number: 7}, + spec: nil, + result: {:error, :qualified_merge_executor_unavailable} + } + ] = refused end end diff --git a/test/merge_orchestration/dispatcher_test.exs b/test/merge_orchestration/dispatcher_test.exs index c90e47b6..f9b5aa58 100644 --- a/test/merge_orchestration/dispatcher_test.exs +++ b/test/merge_orchestration/dispatcher_test.exs @@ -8,6 +8,11 @@ defmodule Hypatia.MergeOrchestration.DispatcherTest do Map.merge( %{ pr: %{repo: "hyperpolymath/panll", number: 412}, + # Explicit synthetic model inputs; no signature-verification claim. + head_sha: String.duplicate("a", 40), + event_id: "test:policy:1", + observation_complete: true, + execution_cap: :arm_auto, change_class: :chore, change_level: :object, pool: :p2, @@ -23,6 +28,61 @@ defmodule Hypatia.MergeOrchestration.DispatcherTest do assert m["tier"] == "eliminate" assert m["merge"]["method"] == "squash" assert m["repo"] == "hyperpolymath/panll" + assert m["event_id"] == "test:policy:1" + assert m["merge"]["head_sha"] == String.duplicate("a", 40) + assert m["merge"]["pool"] == "P2" + end + + test "direct public decision and manifest APIs default a missing execution cap to review" do + context = ctx(%{}) |> Map.delete(:execution_cap) + {decision, entry, _lease} = Dispatcher.dispatch(context) + assert decision.safety == :review + assert entry["strategy"] == "review" + + batch = Dispatcher.decide_all([context]) + assert batch.stats == %{total: 1, auto_execute: 0, review: 1, report_only: 0} + assert Enum.all?(batch.entries, &(&1["strategy"] == "review")) + + dir = Path.join(System.tmp_dir!(), "moj-default-cap-#{System.unique_integer([:positive])}") + on_exit(fn -> File.rm_rf!(dir) end) + + assert {:ok, path, stats} = + Dispatcher.write_manifest([context], + dir: dir, + encode: fn item -> "STRATEGY=" <> item["strategy"] end + ) + + assert stats == batch.stats + assert File.read!(path) == "STRATEGY=review\n" + end + + test "newline-suffixed subjects and events cannot serialize an explicit model arm" do + for overrides <- [ + %{head_sha: String.duplicate("a", 40) <> "\n"}, + %{event_id: "test:policy:1\n"} + ] do + {decision, entry, _lease} = Dispatcher.dispatch(ctx(overrides)) + assert decision.safety == :arm_auto + assert entry["strategy"] == "review" + assert entry["merge"]["safety"] == "review" + assert entry["merge"]["rationale"] =~ "unqualified-observation" + end + end + + test "legacy model arm without an assessed revision/event is review-only at serialization" do + for field <- [:head_sha, :event_id, :observation_complete] do + {_decision, entry, _lease} = ctx(%{}) |> Map.delete(field) |> Dispatcher.dispatch() + assert entry["strategy"] == "review" + assert entry["merge"]["safety"] == "review" + assert entry["merge"]["rationale"] =~ "unqualified-observation" + end + end + + test "canonical pool spellings match the independent farm gate" do + for {pool, name} <- [p0: "P0", p1: "P1", p2: "P2", p3: "P3", mass_squash: "mass_squash"] do + {_decision, entry, _lease} = Dispatcher.dispatch(ctx(%{pool: pool})) + assert entry["merge"]["pool"] == name + end end test "a veto -> report_only / control, and the veto rides along in the manifest" do diff --git a/test/merge_orchestration/kin_competence_test.exs b/test/merge_orchestration/kin_competence_test.exs index e231578b..2de24a3a 100644 --- a/test/merge_orchestration/kin_competence_test.exs +++ b/test/merge_orchestration/kin_competence_test.exs @@ -56,11 +56,13 @@ defmodule Hypatia.MergeOrchestration.KinCompetenceTest do change_class: :proof, change_level: :object, pool: :p2, - attestations: [%{bot: "spammy", verdict: :approve, confidence: 0.99}] + attestations: [%{bot: "spammy", verdict: :approve, confidence: 0.99}], + execution_cap: :arm_auto } - # uniform council would arm on the 0.99 + # The explicit policy-model cap permits the uniform council's 0.99 arm. assert Strategist.decide(ctx).confidence == 0.99 + assert Strategist.decide(ctx).safety == :arm_auto # competence-aware: spammy has zero trust → recuses → no approvals → flag weighted = KinCompetence.attach_weight(ctx, %{"spammy" => 0.0}) diff --git a/test/merge_orchestration/loop_test.exs b/test/merge_orchestration/loop_test.exs index 5957e5bd..ac73710f 100644 --- a/test/merge_orchestration/loop_test.exs +++ b/test/merge_orchestration/loop_test.exs @@ -2,290 +2,288 @@ # Copyright (c) 2026 Jonathan D.A. Jewell defmodule Hypatia.MergeOrchestration.LoopTest do use ExUnit.Case, async: true - alias Hypatia.MergeOrchestration.Loop - alias Hypatia.MergeOrchestration.KinGate.FileStore - - @now ~U[2026-06-14 12:00:00Z] - - # a faithful round-trip codec that needs no JSON dep - defp enc, do: fn t -> t |> :erlang.term_to_binary() |> Base.encode64() end - defp dec, do: fn b -> b |> Base.decode64!() |> :erlang.binary_to_term() end - - defp tmp(prefix), - do: Path.join(System.tmp_dir!(), "#{prefix}-#{System.unique_integer([:positive])}") - - defp file_acquire(dir), - do: fn req -> FileStore.acquire(dir, req, now: @now, encode: enc(), decode: dec()) end - - defp bump(repo, n), - do: %{ - "repo" => repo, - "number" => n, - "branch" => "dependabot/cargo/x#{n}", - "files" => ["Cargo.lock"] - } - - defp pool_p2, do: fn _repo -> "P2" end - - defp approve_ci, - do: fn _repo, _n -> [%{"bot" => "ci", "verdict" => "approve", "confidence" => 0.99}] end - - # a store with exactly one armable PR (cargo bump, P2, CI-approved) — the same - # shape the run/1 manifest test arms, reused by the actuation-backend tests. - defp armed_store do - store = tmp("loop-actuation") - - for sub <- ~w(observations pools attestations leases), - do: File.mkdir_p!(Path.join(store, sub)) - - e = enc() - - File.write!( - Path.join([store, "observations", "hyperpolymath__a__1.json"]), - e.(bump("hyperpolymath/a", 1)) - ) - - File.write!(Path.join([store, "pools", "hyperpolymath__a.json"]), e.(%{"pool" => "P2"})) + alias Hypatia.MergeOrchestration.{Loop, TestObservationStore} + alias TestObservationStore, as: Evidence + + defp cleanup(store), do: on_exit(fn -> File.rm_rf!(store) end) + defp no_acquire(_), do: flunk("unverified evidence must never acquire an execution lease") + defp pool_p2(_), do: "P2" + + defp approval(repo, number, head), + do: [Evidence.attestation(%{"repo" => repo, "number" => number, "head_sha" => head})] + + test "two high-confidence Sensor decisions remain reviews and never acquire a lease" do + observations = [Evidence.observation(), Evidence.observation(%{"number" => 2})] + result = Loop.plan(observations, &pool_p2/1, &approval/3, %{}, "hypatia", &no_acquire/1) + assert result.stats.armed == 0 + assert result.stats.review == 2 + assert result.leases == [] + assert Enum.all?(result.entries, &(&1["strategy"] == "review")) + end - File.write!( - Path.join([store, "attestations", "ci.json"]), - e.(%{ - "subject" => %{"repo" => "hyperpolymath/a", "number" => 1}, - "bot" => "ci", - "verdict" => "approve", - "confidence" => 0.99 - }) - ) + test "meta and veto guards stay stronger than the review cap" do + observations = [ + Evidence.observation(), + Evidence.observation(%{"number" => 2, "files" => [".github/workflows/ci.yml"]}), + Evidence.observation(%{"number" => 3, "files" => ["LICENSE"]}) + ] - store + result = Loop.plan(observations, &pool_p2/1, &approval/3, %{}, "hypatia", &no_acquire/1) + assert result.stats == %{total: 3, armed: 0, deferred: 0, blocked: 0, review: 1, flagged: 2} end - # ── the gate bites: in-cycle one-arm-per-repo ──────────────────────────────── - - test "two armed PRs for the same repo in one cycle: first arms, second is deferred to report_only" do - leases = tmp("loop-incycle") + test "legacy incomplete observations remain non-executable without querying a guessed revision" do + resolver = fn _, _, _ -> flunk("legacy record cannot resolve revision-bound evidence") end - r = + result = Loop.plan( - [bump("hyperpolymath/a", 1), bump("hyperpolymath/a", 2)], - pool_p2(), - approve_ci(), + [%{"repo" => "hyperpolymath/a", "number" => 1, "files" => ["Cargo.lock"]}], + &pool_p2/1, + resolver, %{}, "hypatia", - file_acquire(leases) + &no_acquire/1 ) - assert r.stats.armed == 1 - assert r.stats.deferred == 1 - [e1, e2] = r.entries - assert e1["strategy"] == "auto_execute" - assert e2["strategy"] == "report_only" - assert e2["merge"]["rationale"] =~ "same-repo-this-cycle" - File.rm_rf!(leases) + assert result.stats.armed == 0 + assert result.stats.flagged == 1 + assert hd(result.entries)["strategy"] == "report_only" end - # ── the gate bites: cross-agent persistent lease ───────────────────────────── - - test "an arm is deferred when another holder already holds the repo lease" do - leases = tmp("loop-crossagent") - # another agent already holds repo a - {:ok, _} = - FileStore.acquire( - leases, - %{ - repo: "hyperpolymath/a", - holder: "robot-repo-automaton", - territory: %{branch: "b", paths: [], is_meta: false} - }, - now: @now, - encode: enc(), - decode: dec() - ) + test "complete receipt round-trips exact subject/event into a review manifest" do + store = Evidence.seed() + cleanup(store) + result = Loop.run(store: store, acquire: &no_acquire/1) + assert result.stats.review == 1 + assert result.stats.armed == 0 + entry = result.manifest_path |> File.read!() |> Jason.decode!() + assert entry["event_id"] == Evidence.event() + assert entry["merge"]["head_sha"] == Evidence.head() + assert entry["merge"]["pool"] == "P2" + assert entry["merge"]["evidence_authenticity"] == "unverified" + assert entry["strategy"] == "review" + assert File.ls!(Path.join(store, "leases")) == [] + end - r = - Loop.plan( - [bump("hyperpolymath/a", 1)], - pool_p2(), - approve_ci(), - %{}, - "hypatia", - file_acquire(leases) - ) + test "a complete batch includes canonical standards without lifting review or meta guards" do + observations = [ + Evidence.observation(), + Evidence.observation(%{"repo" => "metadatastician/standards", "number" => 2}), + Evidence.observation(%{ + "repo" => "metadatastician/standards", + "number" => 3, + "files" => ["rulesets/main.json"] + }) + ] + + store = Evidence.seed(observations: observations) + cleanup(store) + result = Loop.run(store: store, acquire: &no_acquire/1) + assert result.stats == %{total: 3, armed: 0, deferred: 0, blocked: 0, review: 2, flagged: 1} + assert result.leases == [] + assert File.ls!(Path.join(store, "leases")) == [] + assert File.read!(result.manifest_path) =~ "metadatastician/standards" + end + + test "unlisted historical records are retained without becoming current observations" do + store = Evidence.seed() + cleanup(store) + historical = Path.join([store, "observations", "hyperpolymath__a__999.json"]) + File.write!(historical, "{broken historical input") + assert Loop.run(store: store).stats.total == 1 + assert File.read!(historical) == "{broken historical input" + end - assert r.stats.armed == 0 - assert r.stats.deferred == 1 - [e1] = r.entries - assert e1["strategy"] == "report_only" - assert e1["merge"]["rationale"] =~ "lease-held:robot-repo-automaton" - File.rm_rf!(leases) + test "a complete empty scoped batch differs from an unreadable or failed batch" do + store = Evidence.seed() + cleanup(store) + Evidence.write_batch(store, []) + result = Loop.run(store: store) + assert result.stats.total == 0 + assert File.read!(result.manifest_path) == "" + assert File.exists?(Path.join([store, "observations", "hyperpolymath__a__1.json"])) end - # ── a full mixed cycle: arm / arm / review / meta-flag ─────────────────────── + test "missing, failed, collecting, duplicate and contradictory receipts preserve the prior manifest" do + transforms = [ + fn receipt -> %{receipt | "status" => "collecting"} end, + fn receipt -> %{receipt | "status" => "failed"} end, + fn receipt -> %{receipt | "schema_version" => 999} end, + fn receipt -> Map.delete(receipt, "event_id") end, + fn receipt -> %{receipt | "repos" => []} end, + fn receipt -> + %{receipt | "observations" => receipt["observations"] ++ receipt["observations"]} + end, + fn receipt -> + put_in(receipt, ["observations", Access.at(0), "sha256"], String.duplicate("0", 64)) + end, + fn receipt -> + put_in(receipt, ["observations", Access.at(0), "head_sha"], String.duplicate("b", 40)) + end, + fn receipt -> put_in(receipt, ["observations", Access.at(0), "file"], "../outside.json") end + ] + + for transform <- transforms do + store = Evidence.seed() + cleanup(store) + previous = Path.join(store, "merge-decisions.jsonl") + File.write!(previous, "previous review-only manifest") + receipt = Evidence.receipt_path(store) + + File.write!( + receipt, + receipt |> File.read!() |> Jason.decode!() |> transform.() |> Jason.encode!() + ) - test "a mixed cycle tallies armed / review / flagged, gating only the arms" do - leases = tmp("loop-mixed") + assert_raise ArgumentError, ~r/unqualified observation batch/, fn -> + Loop.run(store: store) + end - pool = fn - "hyperpolymath/c" -> "P1" - _ -> "P2" + assert File.read!(previous) == "previous review-only manifest" end - observations = [ - bump("hyperpolymath/a", 1), - bump("hyperpolymath/b", 2), - %{"repo" => "hyperpolymath/c", "number" => 3, "files" => ["docs/x.adoc"]}, - %{ - "repo" => "hyperpolymath/d", - "number" => 4, - "branch" => "ci/pin", - "files" => [".github/workflows/ci.yml"] - } - ] + store = Evidence.seed() + cleanup(store) + File.rm!(Evidence.receipt_path(store)) - r = Loop.plan(observations, pool, approve_ci(), %{}, "hypatia", file_acquire(leases)) + assert_raise ArgumentError, ~r/unqualified observation batch/, fn -> + Loop.run(store: store) + end - assert r.stats == %{total: 4, armed: 2, deferred: 0, blocked: 0, review: 1, flagged: 1} - assert length(r.leases) == 2 - File.rm_rf!(leases) + refute File.exists?(Path.join(store, "merge-decisions.jsonl")) end - # ── the whole thing over a real store of files (codec injected) ────────────── + test "a newline-suffixed digest is malformed before observation comparison" do + store = Evidence.seed() + cleanup(store) + receipt_path = Evidence.receipt_path(store) + receipt = receipt_path |> File.read!() |> Jason.decode!() - test "run/1 reads a store of observation/pool/attestation files, gates, and writes the manifest + leases" do - store = tmp("loop-store") + changed = update_in(receipt, ["observations", Access.at(0), "sha256"], &(&1 <> "\n")) - for sub <- ~w(observations pools attestations leases), - do: File.mkdir_p!(Path.join(store, sub)) + File.write!(receipt_path, Jason.encode!(changed)) - e = enc() + assert_raise ArgumentError, ~r/missing, incomplete or malformed receipt/, fn -> + Loop.run(store: store) + end - File.write!( - Path.join([store, "observations", "hyperpolymath__a__1.json"]), - e.(bump("hyperpolymath/a", 1)) - ) + refute File.exists?(Path.join(store, "merge-decisions.jsonl")) + end - File.write!(Path.join([store, "pools", "hyperpolymath__a.json"]), e.(%{"pool" => "P2"})) + test "file tampering, missing members and symlinks cannot satisfy a complete receipt" do + for action <- [:tamper, :missing, :symlink] do + store = Evidence.seed() + cleanup(store) + member = Path.join([store, "observations", "hyperpolymath__a__1.json"]) - File.write!( - Path.join([store, "attestations", "ci.json"]), - e.(%{ - "subject" => %{"repo" => "hyperpolymath/a", "number" => 1}, - "bot" => "ci", - "verdict" => "approve", - "confidence" => 0.99 - }) - ) + case action do + :tamper -> + File.write!(member, Jason.encode!(Evidence.observation(%{"event_id" => "test:other"}))) - r = Loop.run(store: store, holder: "hypatia", now: @now, encode: e, decode: dec()) + :missing -> + File.rm!(member) - assert r.stats.armed == 1 - assert String.ends_with?(r.manifest_path, "merge-decisions.jsonl") + :symlink -> + File.rename!(member, member <> ".saved") + File.ln_s!(member <> ".saved", member) + end - lines = r.manifest_path |> File.read!() |> String.trim() |> String.split("\n") - assert length(lines) == 1 - entry = dec().(hd(lines)) - assert entry["strategy"] == "auto_execute" - assert entry["merge"]["method"] == "squash" + assert_raise ArgumentError, ~r/unqualified observation batch/, fn -> + Loop.run(store: store) + end - # the lease for repo a was persisted by the gate - assert File.exists?(Path.join([store, "leases", "hyperpolymath__a.json"])) - File.rm_rf!(store) + refute File.exists?(Path.join(store, "merge-decisions.jsonl")) + end end - # ── actuation backends: :manifest (default) | :baton | :both ───────────────── - - test ":actuation :baton submits one Baton per armed entry and writes no manifest" do - store = armed_store() - # stand in for Bag.Mesh.submit_planned/2 — record what the planner would route - submit = fn spec -> {:routed, spec.required_cap, spec.mutating, spec.verifier != nil} end - - r = - Loop.run( - store: store, - holder: "hypatia", - now: @now, - encode: enc(), - decode: dec(), - actuation: :baton, - submit: submit - ) + test "matching bytes with inconsistent record metadata still fail qualification" do + for field <- ["head_sha", "event_id", "complete", "files", "changed_file_count"] do + store = Evidence.seed() + cleanup(store) + + values = %{ + "head_sha" => String.duplicate("c", 40), + "event_id" => "test:other", + "complete" => false, + "files" => [], + "changed_file_count" => 9 + } - assert r.stats.armed == 1 - # token-free brain: no manifest is written in pure :baton mode - refute Map.has_key?(r, :manifest_path) - refute File.exists?(Path.join(store, "merge-decisions.jsonl")) + member = Path.join([store, "observations", "hyperpolymath__a__1.json"]) - # one Baton, routed to the secret_access node, mutating + verifier present - assert [%{spec: spec, result: {:routed, "secret_access", true, true}}] = r.batons - assert spec.command == ["gh", "pr", "merge", "1", "--repo", "hyperpolymath/a", "--squash"] - File.rm_rf!(store) - end + body = + member + |> File.read!() + |> Jason.decode!() + |> Map.put(field, values[field]) + |> Jason.encode!() - test ":actuation :both writes the durable manifest AND submits Batons" do - store = armed_store() - - r = - Loop.run( - store: store, - holder: "hypatia", - now: @now, - encode: enc(), - decode: dec(), - actuation: :both, - submit: fn _spec -> :ok end - ) + File.write!(member, body) + receipt_path = Evidence.receipt_path(store) + receipt = receipt_path |> File.read!() |> Jason.decode!() - assert String.ends_with?(r.manifest_path, "merge-decisions.jsonl") - assert File.exists?(r.manifest_path) - assert length(r.batons) == 1 - File.rm_rf!(store) - end + receipt = + put_in( + receipt, + ["observations", Access.at(0), "sha256"], + :crypto.hash(:sha256, body) |> Base.encode16(case: :lower) + ) - test ":actuation defaults to :manifest — no Batons, no Bag.* reference" do - store = armed_store() - # no :submit / :budget injected and bag-of-actions is not a dependency here; - # this must not raise (the Bag.Mesh call is only built in the Baton backends). - r = Loop.run(store: store, holder: "hypatia", now: @now, encode: enc(), decode: dec()) + File.write!(receipt_path, Jason.encode!(receipt)) - assert String.ends_with?(r.manifest_path, "merge-decisions.jsonl") - refute Map.has_key?(r, :batons) - File.rm_rf!(store) + assert_raise ArgumentError, ~r/unqualified observation batch/, fn -> + Loop.run(store: store) + end + end end - # ── real Jason over a real store (CI only) ─────────────────────────────────── + test "a receipt changed during file decoding cannot publish a mixed batch" do + store = Evidence.seed() + cleanup(store) - if Code.ensure_loaded?(Jason) do - test "run/1 with the default Jason codec round-trips a store to a JSON manifest" do - store = tmp("loop-json") + decoder = fn body -> + decoded = Jason.decode!(body) - for sub <- ~w(observations pools attestations leases), - do: File.mkdir_p!(Path.join(store, sub)) + if is_map(decoded) and Map.has_key?(decoded, "head_sha") do + path = Evidence.receipt_path(store) + receipt = path |> File.read!() |> Jason.decode!() |> Map.put("status", "collecting") + File.write!(path, Jason.encode!(receipt)) + end - File.write!( - Path.join([store, "observations", "hyperpolymath__a__1.json"]), - Jason.encode!(bump("hyperpolymath/a", 1)) - ) + decoded + end - File.write!( - Path.join([store, "pools", "hyperpolymath__a.json"]), - Jason.encode!(%{"pool" => "P2"}) - ) + assert_raise ArgumentError, ~r/receipt changed while reading/, fn -> + Loop.run(store: store, decode: decoder) + end - File.write!( - Path.join([store, "attestations", "ci.json"]), - Jason.encode!(%{ - "subject" => %{"repo" => "hyperpolymath/a", "number" => 1}, - "bot" => "ci", - "verdict" => "approve", - "confidence" => 0.99 - }) - ) + refute File.exists?(Path.join(store, "merge-decisions.jsonl")) + end + + test "Baton selections do not invoke a submit function or resolve an unqualified Bag backend" do + for mode <- [:baton, :both] do + store = Evidence.seed() + cleanup(store) + + result = + Loop.run( + store: store, + actuation: mode, + submit: fn _ -> flunk("unqualified executor called") end + ) + + assert result.stats.armed == 0 + assert result.batons == [] + assert Map.has_key?(result, :manifest_path) == (mode == :both) + end + end + + test "unknown backend is an error, not a successful no-op" do + store = Evidence.seed() + cleanup(store) - r = Loop.run(store: store, holder: "hypatia", now: @now) - assert r.stats.armed == 1 - entry = r.manifest_path |> File.read!() |> String.trim() |> Jason.decode!() - assert entry["strategy"] == "auto_execute" - File.rm_rf!(store) + assert_raise ArgumentError, ~r/unknown actuation backend/, fn -> + Loop.run(store: store, actuation: :unknown) end end end diff --git a/test/merge_orchestration/merge_orchestrate_task_test.exs b/test/merge_orchestration/merge_orchestrate_task_test.exs new file mode 100644 index 00000000..c05f9624 --- /dev/null +++ b/test/merge_orchestration/merge_orchestrate_task_test.exs @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +defmodule Mix.Tasks.Hypatia.MergeOrchestrateTest do + use ExUnit.Case, async: false + import ExUnit.CaptureIO + + alias Hypatia.MergeOrchestration.TestObservationStore, as: Evidence + alias Mix.Tasks.Hypatia.MergeOrchestrate + + test "the actual manifest task writes a decision artifact without claiming a merge" do + store = Evidence.seed() + on_exit(fn -> File.rm_rf!(store) end) + + output = capture_io(fn -> MergeOrchestrate.run(["--store", store]) end) + path = Path.join(store, "merge-decisions.jsonl") + entry = path |> File.read!() |> Jason.decode!() + + assert output =~ path + refute output =~ "baton(s)" + assert entry["strategy"] in ["review", "report_only"] + assert entry["merge"]["head_sha"] == Evidence.head() + assert entry["event_id"] == Evidence.event() + assert File.ls!(Path.join(store, "leases")) == [] + end + + test "unavailable Baton modes fail the actual argv path even with no candidates" do + for mode <- ["baton", "both"], observations <- [[], [Evidence.observation()]] do + store = Evidence.seed(observations: observations) + on_exit(fn -> File.rm_rf!(store) end) + manifest = Path.join(store, "merge-decisions.jsonl") + File.write!(manifest, "previous decision manifest") + + output = + capture_io(fn -> + assert_raise Mix.Error, ~r/qualified_merge_executor_unavailable/, fn -> + MergeOrchestrate.run(["--store", store, "--actuation", mode]) + end + end) + + assert output == "" + assert File.read!(manifest) == "previous decision manifest" + assert File.ls!(Path.join(store, "leases")) == [] + end + end +end diff --git a/test/merge_orchestration/scheduler_test.exs b/test/merge_orchestration/scheduler_test.exs index bf7d956d..6d2cbbad 100644 --- a/test/merge_orchestration/scheduler_test.exs +++ b/test/merge_orchestration/scheduler_test.exs @@ -17,45 +17,14 @@ defmodule Hypatia.MergeOrchestration.SchedulerTest do defp dec, do: fn b -> b |> Base.decode64!() |> :erlang.binary_to_term() end # a store with one council-approved bump (sole approver "ci") - defp seed_store do - store = Path.join(System.tmp_dir!(), "sched-#{System.unique_integer([:positive])}") - - for sub <- ~w(observations pools attestations leases), - do: File.mkdir_p!(Path.join(store, sub)) - - e = enc() - - File.write!( - Path.join([store, "observations", "hyperpolymath__a__1.json"]), - e.(%{ - "repo" => "hyperpolymath/a", - "number" => 1, - "branch" => "dependabot/cargo/x", - "files" => ["Cargo.lock"] - }) - ) - - File.write!(Path.join([store, "pools", "hyperpolymath__a.json"]), e.(%{"pool" => "P2"})) - - File.write!( - Path.join([store, "attestations", "ci.json"]), - e.(%{ - "subject" => %{"repo" => "hyperpolymath/a", "number" => 1}, - "bot" => "ci", - "verdict" => "approve", - "confidence" => 0.99 - }) - ) - - store - end + defp seed_store, do: Hypatia.MergeOrchestration.TestObservationStore.seed(encode: enc()) defp codec, do: [now: @now, encode: enc(), decode: dec()] - test "a cycle with no trust source runs a uniform council and arms the bump" do + test "a cycle with no trust source remains review-only without authenticated approvals" do store = seed_store() r = Scheduler.cycle([store: store, got: nil] ++ codec()) - assert r.stats.armed == 1 + assert r.stats.armed == 0 File.rm_rf!(store) end @@ -77,14 +46,19 @@ defmodule Hypatia.MergeOrchestration.SchedulerTest do r = Scheduler.cycle( - [store: store, got: nil, actuation: :baton, submit: fn spec -> {:ok, spec.check_id} end] ++ + [ + store: store, + got: nil, + actuation: :baton, + submit: fn _ -> flunk("unqualified Baton executor invoked") end + ] ++ codec() ) - assert r.stats.armed == 1 + assert r.stats.armed == 0 refute Map.has_key?(r, :manifest_path) - assert [%{spec: spec, result: {:ok, _}}] = r.batons - assert spec.required_cap == "secret_access" + assert r.batons == [] + assert r.stats.review == 1 File.rm_rf!(store) end @@ -94,7 +68,7 @@ defmodule Hypatia.MergeOrchestration.SchedulerTest do r = Scheduler.cycle([got: nil] ++ codec()) assert r.manifest_path == Path.join(store, "merge-decisions.jsonl") - assert r.stats.armed == 1 + assert r.stats.armed == 0 System.delete_env("MERGE_ORCH_STORE") File.rm_rf!(store) diff --git a/test/merge_orchestration/sensor_test.exs b/test/merge_orchestration/sensor_test.exs index ed6fbc4c..3768f514 100644 --- a/test/merge_orchestration/sensor_test.exs +++ b/test/merge_orchestration/sensor_test.exs @@ -2,7 +2,8 @@ # Copyright (c) 2026 Jonathan D.A. Jewell defmodule Hypatia.MergeOrchestration.SensorTest do use ExUnit.Case, async: true - alias Hypatia.MergeOrchestration.Sensor + alias Hypatia.MergeOrchestration.{Dispatcher, Sensor} + alias Hypatia.MergeOrchestration.TestObservationStore, as: Evidence defp obs(o), do: Map.merge(%{"repo" => "hyperpolymath/panll", "number" => 1, "files" => []}, o) @@ -71,6 +72,71 @@ defmodule Hypatia.MergeOrchestration.SensorTest do end end + test "canonical standards subjects qualify without trusting renamed or stale attestations" do + for repo <- ["metadatastician/standards", "Metadatastician/Standards"] do + observation = Evidence.observation(%{"repo" => repo, "files" => ["Cargo.lock"]}) + matching = Evidence.attestation(observation) + former = Evidence.attestation(%{observation | "repo" => "hyperpolymath/standards"}) + stale = Evidence.attestation(%{observation | "head_sha" => String.duplicate("b", 40)}) + + assert Sensor.valid_subject?(repo, 1, Evidence.head()) + assert Sensor.valid_observation?(observation) + context = Sensor.to_context(observation, "P2", [matching, former, stale]) + assert context.observation_complete + assert context.execution_cap == :review + assert context.evidence_authenticity == :unverified + assert [%{subject: subject}] = context.attestations + assert subject == Map.take(observation, ["repo", "number", "head_sha"]) + end + end + + test "the standards mapping does not admit unrelated owners or malformed subjects" do + for repo <- [ + "metadatastician/unrelated", + "other/standards", + "metadatastician/standards-extra", + "metadatastician/standards/extra", + "metadatastician/../standards", + "metadatastician/standards\n", + "hyperpolymath/panll\n" + ] do + refute Sensor.valid_subject?(repo, 1, Evidence.head()) + end + + for {number, head} <- [{0, Evidence.head()}, {1, "abc"}, {"1", Evidence.head()}] do + refute Sensor.valid_subject?("metadatastician/standards", number, head) + end + end + + test "SHA and event identifiers must match the complete string" do + assert Sensor.valid_sha?(Evidence.head()) + assert Sensor.valid_event?(Evidence.event()) + + for suffix <- ["\n", "\r\n", " "] do + refute Sensor.valid_sha?(Evidence.head() <> suffix) + refute Sensor.valid_event?(Evidence.event() <> suffix) + end + + refute Sensor.valid_sha?("\n" <> Evidence.head()) + refute Sensor.valid_event?("\n" <> Evidence.event()) + refute Sensor.valid_subject?("hyperpolymath/a", 1, Evidence.head() <> "\n") + end + + test "newline metadata cannot qualify an observation or become executable" do + for field <- ["head_sha", "base_sha", "event_id"] do + observation = Evidence.observation() + changed = Map.update!(observation, field, &(&1 <> "\n")) + refute Sensor.valid_observation?(changed) + + context = Sensor.to_context(changed, "P3", [Evidence.attestation(changed)]) + refute context.observation_complete + assert context.execution_cap == :flag + {decision, entry, _lease} = Dispatcher.dispatch(context) + assert decision.safety == :flag + assert entry["strategy"] == "report_only" + end + end + test "ordinary library code is object-level" do assert {_, :object, _} = Sensor.classify(obs(%{"files" => ["lib/x.ex", "lib/y.ex"]})) end @@ -88,9 +154,18 @@ defmodule Hypatia.MergeOrchestration.SensorTest do test "to_context produces the ctx the Strategist/Dispatcher read, with pool mapped and attestations normalised" do ctx = Sensor.to_context( - obs(%{"number" => 412, "branch" => "dependabot/cargo/x", "files" => ["Cargo.lock"]}), + Evidence.observation(%{ + "repo" => "hyperpolymath/panll", + "number" => 412, + "branch" => "dependabot/cargo/x", + "files" => ["Cargo.lock"] + }), %{"pool" => "P2", "product_tier" => "standard"}, - [%{"bot" => "ci", "verdict" => "approve", "confidence" => 0.99}] + [ + Evidence.attestation( + Evidence.observation(%{"repo" => "hyperpolymath/panll", "number" => 412}) + ) + ] ) assert ctx.pr == %{repo: "hyperpolymath/panll", number: 412} @@ -101,6 +176,11 @@ defmodule Hypatia.MergeOrchestration.SensorTest do assert ctx.paths == ["Cargo.lock"] assert [%{bot: "ci", verdict: :approve, confidence: 0.99}] = ctx.attestations assert ctx.contributing_bots == ["ci"] + assert ctx.head_sha == Evidence.head() + assert ctx.event_id == Evidence.event() + assert ctx.observation_complete + assert ctx.evidence_authenticity == :unverified + assert ctx.execution_cap == :review end test "an unlabelled repo (nil pool) defaults to the conservative :p1" do @@ -138,7 +218,7 @@ defmodule Hypatia.MergeOrchestration.SensorTest do # ── sense → deliberate (the integration proof) ─────────────────────────────── - test "sense_and_decide over a mixed batch hits all three safety outcomes" do + test "Sensor-fed high confidence stays review-only; meta and license vetoes remain stronger" do pools = %{ "hyperpolymath/a" => "P2", "hyperpolymath/b" => "P1", @@ -146,47 +226,57 @@ defmodule Hypatia.MergeOrchestration.SensorTest do "hyperpolymath/d" => "P3" } - observations = [ - # A: bump @ P2, council-approved → arm_auto - %{ - "repo" => "hyperpolymath/a", - "number" => 1, - "branch" => "dependabot/cargo/x", - "files" => ["Cargo.lock"] - }, - # B: docs @ P1 → clamped to review - %{"repo" => "hyperpolymath/b", "number" => 2, "files" => ["docs/x.adoc"]}, - # C: workflow edit → meta → flag - %{ - "repo" => "hyperpolymath/c", - "number" => 3, - "branch" => "ci/pin", - "files" => [".github/workflows/ci.yml"] - }, - # D: LICENSE touch → owner-only veto → flag - %{"repo" => "hyperpolymath/d", "number" => 4, "files" => ["LICENSE"]} - ] + observations = + [ + # A: bump @ P2 with unverified approval → review + %{ + "repo" => "hyperpolymath/a", + "number" => 1, + "branch" => "dependabot/cargo/x", + "files" => ["Cargo.lock"] + }, + # B: docs @ P1 → clamped to review + %{"repo" => "hyperpolymath/b", "number" => 2, "files" => ["docs/x.adoc"]}, + # C: workflow edit → meta → flag + %{ + "repo" => "hyperpolymath/c", + "number" => 3, + "branch" => "ci/pin", + "files" => [".github/workflows/ci.yml"] + }, + # D: LICENSE touch → owner-only veto → flag + %{"repo" => "hyperpolymath/d", "number" => 4, "files" => ["LICENSE"]} + ] + |> Enum.map( + &Evidence.observation(Map.merge(%{"branch" => "feature/change", "title" => "Change"}, &1)) + ) resolve_pool = fn repo -> Map.get(pools, repo) end - resolve_atts = fn _repo, _n -> - [%{"bot" => "ci", "verdict" => "approve", "confidence" => 0.99}] + resolve_atts = fn repo, number, head -> + [Evidence.attestation(%{"repo" => repo, "number" => number, "head_sha" => head})] end %{stats: stats, decisions: decisions} = Sensor.sense_and_decide(observations, resolve_pool, resolve_atts) - assert stats == %{total: 4, auto_execute: 1, review: 1, report_only: 2} + assert stats == %{total: 4, auto_execute: 0, review: 2, report_only: 2} # the bump armed with a squash; the meta and license ones both flagged for the right reason - assert %{change_class: :bump, method: :squash, safety: :arm_auto} = Enum.at(decisions, 0) + assert %{ + change_class: :bump, + method: :squash, + safety: :review, + clamped_by: "attestation-verification-unavailable" + } = Enum.at(decisions, 0) + assert %{change_level: :meta, safety: :flag} = Enum.at(decisions, 2) assert %{change_class: :license_touch, safety: :flag} = Enum.at(decisions, 3) end # ── store-backed resolvers: disk plumbing (decoder injected, dep-free) ──────── - test "dir_attestation_resolver reads every json and filters by subject (repo, number)" do + test "dir_attestation_resolver requires exact repository, PR and full head" do dir = Path.join(System.tmp_dir!(), "moj-att-#{System.unique_integer([:positive])}") File.mkdir_p!(dir) File.write!(Path.join(dir, "a.json"), "1") @@ -194,13 +284,22 @@ defmodule Hypatia.MergeOrchestration.SensorTest do File.write!(Path.join(dir, "note.txt"), "ignored") decode = fn - "1" -> %{"subject" => %{"repo" => "r", "number" => 1}, "bot" => "x", "verdict" => "approve"} - "2" -> %{"subject" => %{"repo" => "r", "number" => 2}, "bot" => "y", "verdict" => "veto"} + "1" -> + Evidence.attestation(Evidence.observation(), %{"bot" => "x"}) + + "2" -> + Evidence.attestation(Evidence.observation(%{"number" => 2}), %{ + "bot" => "y", + "verdict" => "veto" + }) end resolve = Sensor.dir_attestation_resolver(dir, decode) - assert [%{"bot" => "x"}] = resolve.("r", 1) - assert resolve.("r", 99) == [] + assert [%{"bot" => "x"}] = resolve.("hyperpolymath/a", 1, Evidence.head()) + assert resolve.("hyperpolymath/a", 99, Evidence.head()) == [] + assert resolve.("hyperpolymath/a", 1, String.duplicate("b", 40)) == [] + assert resolve.("hyperpolymath/other", 1, Evidence.head()) == [] + assert resolve.("hyperpolymath/a", 1, nil) == [] File.rm_rf!(dir) end @@ -220,7 +319,7 @@ defmodule Hypatia.MergeOrchestration.SensorTest do if Code.ensure_loaded?(Jason) do @fix Path.expand("../../docs/design/merge-orchestration/fixtures", __DIR__) - test "store_resolvers parse the real signed attestation + pool fixtures via Jason" do + test "legacy signed fixture with an abbreviated subject cannot qualify a full revision" do dir = Path.join(System.tmp_dir!(), "moj-store-#{System.unique_integer([:positive])}") File.mkdir_p!(Path.join(dir, "pools")) File.mkdir_p!(Path.join(dir, "attestations")) @@ -238,9 +337,65 @@ defmodule Hypatia.MergeOrchestration.SensorTest do {resolve_pool, resolve_atts} = Sensor.store_resolvers(dir) assert Sensor.pool_atom(resolve_pool.("hyperpolymath/panll")) == :p2 - assert [att] = resolve_atts.("hyperpolymath/hypatia", 442) - assert %{bot: "ci", verdict: :approve} = Sensor.normalize_attestation(att) + assert resolve_atts.("hyperpolymath/hypatia", 442, "3aa68ac") == [] + + assert resolve_atts.("hyperpolymath/hypatia", 442, "3aa68ac" <> String.duplicate("0", 33)) == + [] + File.rm_rf!(dir) end end + + test "injected resolvers cannot promote stale or unbound approvals" do + observation = Evidence.observation() + stale = Evidence.attestation(%{observation | "head_sha" => String.duplicate("b", 40)}) + unbound = %{"bot" => "ci", "verdict" => "approve", "confidence" => 1.0} + context = Sensor.to_context(observation, "P2", [stale, unbound]) + assert context.attestations == [] + assert Hypatia.MergeOrchestration.Strategist.decide(context).safety == :flag + end + + test "a claimed seal or verification flag is not a production verifier" do + observation = Evidence.observation() + + claimed = + Evidence.attestation(observation, %{ + "verified" => true, + "seal" => %{"sig" => "not-a-signature"} + }) + + decision = + observation + |> Sensor.to_context("P3", [claimed]) + |> Hypatia.MergeOrchestration.Strategist.decide() + + assert decision.safety == :review + assert decision.evidence_authenticity == :unverified + end + + test "incomplete observations and malformed approval confidence do not become executable" do + observation = Evidence.observation() + + for changed <- [ + Map.delete(observation, "event_id"), + %{observation | "complete" => false}, + %{observation | "head_sha" => "abc"}, + %{observation | "files" => nil}, + %{observation | "labels" => nil}, + %{observation | "changed_file_count" => 99} + ] do + refute Sensor.valid_observation?(changed) + context = Sensor.to_context(changed, "P3", [Evidence.attestation(observation)]) + assert Hypatia.MergeOrchestration.Strategist.decide(context).safety == :flag + end + + for confidence <- [nil, "1.0", 2.0, -1.0] do + context = + Sensor.to_context(observation, "P2", [ + Evidence.attestation(observation, %{"confidence" => confidence}) + ]) + + assert context.attestations == [] + end + end end diff --git a/test/merge_orchestration/strategist_test.exs b/test/merge_orchestration/strategist_test.exs index 8fa378f3..2ac964e1 100644 --- a/test/merge_orchestration/strategist_test.exs +++ b/test/merge_orchestration/strategist_test.exs @@ -11,13 +11,43 @@ defmodule Hypatia.MergeOrchestration.StrategistTest do change_class: :chore, change_level: :object, pool: :p2, + # Explicit policy-model assumption; this is not authenticated runtime evidence. + execution_cap: :arm_auto, attestations: [%{bot: "ci", verdict: :approve, confidence: 0.99}] }, overrides ) end - test "green chore in P2 -> squash + arm_auto, unclamped" do + test "missing or invalid execution caps cannot promote high confidence into an arm" do + for cap <- [:missing, nil, :unknown, "arm_auto"] do + context = + case cap do + :missing -> Map.delete(ctx(%{}), :execution_cap) + value -> ctx(%{execution_cap: value}) + end + + decision = Strategist.decide(context) + assert decision.confidence == 0.99 + assert decision.safety == :review + assert decision.clamped_by == "runtime-execution-cap" + end + end + + test "the default review cap cannot weaken veto, meta or pool restrictions" do + for overrides <- [ + %{pool: :p0}, + %{change_level: :meta}, + %{attestations: [%{bot: "ci", verdict: :veto, rationale: "hold"}]} + ] do + decision = overrides |> ctx() |> Map.delete(:execution_cap) |> Strategist.decide() + assert decision.safety == :flag + end + + assert Strategist.decide(ctx(%{execution_cap: :flag})).safety == :flag + end + + test "green chore in P2 with an explicit model cap -> squash + arm_auto, unclamped" do d = Strategist.decide(ctx(%{})) assert d.method == :squash assert d.safety == :arm_auto diff --git a/test/merge_orchestration/ticker_test.exs b/test/merge_orchestration/ticker_test.exs index d3beb86d..f86fe68d 100644 --- a/test/merge_orchestration/ticker_test.exs +++ b/test/merge_orchestration/ticker_test.exs @@ -9,38 +9,7 @@ defmodule Hypatia.MergeOrchestration.TickerTest do defp enc, do: fn t -> t |> :erlang.term_to_binary() |> Base.encode64() end defp dec, do: fn b -> b |> Base.decode64!() |> :erlang.binary_to_term() end - defp seed_store do - store = Path.join(System.tmp_dir!(), "tick-#{System.unique_integer([:positive])}") - - for sub <- ~w(observations pools attestations leases), - do: File.mkdir_p!(Path.join(store, sub)) - - e = enc() - - File.write!( - Path.join([store, "observations", "hyperpolymath__a__1.json"]), - e.(%{ - "repo" => "hyperpolymath/a", - "number" => 1, - "branch" => "dependabot/cargo/x", - "files" => ["Cargo.lock"] - }) - ) - - File.write!(Path.join([store, "pools", "hyperpolymath__a.json"]), e.(%{"pool" => "P2"})) - - File.write!( - Path.join([store, "attestations", "ci.json"]), - e.(%{ - "subject" => %{"repo" => "hyperpolymath/a", "number" => 1}, - "bot" => "ci", - "verdict" => "approve", - "confidence" => 0.99 - }) - ) - - store - end + defp seed_store, do: Hypatia.MergeOrchestration.TestObservationStore.seed(encode: enc()) defp state(cycle_opts), do: %{ @@ -57,12 +26,31 @@ defmodule Hypatia.MergeOrchestration.TickerTest do s = Ticker.do_cycle(state(store: store, got: nil, now: @now, encode: enc(), decode: dec())) assert s.runs == 1 - assert s.last_stats.armed == 1 + assert s.last_stats.armed == 0 assert s.errors == [] + assert s.last_outcome == :completed + assert s.last_stats.review == 1 assert %DateTime{} = s.last_run File.rm_rf!(store) end + test "a failed later observation cannot expose prior successful stats as current" do + store = seed_store() + + first = + Ticker.do_cycle(state(store: store, got: nil, now: @now, encode: enc(), decode: dec())) + + assert first.last_outcome == :completed + File.rm!(Hypatia.MergeOrchestration.TestObservationStore.receipt_path(store)) + failed = Ticker.do_cycle(first) + assert failed.last_outcome == :failed + assert failed.last_stats == nil + assert failed.runs == first.runs + assert [message | _] = failed.errors + assert message =~ "unqualified observation batch" + File.rm_rf!(store) + end + test "do_cycle catches a cycle failure and records it without raising (the tick never crashes)" do store = seed_store() # the manifest/lease encoder raises → the cycle blows up mid-run @@ -92,7 +80,7 @@ defmodule Hypatia.MergeOrchestration.TickerTest do status = Ticker.status(pid) assert status.runs == 1 - assert status.last_stats.armed == 1 + assert status.last_stats.armed == 0 GenServer.stop(pid) File.rm_rf!(store) diff --git a/test/neural_source_store_lifecycle_test.exs b/test/neural_source_store_lifecycle_test.exs new file mode 100644 index 00000000..5ad998c8 --- /dev/null +++ b/test/neural_source_store_lifecycle_test.exs @@ -0,0 +1,817 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# Synthetic lifecycle controls: isolated real processes, no dispatch or actuator. +defmodule Hypatia.Neural.SourceStoreLifecycleTest do + use ExUnit.Case, async: false + + alias Hypatia.{FleetDispatcher, LearningScheduler, OutcomeTracker} + alias Hypatia.Diagnostics.Monitor + alias Hypatia.Kin.Gate + alias Hypatia.Watcher.AnomalyDetector + + alias Hypatia.Neural.{ + Blackboard, + Coordinator, + EchoStateNetwork, + GraphOfTrust, + LiquidStateMachine, + Persistence, + SourceStore, + SequenceModel, + TrainingPipeline + } + + @finding %{ + "id" => "recipe-source-fixture", + "recipe_id" => "recipe-source-fixture", + "category" => "CommandInjection", + "severity" => "High", + "language" => "shell" + } + + setup do + original = Application.fetch_env!(:hypatia, :verisimdb_data_path) + assert String.contains?(Path.expand(original), "/_build/test/verisim-data") + root = Path.join(System.tmp_dir!(), "hypatia-model-source-#{System.unique_integer([:positive])}") + first = Path.join(root, "first") + second = Path.join(root, "second") + + for store <- [first, second], part <- ["outcomes", "recipes", "patterns", "scans"] do + File.mkdir_p!(Path.join(store, part)) + end + + # Both timers can call the application-owned Coordinator. Suspend them + # before any global-root override, and restore the root before resuming. + for module <- [LearningScheduler, AnomalyDetector] do + pid = Process.whereis(module) + assert is_pid(pid) + :ok = :sys.suspend(pid) + + on_exit(fn -> + Application.put_env(:hypatia, :verisimdb_data_path, original) + if Process.alive?(pid), do: :sys.resume(pid) + end) + end + + Application.put_env(:hypatia, :verisimdb_data_path, first) + + on_exit(fn -> + Application.put_env(:hypatia, :verisimdb_data_path, original) + File.rm_rf!(root) + end) + + %{root: root, first: first, second: second, original: original} + end + + test "source identity is an expanded pathname and never a content attestation", ctx do + Application.put_env(:hypatia, :verisimdb_data_path, Path.join(ctx.first, ".")) + assert SourceStore.current() == ctx.first + assert SourceStore.check(SourceStore.fields(), ctx.first) == :ok + assert {:error, :unbound_source_store} = SourceStore.check(%{}, ctx.first) + refute SourceStore.snapshot_matches?(%{"source_store" => ctx.first}, ctx.first) + assert {:error, :unbound_source_store} = Persistence.save_all(nil) + refute File.exists?(Path.join(ctx.first, "neural-states")) + end + + test "an in-flight root change refuses the updated model and latches the original source", ctx do + state = Map.merge(SourceStore.fields(), %{model: :original}) + + assert {:error, {:source_store_changed, first, second}, blocked} = + SourceStore.transact(state, ctx.first, fn -> + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + {:ok, %{state | model: :uncommitted}} + end) + + assert {first, second} == {ctx.first, ctx.second} + assert blocked.model == :original + assert blocked.source_store == ctx.first + assert blocked.source_status == :blocked + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.first) + assert {:error, {:source_lifecycle_blocked, _}} = SourceStore.check(blocked, ctx.first) + assert SourceStore.diagnostic(blocked).restart_required + end + + test "an operation error is a refusal, not a successful transaction" do + state = Map.merge(SourceStore.fields(), %{model: :original}) + + assert {:error, :fixture_refusal, refused} = + SourceStore.transact(state, state.source_store, fn -> + {{:error, :fixture_refusal}, %{state | model: :uncommitted}} + end) + + assert refused.model == :original + assert refused.source_error == :fixture_refusal + end + + test "an actual ready cohort can analyze, train and persist its own source", ctx do + write_training_store(ctx.first) + {server, _cohort} = coordinator() + assert %{source_store: source, source_status: :ready, network_count: 8} = + Coordinator.analyze(@finding, server) + assert source == ctx.first + assert :ok = Coordinator.force_cycle(server) + assert {:ok, %{cycle_count: 1}} = barrier(server, ctx.first) + + state = :sys.get_state(server) + assert state.esn.trained + assert state.rbf.trained + assert state.source_store == ctx.first + + snapshots = Persistence.load_all(ctx.first) + assert map_size(snapshots) == 8 + + for {_name, snapshot} <- snapshots do + assert {:ok, data} = snapshot + assert SourceStore.snapshot_matches?(data, ctx.first) + end + + refute File.exists?(Path.join(ctx.second, "neural-states")) + end + + test "running A cannot answer, learn or persist as empty B and cannot resume by switching back", ctx do + write_training_store(ctx.first) + {server, _cohort} = coordinator() + assert :ok = Coordinator.force_cycle(server) + assert {:ok, %{cycle_count: 1}} = barrier(server, ctx.first) + before = :sys.get_state(server) + old_files = snapshot_bytes(ctx.first) + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + + assert {:error, _} = Coordinator.analyze(@finding, server) + assert {:error, _} = Coordinator.dispatch_recommendation(@finding, server) + assert {:error, _} = Coordinator.force_cycle(server) + # Submission cannot certify a busy lifecycle; the following status barrier + # observes the actual execution refusal before checking model preservation. + assert :ok = Coordinator.record_outcome(@finding, "success", server) + assert {:error, {:neural_source_unavailable, details}} = Coordinator.status(server) + assert details.source_store == ctx.first + assert details.current_store == ctx.second + assert details.restart_required + assert {:error, _} = Coordinator.health_report(server) + assert {:error, _} = Persistence.save_all(:sys.get_state(server)) + + after_refusal = :sys.get_state(server) + assert after_refusal.rbf == before.rbf + assert after_refusal.esn == before.esn + assert after_refusal.moe == before.moe + assert after_refusal.cycle_count == before.cycle_count + assert snapshot_bytes(ctx.first) == old_files + refute File.exists?(Path.join(ctx.second, "neural-states")) + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.first) + assert {:error, _} = Coordinator.force_cycle(server) + assert :sys.get_state(server).source_status == :blocked + end + + test "fresh B refuses A peers while a fresh B cohort remains cold without B inputs", ctx do + write_training_store(ctx.first) + {first_server, first_cohort} = coordinator() + assert :ok = Coordinator.force_cycle(first_server) + assert {:ok, %{cycle_count: 1}} = barrier(first_server, ctx.first) + first_files = snapshot_bytes(ctx.first) + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + mismatched = start_coordinator(first_cohort) + assert {:error, _} = Coordinator.force_cycle(mismatched) + assert :sys.get_state(mismatched).source_status == :blocked + refute File.exists?(Path.join(ctx.second, "neural-states")) + + {second_server, _cohort} = coordinator() + assert %{source_status: :ready, rbf: %{trained: false}, esn: %{trained: false}} = + Coordinator.health_report(second_server) + assert :ok = Coordinator.force_cycle(second_server) + assert {:ok, %{cycle_count: 1}} = barrier(second_server, ctx.second) + second_state = :sys.get_state(second_server) + refute second_state.rbf.trained + refute second_state.esn.trained + assert second_state.source_store == ctx.second + assert snapshot_bytes(ctx.first) == first_files + assert {:ok, persisted} = Persistence.load_all(ctx.second).rbf + assert SourceStore.snapshot_matches?(persisted, ctx.second) + refute persisted["trained"] + end + + test "queued admission and execution use the same captured source", ctx do + cohort = isolated_cohort() + assert {:ok, state} = Coordinator.init(cohort) + assert {:reply, :ok, admitted} = + Coordinator.handle_call({:queue, ctx.first, :force_cycle}, {self(), make_ref()}, state) + assert_receive {:source_bound, source, :force_cycle} = queued + assert source == ctx.first + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + assert {:noreply, refused} = Coordinator.handle_info(queued, admitted) + assert refused.cycle_count == 0 + assert refused.source_store == ctx.first + assert refused.source_status == :blocked + refute File.exists?(Path.join(ctx.first, "neural-states")) + refute File.exists?(Path.join(ctx.second, "neural-states")) + end + + test "a stale A request cannot be admitted by a fresh B lifecycle", ctx do + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + {server, _cohort} = coordinator() + assert {:error, {:request_source_mismatch, first, second}} = + GenServer.call(server, {:queue, ctx.first, :force_cycle}) + assert {first, second} == {ctx.first, ctx.second} + assert {:ok, %{cycle_count: 0, source_status: :ready}} = Coordinator.status(server) + refute File.exists?(Path.join(ctx.second, "neural-states")) + end + + test "named models refuse unbound and foreign restoration and expose a blocked lifecycle", ctx do + cohort = isolated_cohort() + + for {_name, server} <- Keyword.fetch!(cohort, :named_networks) do + assert {:ok, own} = GenServer.call(server, {:snapshot, ctx.first}) + assert :ok = GenServer.call(server, {:restore, ctx.first, own}) + assert {:error, :unbound_or_foreign_snapshot} = + GenServer.call(server, {:restore, ctx.first, Map.delete(own, "source_schema")}) + assert {:error, :unbound_or_foreign_snapshot} = + GenServer.call(server, {:restore, ctx.first, Map.put(own, "source_store", ctx.second)}) + end + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + + for {_name, server} <- Keyword.fetch!(cohort, :named_networks) do + diagnostic = GenServer.call(server, :get_state) + assert diagnostic["source_store"] == ctx.first + assert diagnostic["source_status"] == "blocked" + end + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.first) + + for {_name, server} <- Keyword.fetch!(cohort, :named_networks) do + assert {:error, {:source_lifecycle_blocked, _}} = + GenServer.call(server, {:snapshot, ctx.first}) + end + end + + test "a temporary peer outage refuses the operation but permits a matching retry", ctx do + name = __MODULE__.RecoverableSequence + sequence = named_sequence(name) + cohort = isolated_cohort(%{sequence: name}) + server = start_coordinator(cohort) + before = :sys.get_state(server) + GenServer.stop(sequence) + + assert {:error, {:cohort_source_mismatch, :sequence, {:process_unavailable, _}}} = + Persistence.save_all(before) + refute File.exists?(Path.join(ctx.first, "neural-states")) + + assert {:error, {:neural_source_unavailable, details}} = Coordinator.status(server) + assert details.source_status == :ready + refute details.restart_required + refused = :sys.get_state(server) + assert refused.moe == before.moe + assert refused.esn == before.esn + assert refused.rbf == before.rbf + + named_sequence(name) + assert {:ok, %{source_status: :ready}} = Coordinator.status(server) + assert :sys.get_state(server).source_error == nil + assert :ok = Persistence.save_all(:sys.get_state(server)) + assert map_size(snapshot_bytes(ctx.first)) == 8 + refute File.exists?(Path.join(ctx.second, "neural-states")) + end + + test "an unavailable peer during initialization stays blocked after the peer returns", ctx do + name = __MODULE__.RecoverableSequence + sequence = named_sequence(name) + cohort = isolated_cohort(%{sequence: name}) + GenServer.stop(sequence) + server = start_coordinator(cohort) + + assert %{source_status: :blocked, source_error: {:source_initialization_incomplete, _}} = + :sys.get_state(server) + + named_sequence(name) + networks = Keyword.fetch!(cohort, :named_networks) + assert {:ok, _} = Persistence.collect_named(ctx.first, networks) + assert {:error, _} = Coordinator.status(server) + assert {:error, _} = Coordinator.force_cycle(server) + assert :sys.get_state(server).source_status == :blocked + refute File.exists?(Path.join(ctx.first, "neural-states")) + + fresh = start_coordinator(cohort) + assert {:ok, %{source_status: :ready}} = Coordinator.status(fresh) + end + + test "partial peer hydration cannot become ready after a generic restore failure", ctx do + cohort = isolated_cohort() + networks = Keyword.fetch!(cohort, :named_networks) + assert {:ok, gnn} = GenServer.call(networks.gnn, {:snapshot, ctx.first}) + assert {:ok, vae} = GenServer.call(networks.vae, {:snapshot, ctx.first}) + embeddings = %{"fixture-agent" => [0.25]} + write_json(Path.join([ctx.first, "neural-states", "gnn.json"]), + Map.put(gnn, "node_embeddings", embeddings)) + write_json(Path.join([ctx.first, "neural-states", "vae.json"]), + Map.put(vae, "cluster_labels", 42)) + before_files = snapshot_bytes(ctx.first) + server = start_coordinator(cohort) + + assert %{source_status: :blocked, + source_error: {:source_initialization_incomplete, {:neural_operation_failed, _}}} = + :sys.get_state(server) + assert {:ok, partially_restored} = GenServer.call(networks.gnn, {:snapshot, ctx.first}) + assert partially_restored["node_embeddings"] == embeddings + assert {:ok, _} = Persistence.collect_named(ctx.first, networks) + assert {:error, _} = Coordinator.analyze(@finding, server) + assert {:error, _} = Coordinator.status(server) + assert snapshot_bytes(ctx.first) == before_files + refute File.exists?(Path.join(ctx.second, "neural-states")) + end + + test "encoding failure cannot leave a partially collected bundle reported as saved", ctx do + {server, _cohort} = coordinator() + state = :sys.get_state(server) + bad = %{state | rbf: %{state.rbf | training_stats: %{unencodable: fn -> :fixture end}}} + assert {:error, _} = Persistence.save_all(bad) + refute File.exists?(Path.join(ctx.first, "neural-states")) + end + + test "a matching saved RBF is restorable but legacy and foreign bytes remain diagnostic", ctx do + write_training_store(ctx.first) + {server, _cohort} = coordinator() + assert :ok = Coordinator.force_cycle(server) + assert {:ok, %{cycle_count: 1}} = barrier(server, ctx.first) + assert {:ok, bound} = Persistence.load_all(ctx.first).rbf + assert bound["trained"] + + {restored, _cohort} = coordinator() + assert :sys.get_state(restored).rbf.trained + assert :sys.get_state(restored).rbf.output_weights == bound["output_weights"] + + for diagnostic <- [ + Map.drop(bound, ["source_schema", "source_store", "source_status"]), + Map.put(bound, "source_store", ctx.second), + Map.put(bound, "source_schema", 999) + ] do + path = Path.join([ctx.first, "neural-states", "rbf.json"]) + write_json(path, diagnostic) + bytes = File.read!(path) + {cold, _cohort} = coordinator() + refute :sys.get_state(cold).rbf.trained + assert {:ok, ^diagnostic} = Persistence.load_all(ctx.first).rbf + assert File.read!(path) == bytes + end + end + + test "an old A task cannot write into a new B blackboard", ctx do + first_board = Keyword.fetch!(isolated_cohort(), :blackboard) + assert {:ok, _} = Blackboard.start_round_source(ctx.first, first_board) + assert :ok = Blackboard.write_source(ctx.first, :fixture, :value, :from_a, first_board) + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + second_board = Keyword.fetch!(isolated_cohort(), :blackboard) + assert {:ok, _} = Blackboard.start_round_source(ctx.second, second_board) + assert {:error, {:request_source_mismatch, _, _}} = + Blackboard.write_source(ctx.first, :fixture, :value, :from_a, second_board) + assert :not_found = Blackboard.read_source(ctx.second, :fixture, :value, second_board) + assert {:ok, %{entry_count: 0}} = Blackboard.snapshot_source(ctx.second, second_board) + assert {:error, {:source_store_changed, _, _}} = + Blackboard.write_source(ctx.first, :fixture, :value, :late, first_board) + end + + test "raw unbound blackboard entries cannot enter a source-qualified trace", ctx do + board = Keyword.fetch!(isolated_cohort(), :blackboard) + assert {:ok, round} = Blackboard.start_round_source(ctx.first, board) + table = :sys.get_state(board).table + legacy = %{network: :fixture, key: :legacy, value: :unbound, round: round} + :ets.insert(table, {{:fixture, :legacy}, legacy}) + + assert {:error, :foreign_blackboard_entry} = + Blackboard.read_source(ctx.first, :fixture, :legacy, board) + assert {:error, :foreign_blackboard_entry} = Blackboard.snapshot_source(ctx.first, board) + assert :sys.get_state(board).source_error == :foreign_blackboard_entry + + assert {:ok, _} = Blackboard.start_round_source(ctx.first, board) + assert :ok = Blackboard.write_source(ctx.first, :fixture, :bound, :value, board) + assert {:ok, %{entry_count: 1}} = Blackboard.snapshot_source(ctx.first, board) + end + + test "captured training, trust language context and strategy reads cannot switch to B", ctx do + write_training_store(ctx.first) + write_json(Path.join([ctx.first, "scans", "fixture-repo.json"]), %{"primary_language" => "rust"}) + write_json(Path.join([ctx.second, "scans", "fixture-repo.json"]), %{"primary_language" => "elixir"}) + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + + assert TrainingPipeline.build_esn_training_data() == [] + assert [%{count: 71}] = TrainingPipeline.build_esn_training_data(ctx.first) + assert TrainingPipeline.build_rbf_training_data() == {[], []} + assert {vectors, targets} = TrainingPipeline.build_rbf_training_data(ctx.first) + assert length(vectors) == 5 + assert targets == List.duplicate(1.0, 5) + assert GraphOfTrust.build().nodes == %{} + pinned = GraphOfTrust.build_from_store(ctx.first) + assert pinned.language_clusters[:systems] == ["fixture-repo"] + assert GraphOfTrust.trust_score(pinned, "recipe-source-fixture") > 0.5 + assert OutcomeTracker.annealed_strategy_from_store("recipe-source-fixture", :auto_execute, ctx.first) == + :auto_execute + assert OutcomeTracker.annealed_strategy_from_store("recipe-source-fixture", :auto_execute, ctx.second) == + :report_only + refute File.exists?(Path.join(ctx.first, "annealing-states")) + refute File.exists?(Path.join(ctx.second, "annealing-states")) + end + + test "Kin requires a ready source assessment and preserves manual-review tiers", ctx do + action = %{pattern_id: "recipe-source-fixture", dispatch_tier: :auto_execute, confidence: 0.99} + ready = %{ + source_store: ctx.first, + source_status: :ready, + confidence: 0.99, + strategy: :auto_execute, + is_novel: false + } + assert :pass = Gate.neural_consensus(action, fn _ -> ready end) + + for result <- [ + {:error, :blocked}, + :unavailable, + %{confidence: 0.99}, + %{ready | source_store: ctx.second}, + %{ready | source_status: :blocked}, + %{ready | confidence: 0.5}, + %{ready | confidence: -0.01}, + %{ready | confidence: 1.01}, + %{ready | confidence: "0.99"}, + %{ready | strategy: :review}, + %{ready | strategy: :report_only}, + %{ready | strategy: "auto_execute"}, + %{ready | strategy: nil}, + Map.delete(ready, :strategy), + %{ready | is_novel: true}, + %{ready | is_novel: "false"}, + %{ready | is_novel: nil}, + Map.delete(ready, :is_novel) + ] do + assert {:hold, _} = Gate.neural_consensus(action, fn _ -> result end) + end + + assert {:hold, _} = Gate.neural_consensus(action, fn _ -> raise "fixture failure" end) + assert {:hold, _} = Gate.neural_consensus(action, fn _ -> exit(:fixture_unavailable) end) + + for tier <- [:review, :report_only] do + assert :pass = + Gate.neural_consensus(%{action | dispatch_tier: tier}, fn _ -> + flunk("manual tier must not invoke neural prediction") + end) + end + end + + test "diagnostics and watcher refuse blocked, unbound and old-source reports", ctx do + {server, _cohort} = coordinator() + assert {:ok, status} = Coordinator.status(server) + assert {:ok, {:neural, ^status}} = Monitor.classify_neural_status({:ok, status}) + report = Coordinator.health_report(server) + signal = %{report | esn: %{trained: true, drift: :rising_drift}} + assert {:rising_drift, true} = AnomalyDetector.source_esn_signal(signal) + + for result <- [ + {:error, :blocked}, + Map.delete(signal, :source_store), + %{signal | source_status: :blocked}, + %{signal | source_store: ctx.second}, + %{signal | esn: %{trained: false, drift: :rising_drift}} + ] do + assert {:unavailable, false} = AnomalyDetector.source_esn_signal(result) + end + + for result <- [ + nil, + {:error, :blocked}, + {:ok, Map.delete(status, :source_store)}, + {:ok, %{status | source_status: :blocked}}, + {:ok, %{status | source_store: ctx.second}} + ] do + assert {:error, {:neural_source_unavailable, _}} = Monitor.classify_neural_status(result) + end + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + assert {:error, _} = Coordinator.health_report(server) + assert {:error, _} = Monitor.classify_neural_status(Coordinator.status(server)) + assert Process.alive?(server) + assert :sys.get_state(server).source_status == :blocked + end + + test "diagnostics retains actual refusal tuples even when notification fails" do + reason = {:neural_source_unavailable, %{source_status: :blocked}} + components = [{:error, reason}, {:ok, {:fixture, %{dropped: 0, dispatched: 1}}}] + state = %{components: [], failures: [], last_check: nil, recovery_attempts: %{}} + observed = Monitor.record_observations(state, components) + assert observed.components == components + assert observed.failures == [{:failure, reason}] + assert %DateTime{} = observed.last_check + assert Monitor.source_lifecycle_failure?(reason) + refute Monitor.source_lifecycle_failure?(:pipeline_crashed) + + assert Monitor.detect_failures([ + {:error, :pipeline_crashed}, + {:ok, {:fixture, %{dropped: 2, dispatched: 2}}}, + {:ok, {:fixture, %{buffered: 10, max_buffer: 10}}} + ]) == [ + {:failure, :pipeline_crashed}, + {:failure, :high_error_rate}, + {:failure, :buffer_overflow} + ] + + for notifier <- [ + fn _ -> raise "fixture notification failure" end, + fn _ -> exit(:fixture_notification_exit) end + ] do + assert Monitor.finish_observations(observed, notifier, fn _, _ -> + flunk("notification failure must not trigger recovery") + end) == observed + end + + observer = self() + notify = fn failures -> send(observer, {:fixture_notification, failures}) end + + recover = fn failures, snapshot -> + send(observer, {:fixture_recovery_pass, failures}) + snapshot + end + + assert Monitor.finish_observations(observed, notify, recover) == observed + assert_receive {:fixture_notification, [{:failure, ^reason}]} + refute_received {:fixture_notification, _} + refute_received {:fixture_recovery_pass, _} + assert observed.recovery_attempts == %{} + + mixed = Monitor.record_observations(state, components ++ [{:error, :pipeline_crashed}]) + assert Monitor.finish_observations(mixed, notify, recover) == mixed + + assert_receive {:fixture_notification, + [{:failure, ^reason}, {:failure, :pipeline_crashed}]} + + assert_receive {:fixture_recovery_pass, [{:failure, :pipeline_crashed}]} + refute_received {:fixture_notification, _} + refute_received {:fixture_recovery_pass, _} + end + + test "a busy or unavailable actual coordinator is not reported as healthy", ctx do + {server, _cohort} = coordinator() + assert {:ok, {:neural, _}} = Monitor.check_neural(server) + :ok = :sys.suspend(server) + + try do + assert {:error, {:neural_source_unavailable, :busy_or_unverified}} = + Monitor.check_neural(server, 1) + after + :sys.resume(server) + end + + assert Process.alive?(server) + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + assert {:error, {:neural_source_unavailable, _}} = Monitor.check_neural(server) + GenServer.stop(server) + assert {:error, :neural_crashed} = Monitor.check_neural(server) + assert {:error, :neural_crashed} = Monitor.check_neural({:invalid_server, :fixture}) + end + + test "outcomes submit without waiting for a busy Coordinator and execute once", ctx do + {server, cohort} = coordinator() + before = :sys.get_state(server) + + assert {:queued, {:ok, %{source_status: :ready}}} = + submit_while_suspended(server, ctx.first, fn -> :ok end) + + after_event = :sys.get_state(server) + refute after_event.moe == before.moe + assert length(after_event.lsm.history) == length(before.lsm.history) + 1 + sequence = Keyword.fetch!(cohort, :named_networks).sequence + assert {:ok, snapshot} = GenServer.call(sequence, {:snapshot, ctx.first}) + assert [%{finding: @finding, outcome: "failure", confidence: confidence}] = + snapshot["sequence_buffer"] + assert is_number(confidence) + refute File.exists?(Path.join(ctx.first, "neural-states")) + end + + test "a submitted outcome rechecks its captured source before learning", ctx do + {server, cohort} = coordinator() + before = :sys.get_state(server) + sequence = Keyword.fetch!(cohort, :named_networks).sequence + sequence_before = :sys.get_state(sequence) + + assert {:queued, {:error, {:neural_source_unavailable, _}}} = + submit_while_suspended(server, ctx.first, fn -> + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + end) + + refused = :sys.get_state(server) + assert refused.source_status == :blocked + assert refused.source_store == ctx.first + assert refused.moe == before.moe + assert refused.lsm == before.lsm + assert refused.esn == before.esn + assert :sys.get_state(sequence) == sequence_before + refute File.exists?(Path.join(ctx.first, "neural-states")) + refute File.exists?(Path.join(ctx.second, "neural-states")) + end + + test "Kin's default predictor holds before its outer review deadline", ctx do + # Exercise the actual default named-server route, with the application's + # original source restored so its queued call cannot observe a test root. + Application.put_env(:hypatia, :verisimdb_data_path, ctx.original) + server = Process.whereis(Coordinator) + assert is_pid(server) + :ok = :sys.suspend(server) + action = %{pattern_id: "recipe-source-fixture", dispatch_tier: :auto_execute, confidence: 0.99} + task = Task.async(fn -> Gate.neural_consensus(action) end) + + result = + try do + Task.yield(task, 7_000) + after + if Process.alive?(task.pid), do: Task.shutdown(task, :brutal_kill) + :sys.resume(server) + # Drain the ordinary dispatch request while the original root is + # still selected; a system-state query would bypass this mailbox. + assert {:ok, %{source_status: :ready}} = barrier(server, ctx.original) + end + + assert {:ok, {:hold, reason}} = result + assert reason =~ "assessment unavailable" + end + + test "the supported recommendation API honors an explicit shorter wait", ctx do + {server, _cohort} = coordinator() + :ok = :sys.suspend(server) + + try do + assert {:timeout, {GenServer, :call, _}} = + catch_exit(Coordinator.dispatch_recommendation(@finding, server, 1)) + after + :sys.resume(server) + end + + assert {:ok, %{source_status: :ready}} = barrier(server, ctx.first) + end + + test "Scheduler admission and asynchronous outcomes retain execution refusals", ctx do + {server, _cohort} = coordinator() + assert LearningScheduler.trigger_neural_cycle(server) + assert {:ok, %{cycle_count: 1}} = barrier(server, ctx.first) + assert :queued = FleetDispatcher.record_neural_outcome(@finding, "failure", server) + assert {:ok, _} = barrier(server, ctx.first) + before = :sys.get_state(server) + + Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) + refute LearningScheduler.trigger_neural_cycle(server) + assert :queued = FleetDispatcher.record_neural_outcome(@finding, "success", server) + assert {:error, _} = barrier(server, ctx.second) + assert :sys.get_state(server).moe == before.moe + assert :sys.get_state(server).cycle_count == before.cycle_count + refute File.exists?(Path.join(ctx.second, "neural-states")) + + missing = spawn(fn -> :ok end) + monitor = Process.monitor(missing) + assert_receive {:DOWN, ^monitor, :process, ^missing, _} + refute LearningScheduler.trigger_neural_cycle(missing) + assert {:error, :neural_unavailable} = + FleetDispatcher.record_neural_outcome(@finding, "failure", missing) + end + + test "trust rejects missing, blank and nonstring subjects without partial positive edges" do + valid = qualified_row() + recipes = [%{"id" => "recipe-source-fixture"}] + + invalid = + for field <- ["repo", "bot", "recipe_id"], + value <- [nil, false, "", " \t\n", 42, [], %{}] do + Map.put(valid, field, value) + end + + invalid = invalid ++ Enum.map(["repo", "bot", "recipe_id"], &Map.delete(valid, &1)) + + for row <- invalid do + assert OutcomeTracker.qualified_outcome?(row) + graph = GraphOfTrust.build([row], recipes) + assert graph.edges == [] + assert graph.cross_repo_edges == [] + assert graph.nodes == %{"recipe-source-fixture" => :recipe} + assert graph.trust_scores == %{"recipe-source-fixture" => 0.5} + end + + graph = GraphOfTrust.build(invalid ++ [valid], recipes) + assert length(graph.edges) == 3 + assert GraphOfTrust.trust_score(graph, "recipe-source-fixture") > 0.5 + refute Map.has_key?(graph.nodes, "unknown") + assert valid == qualified_row() + end + + test "canonical pattern is an explicit identity but cannot mask a malformed recipe_id" do + canonical = qualified_row() |> Map.delete("recipe_id") |> Map.put("pattern", "recipe-source-fixture") + recipes = [%{"id" => "recipe-source-fixture"}] + assert length(GraphOfTrust.build([canonical], recipes).edges) == 3 + + for bad <- ["", " ", 42, [], %{}] do + graph = GraphOfTrust.build([Map.put(canonical, "recipe_id", bad)], recipes) + assert graph.edges == [] + assert graph.trust_scores == %{"recipe-source-fixture" => 0.5} + end + end + + defp isolated_cohort(overrides \\ %{}) do + networks = + Map.new(Persistence.default_networks(), fn {name, module} -> + {name, Map.get_lazy(overrides, name, fn -> isolated_process(module, []) end)} + end) + + [named_networks: networks, blackboard: isolated_process(Blackboard, [isolated: true])] + end + + defp named_sequence(name) do + start_supervised!(%{ + id: make_ref(), + start: {GenServer, :start_link, [SequenceModel, [], [name: name]]}, + restart: :temporary + }) + end + + defp submit_while_suspended(server, source, after_submission) do + :ok = :sys.suspend(server) + observer = self() + reference = make_ref() + + task = + Task.async(fn -> + submitted = FleetDispatcher.record_neural_outcome(@finding, "failure", server) + send(observer, {reference, submitted}) + # Same sender as the submitted event: this reply is an execution + # barrier, not merely a read from another process that could race it. + {submitted, barrier(server, source)} + end) + + try do + try do + assert_receive {^reference, :queued}, 2_000 + after_submission.() + after + :sys.resume(server) + end + + Task.await(task, 30_000) + after + if Process.alive?(task.pid), do: Task.shutdown(task, :brutal_kill) + end + end + + defp coordinator do + cohort = isolated_cohort() + {start_coordinator(cohort), cohort} + end + + defp start_coordinator(cohort) do + server = isolated_process(Coordinator, cohort) + + # Small real reservoirs bound test cost; model training still executes. + :sys.replace_state(server, fn state -> + %{state | + esn: EchoStateNetwork.init(reservoir_size: 4, seed: 17), + lsm: LiquidStateMachine.init(reservoir_size: 4, seed: 17)} + end) + + server + end + + defp isolated_process(module, opts) do + start_supervised!(%{id: make_ref(), start: {GenServer, :start_link, [module, opts, []]}}) + end + + defp barrier(server, source), do: GenServer.call(server, {:status, source}, 30_000) + + defp qualified_row do + %{ + "recipe_id" => "recipe-source-fixture", + "repo" => "fixture-repo", + "bot" => "fixture-bot", + "outcome" => "success", + "verification" => "verified", + "timestamp" => "2026-11-04T00:00:00Z" + } + end + + defp write_training_store(root) do + write_json(Path.join([root, "recipes", "fixture.json"]), %{"id" => "recipe-source-fixture"}) + rows = List.duplicate(qualified_row(), 71) + File.write!(Path.join([root, "outcomes", "fixture.jsonl"]), Enum.map_join(rows, "", &(Jason.encode!(&1) <> "\n"))) + patterns = + for index <- 1..5 do + %{"id" => "pattern-#{index}", "severity" => "high", "category" => "CommandInjection", + "triangle_tier" => "eliminate", "frequency" => index / 5, "age_days" => index} + end + write_json(Path.join([root, "patterns", "registry.json"]), %{"patterns" => patterns}) + end + + defp write_json(path, data) do + File.mkdir_p!(Path.dirname(path)) + File.write!(path, Jason.encode!(data) <> "\n") + end + + defp snapshot_bytes(root) do + Path.join([root, "neural-states", "*.json"]) + |> Path.wildcard() + |> Map.new(fn path -> {Path.basename(path), File.read!(path)} end) + end +end diff --git a/test/neural_trust_restart_test.exs b/test/neural_trust_restart_test.exs new file mode 100644 index 00000000..401a9bf6 --- /dev/null +++ b/test/neural_trust_restart_test.exs @@ -0,0 +1,137 @@ +# SPDX-License-Identifier: MPL-2.0 +# Synthetic source and snapshot controls; no production corpus is modified. +defmodule Hypatia.Neural.TrustRestartTest do + use ExUnit.Case, async: false + + alias Hypatia.Neural.{Coordinator, GraphOfTrust, Persistence} + + setup do + root = Application.fetch_env!(:hypatia, :verisimdb_data_path) |> Path.expand() + # The standard test configuration is explicitly a disposable build store. + assert String.contains?(root, "/_build/test/verisim-data") + recipe = Path.join([root, "recipes", "restart-qualification-fixture.json"]) + snapshot = Path.join([root, "neural-states", "trust.json"]) + originals = for path <- [recipe, snapshot], into: %{}, do: {path, File.read(path)} + File.mkdir_p!(Path.dirname(recipe)) + File.mkdir_p!(Path.dirname(snapshot)) + File.write!(recipe, Jason.encode!(%{"id" => "restart-qualification-fixture"})) + + on_exit(fn -> + Application.put_env(:hypatia, :verisimdb_data_path, root) + + for {path, original} <- originals do + case original do + {:ok, bytes} -> File.write!(path, bytes) + {:error, :enoent} -> File.rm(path) + end + end + end) + + %{root: root, snapshot: snapshot} + end + + test "coordinator startup rejects legacy, current-labelled and future trust-score caches", %{ + snapshot: path + } do + for version <- [nil, 2, 999] do + snapshot = %{ + "trust_scores" => %{ + "restart-qualification-fixture" => 0.999, + "legacy-only-poisoned-entry" => 1.0 + }, + "score_semantics" => "qualified_positive_support", + "restorable" => true + } + + snapshot = if version, do: Map.put(snapshot, "schema_version", version), else: snapshot + File.write!(path, Jason.encode!(snapshot)) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert {:ok, state} = Coordinator.init(isolated_cohort()) + assert state.trust_graph.nodes["restart-qualification-fixture"] == :recipe + + assert GraphOfTrust.trust_score(state.trust_graph, "restart-qualification-fixture") == + 0.5 + + refute Map.has_key?(state.trust_graph.trust_scores, "legacy-only-poisoned-entry") + end) + + assert log =~ "persisted trust scores ignored" + refute log =~ "trust scores restored" + end + end + + test "runtime store replacement rebuilds only that source and writes diagnostic versioned snapshots", + %{ + root: root, + snapshot: old_snapshot + } do + File.write!(old_snapshot, Jason.encode!(%{"trust_scores" => %{"old-store-poison" => 1.0}})) + replacement = Path.join(root, "replacement-store") + File.mkdir_p!(Path.join(replacement, "recipes")) + + File.write!( + Path.join([replacement, "recipes", "fresh.json"]), + Jason.encode!(%{"id" => "replacement-only-recipe"}) + ) + + on_exit(fn -> File.rm_rf!(replacement) end) + Application.put_env(:hypatia, :verisimdb_data_path, replacement) + assert {:ok, state} = Coordinator.init(isolated_cohort()) + assert state.trust_graph.nodes == %{"replacement-only-recipe" => :recipe} + assert state.trust_graph.trust_scores == %{"replacement-only-recipe" => 0.5} + assert state.source_status == :ready + assert :ok = Persistence.save_all(state) + new_snapshot = Path.join([replacement, "neural-states", "trust.json"]) + decoded = new_snapshot |> File.read!() |> Jason.decode!() + assert decoded["schema_version"] == 2 + assert decoded["restore_policy"] == "recompute_from_current_qualified_outcomes" + assert decoded["trust_scores"] == %{"replacement-only-recipe" => 0.5} + + assert File.read!(old_snapshot) |> Jason.decode!() == %{ + "trust_scores" => %{"old-store-poison" => 1.0} + } + + # Even an apparently current snapshot can be altered or outlive its + # source. The writer's version is metadata, not restore authority. + File.write!(new_snapshot, Jason.encode!(Map.put(decoded, "trust_scores", %{"poison" => 1.0}))) + assert {:ok, restarted} = Coordinator.init(isolated_cohort()) + assert restarted.trust_graph.trust_scores == state.trust_graph.trust_scores + + # A fresh qualified success must still contribute after restart. Keeping + # every entity permanently neutral is not a valid way to reject caches. + File.mkdir_p!(Path.join(replacement, "outcomes")) + + File.write!( + Path.join([replacement, "outcomes", "synthetic-restart.jsonl"]), + Jason.encode!(%{ + "recipe_id" => "replacement-only-recipe", + "repo" => "replacement-repo", + "bot" => "replacement-bot", + "outcome" => "success", + "verification" => "verified" + }) <> "\n" + ) + + assert {:ok, supported} = Coordinator.init(isolated_cohort()) + assert GraphOfTrust.trust_score(supported.trust_graph, "replacement-only-recipe") > 0.5 + refute Map.has_key?(supported.trust_graph.trust_scores, "poison") + end + + defp isolated_cohort do + networks = + Map.new(Persistence.default_networks(), fn {name, module} -> + pid = start_supervised!(%{id: make_ref(), start: {GenServer, :start_link, [module, [], []]}}) + {name, pid} + end) + + board = + start_supervised!(%{ + id: make_ref(), + start: {GenServer, :start_link, [Hypatia.Neural.Blackboard, [isolated: true], []]} + }) + + [named_networks: networks, blackboard: board] + end +end diff --git a/test/outcome_tracker_runtime_store_test.exs b/test/outcome_tracker_runtime_store_test.exs new file mode 100644 index 00000000..05982b6e --- /dev/null +++ b/test/outcome_tracker_runtime_store_test.exs @@ -0,0 +1,195 @@ +# SPDX-License-Identifier: MPL-2.0 +defmodule Hypatia.OutcomeTrackerRuntimeStoreTest do + use ExUnit.Case, async: false + + alias Hypatia.{LearningScheduler, OutcomeTracker} + + setup do + old_root = Application.fetch_env!(:hypatia, :verisimdb_data_path) |> Path.expand() + assert String.contains?(old_root, "/_build/test/verisim-data") + keys = [:verisimdb_data_path, :annealing_state_path, :fleet_path] + original = Map.new(keys, fn key -> {key, Application.fetch_env(:hypatia, key)} end) + id = "recipe-runtime-boundary-#{System.unique_integer([:positive])}" + root = Path.join(System.tmp_dir!(), id) + for part <- ["recipes", "outcomes"], do: File.mkdir_p!(Path.join(root, part)) + Application.put_env(:hypatia, :verisimdb_data_path, root) + Application.put_env(:hypatia, :annealing_state_path, Path.join(root, "annealing-states")) + Application.put_env(:hypatia, :fleet_path, Path.join(root, "fleet")) + + on_exit(fn -> + for {key, value} <- original do + case value do + {:ok, previous} -> Application.put_env(:hypatia, key, previous) + :error -> Application.delete_env(:hypatia, key) + end + end + + File.rm_rf!(root) + end) + + %{root: root, old_root: old_root, id: id} + end + + defp write_row(root, name, row) do + path = Path.join([root, "outcomes", name <> ".jsonl"]) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, Jason.encode!(row) <> "\n") + path + end + + test "scheduler-discovered outcomes update only the selected runtime recipe", ctx do + recipe_name = ctx.id <> ".json" + current_recipe = Path.join([ctx.root, "recipes", recipe_name]) + previous_recipe = Path.join([ctx.old_root, "recipes", recipe_name]) + previous_log = Path.join([ctx.old_root, "outcomes", ctx.id <> ".jsonl"]) + File.mkdir_p!(Path.dirname(previous_recipe)) + original_recipe = Jason.encode!(%{"id" => ctx.id, "confidence" => 0.2}) <> "\n" + File.write!(previous_recipe, original_recipe) + File.write!(current_recipe, Jason.encode!(%{"id" => ctx.id, "confidence" => 0.8})) + + on_exit(fn -> + File.rm(previous_recipe) + File.rm(previous_log) + end) + + base = %{"recipe_id" => ctx.id, "repo" => "synthetic", "timestamp" => "2026-11-04T00:00:00Z"} + + write_row( + ctx.old_root, + ctx.id, + Map.merge(base, %{"outcome" => "success", "verification" => "verified"}) + ) + + write_row( + ctx.root, + ctx.id, + Map.merge(base, %{"outcome" => "failure", "verification" => "failed"}) + ) + + assert {_, 1, ids} = LearningScheduler.ingest_new_outcomes(%{}) + assert ids == MapSet.new([ctx.id]) + assert {:ok, _confidence} = OutcomeTracker.update_recipe_confidence(ctx.id) + current = current_recipe |> File.read!() |> Jason.decode!() + assert current["successful_fixes"] == 0 + assert current["failed_fixes"] == 1 + assert current["confidence_prior"] == 0.8 + assert File.read!(previous_recipe) == original_recipe + assert File.regular?(Path.join([ctx.root, "annealing-states", ctx.id <> ".json"])) + end + + defp fleet_row(recipe_id) do + %{ + "recipe_id" => recipe_id, + "repo" => "synthetic", + "file" => ".", + "bot" => "fixture-bot", + "outcome" => "failure", + "verdict" => "failed", + "exit_code" => 1 + } + end + + defp write_fleet(fleet_root, content) do + path = Path.join([fleet_root, "shared-context", "learning", "fix-outcomes.jsonl"]) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, content) + path + end + + test "fleet ingestion follows runtime path changes without replay or source echo", ctx do + first_root = Path.join(ctx.root, "fleet-first") + second_root = Path.join(ctx.root, "fleet-second") + first_line = Jason.encode!(fleet_row(ctx.id <> "-first")) + second_line = Jason.encode!(fleet_row(ctx.id <> "-second")) + first_file = write_fleet(first_root, first_line <> "\n") + second_file = write_fleet(second_root, second_line <> "\n") + + Application.put_env(:hypatia, :fleet_path, first_root) + assert {1, first_signature} = LearningScheduler.ingest_fleet_outcomes(nil) + assert is_binary(first_signature) + first_id = LearningScheduler.fleet_source_id(first_line, 1) + assert {:ok, ids} = OutcomeTracker.imported_fleet_ids() + assert ids == MapSet.new([first_id]) + + [outcome_file] = Path.wildcard(Path.join([ctx.root, "outcomes", "*.jsonl"])) + before_replay = File.read!(outcome_file) + assert {0, ^first_signature} = LearningScheduler.ingest_fleet_outcomes(first_signature) + assert File.read!(outcome_file) == before_replay + + Application.put_env(:hypatia, :fleet_path, second_root) + assert {1, second_signature} = LearningScheduler.ingest_fleet_outcomes(first_signature) + assert second_signature != first_signature + second_id = LearningScheduler.fleet_source_id(second_line, 1) + assert {:ok, ids} = OutcomeTracker.imported_fleet_ids() + assert ids == MapSet.new([first_id, second_id]) + assert File.read!(first_file) == first_line <> "\n" + assert File.read!(second_file) == second_line <> "\n" + assert {0, ^second_signature} = LearningScheduler.ingest_fleet_outcomes(second_signature) + + Application.put_env(:hypatia, :fleet_path, Path.join(ctx.root, "fleet-missing")) + before_missing = File.read!(outcome_file) + assert {0, ^second_signature} = LearningScheduler.ingest_fleet_outcomes(second_signature) + assert File.read!(outcome_file) == before_missing + end + + test "runtime fleet ingestion does not credit unverified success or acknowledge a partial tail", ctx do + fleet_root = Path.join(ctx.root, "fleet-partial") + Application.put_env(:hypatia, :fleet_path, fleet_root) + unqualified = Jason.encode!(%{"recipe_id" => ctx.id, "repo" => "synthetic", "outcome" => "success"}) + partial = unqualified <> "\n" <> ~s({"recipe_id":) + fleet_file = write_fleet(fleet_root, partial) + + assert {0, nil} = LearningScheduler.ingest_fleet_outcomes(nil) + assert {:ok, ids} = OutcomeTracker.imported_fleet_ids() + assert ids == MapSet.new() + assert File.read!(fleet_file) == partial + + qualified = Jason.encode!(fleet_row(ctx.id <> "-qualified")) + complete = unqualified <> "\n" <> qualified <> "\n" + File.write!(fleet_file, complete) + assert {1, signature} = LearningScheduler.ingest_fleet_outcomes(nil) + qualified_id = LearningScheduler.fleet_source_id(qualified, 2) + assert {:ok, ids} = OutcomeTracker.imported_fleet_ids() + assert ids == MapSet.new([qualified_id]) + assert File.read!(fleet_file) == complete + assert {0, ^signature} = LearningScheduler.ingest_fleet_outcomes(signature) + end + + test "fleet replay identity lookup follows the same runtime outcome store", ctx do + write_row(ctx.root, ctx.id, %{"fleet_import_id" => ctx.id, "recipe_id" => ctx.id}) + assert {:ok, ids} = OutcomeTracker.imported_fleet_ids() + assert ids == MapSet.new([ctx.id]) + end + + test "recording resolves outcome, fleet and annealing destinations at runtime", ctx do + assert {:ok, _} = + OutcomeTracker.record_outcome(ctx.id, "synthetic", ".", :failure, %{ + "verification" => "failed" + }) + + [outcome] = Path.wildcard(Path.join([ctx.root, "outcomes", "*.jsonl"])) + assert File.read!(outcome) =~ ctx.id + + assert File.read!( + Path.join([ctx.root, "fleet", "shared-context", "learning", "fix-outcomes.jsonl"]) + ) =~ ctx.id + + assert File.regular?(Path.join([ctx.root, "annealing-states", ctx.id <> ".json"])) + end + + test "an absent annealing override follows the selected data store", ctx do + Application.delete_env(:hypatia, :annealing_state_path) + + assert {:ok, _} = + OutcomeTracker.record_fleet_outcome( + ctx.id, + "synthetic", + ".", + :failure, + ctx.id, + "failed" + ) + + assert File.regular?(Path.join([ctx.root, "annealing-states", ctx.id <> ".json"])) + end +end diff --git a/test/support/merge_observation_store.ex b/test/support/merge_observation_store.ex new file mode 100644 index 00000000..9521d697 --- /dev/null +++ b/test/support/merge_observation_store.ex @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: MPL-2.0 +defmodule Hypatia.MergeOrchestration.TestObservationStore do + @moduledoc false + # Synthetic test evidence. These helpers do not claim a valid signature or a + # real GitHub run; even matching approvals must remain review-only at runtime. + @head String.duplicate("a", 40) + @event "test:observation:1" + @at "2026-11-04T00:00:00Z" + + def head, do: @head + def event, do: @event + def at, do: @at + + def observation(overrides \\ %{}) do + files = Map.get(overrides, "files", ["Cargo.lock"]) + + Map.merge( + %{ + "schema_version" => 1, + "complete" => true, + "event_id" => @event, + "observed_at" => @at, + "repo" => "hyperpolymath/a", + "number" => 1, + "head_sha" => @head, + "base_sha" => String.duplicate("b", 40), + "branch" => "dependabot/cargo/x", + "title" => "Bump x from 1 to 2", + "files" => files, + "labels" => [], + "state" => "open", + "changed_file_count" => length(files) + }, + overrides + ) + end + + def attestation(obs, overrides \\ %{}) do + Map.merge( + %{ + "subject" => Map.take(obs, ["repo", "number", "head_sha"]), + "bot" => "ci", + "verdict" => "approve", + "confidence" => 0.99 + }, + overrides + ) + end + + def seed(opts \\ []) do + dir = Path.join(System.tmp_dir!(), "merge-store-#{System.unique_integer([:positive])}") + encode = Keyword.get(opts, :encode, &Jason.encode!/1) + observations = Keyword.get(opts, :observations, [observation()]) + for sub <- ~w(observations pools attestations leases), do: File.mkdir_p!(Path.join(dir, sub)) + + write_batch(dir, observations, encode) + + for {obs, index} <- Enum.with_index(observations) do + pool_file = String.replace(obs["repo"], "/", "__") <> ".json" + File.write!(Path.join([dir, "pools", pool_file]), encode.(%{"pool" => "P2"})) + File.write!(Path.join([dir, "attestations", "#{index}.json"]), encode.(attestation(obs))) + end + + dir + end + + def write_batch(dir, observations, encode \\ &Jason.encode!/1) do + entries = + Enum.map(observations, fn obs -> + name = String.replace(obs["repo"], "/", "__") <> "__#{obs["number"]}.json" + body = encode.(obs) + File.write!(Path.join([dir, "observations", name]), body) + + Map.merge(Map.take(obs, ["repo", "number", "head_sha"]), %{ + "file" => name, + "sha256" => :crypto.hash(:sha256, body) |> Base.encode16(case: :lower) + }) + end) + + repos = + if observations == [], + do: ["hyperpolymath/a"], + else: Enum.uniq(Enum.map(observations, & &1["repo"])) + + receipt = %{ + "schema_version" => 1, + "status" => "complete", + "event_id" => @event, + "observed_at" => @at, + "repos" => repos, + "observations" => entries + } + + File.write!(receipt_path(dir), encode.(receipt)) + receipt + end + + def receipt_path(dir), do: Path.join([dir, "observations", ".observation-batch.json"]) +end diff --git a/test/workflow_hardening_test.exs b/test/workflow_hardening_test.exs index 18e879b4..b245ce41 100644 --- a/test/workflow_hardening_test.exs +++ b/test/workflow_hardening_test.exs @@ -1265,8 +1265,8 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do # Regression floor for the alert-DELETION shape measured 2026-09-14 on # hyperpolymath/academic-workflow-suite: a masked scanner plus an # unconditional SARIF upload auto-closed 84 real code-scanning alerts - # while every run reported success. The arms below mirror the four-arm - # planted-positive control run against the real files. + # while every run reported success. Positive fixtures below expose the + # report conversion path; they do not infer an external script's I/O. describe "standards#943 precision — mirror pushes and reusable callers" do test "WH013 is silent when every push targets a foreign-forge remote" do @@ -1337,8 +1337,82 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do end end + defp wh014_repo(script) do + body = script |> String.split("\n") |> Enum.map_join("\n", &(" " <> &1)) + + create_repo_with_workflow( + "jobs:\n scan:\n runs-on: ubuntu-latest\n steps:\n - run: |\n" <> + body <> + "\n - uses: github/codeql-action/upload-sarif@v4\n" <> + " with:\n sarif_file: out.sarif\n" + ) + end + describe "wh014_masked_scanner_upload/1" do - test "fires on mask + upload with no findings assertion (the measured shape)" do + test "a diagnostic count in another job cannot qualify a SARIF mask" do + repo = + create_repo_with_workflow(""" + jobs: + diagnostics: + runs-on: ubuntu-latest + steps: + - run: | + COUNT=$(git rev-list --count HEAD 2>/dev/null || echo 0) + test "$COUNT" -gt 0 + security: + runs-on: ubuntu-latest + steps: + - run: trivy fs --format sarif --output results.sarif . + - uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: results.sarif + """) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + + test "a same-job diagnostic git count is not a masked JSON findings count" do + repo = + create_repo_with_workflow(""" + jobs: + security: + runs-on: ubuntu-latest + steps: + - run: | + COUNT=$(git rev-list --count HEAD 2>/dev/null || echo 0) + test "$COUNT" -gt 0 + trivy fs --format sarif --output results.sarif . + - uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: results.sarif + """) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + + test "an unrelated masked JSON job does not acquire another job's SARIF upload" do + repo = + create_repo_with_workflow(""" + jobs: + diagnostics: + runs-on: ubuntu-latest + steps: + - run: | + COUNT=$(jq '. | length' statistics.json || echo 0) + security: + runs-on: ubuntu-latest + steps: + - run: trivy fs --format sarif --output results.sarif . + - uses: github/codeql-action/upload-sarif@v4 + """) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + + test "fires on mask plus a visible conversion and upload with no findings assertion" do repo = create_repo_with_workflow(""" name: Scan @@ -1350,7 +1424,7 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do - run: | hypatia scan . --exit-zero > hypatia-findings.json || true COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0) - node write-sarif.cjs + jq '{version: "2.1.0", runs: [{tool: {driver: {name: "fixture"}}, results: .}]}' hypatia-findings.json > hypatia.sarif - uses: github/codeql-action/upload-sarif@v4.32.6 with: sarif_file: hypatia.sarif @@ -1364,7 +1438,7 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do File.rm_rf!(repo) end - test "does not fire when the findings artefact is asserted non-empty" do + test "a non-empty findings assertion cannot certify a masked scan completed" do repo = create_repo_with_workflow(""" name: Scan @@ -1376,13 +1450,108 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do - run: | hypatia scan . --exit-zero > hypatia-findings.json || true jq -e 'type == "array" and length > 0' hypatia-findings.json + jq '{version: "2.1.0", runs: [{tool: {driver: {name: "fixture"}}, results: .}]}' hypatia-findings.json > hypatia.sarif - uses: github/codeql-action/upload-sarif@v4.32.6 with: sarif_file: hypatia.sarif """) + on_exit(fn -> File.rm_rf!(repo) end) + assert [%{rule: "WH014"}] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + + test "an unrelated jq assertion cannot hide malformed findings becoming zero" do + repo = + create_repo_with_workflow(""" + name: Scan + jobs: + scan: + runs-on: ubuntu-latest + steps: + - run: | + printf 'not-json' > findings.json + COUNT=$(jq '. | length' findings.json 2>/dev/null || echo 0) + jq -e '.enabled' unrelated-settings.json + jq '{version: "2.1.0", runs: [{tool: {driver: {name: "fixture"}}, results: .}]}' findings.json > out.sarif + - uses: github/codeql-action/upload-sarif@v4.32.6 + with: + sarif_file: out.sarif + """) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [%{rule: "WH014"}] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + + test "an empty array from a failed scan stays unsafe even when its shape validates" do + repo = + create_repo_with_workflow(""" + name: Scan + jobs: + scan: + runs-on: ubuntu-latest + steps: + - run: | + (printf '[]'; exit 2) > findings.json || true + jq -e 'type == "array"' findings.json + jq '{version: "2.1.0", runs: [{tool: {driver: {name: "fixture"}}, results: .}]}' findings.json > out.sarif + - uses: github/codeql-action/upload-sarif@v4.32.6 + with: + sarif_file: out.sarif + """) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [%{rule: "WH014"}] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + + test "unmasked empty and non-empty inputs do not require a minimum finding count" do + for report <- ["[]", ~s([{"rule_id":"real-finding-fixture"}])] do + repo = + create_repo_with_workflow(""" + name: Scan + jobs: + scan: + runs-on: ubuntu-latest + steps: + - run: | + set -euo pipefail + printf '%s' '#{report}' > findings.json + jq -e 'type == "array"' findings.json + jq '{version: "2.1.0", runs: [{tool: {driver: {name: "fixture"}}, results: .}]}' findings.json > out.sarif + - uses: github/codeql-action/upload-sarif@v4.32.6 + with: + sarif_file: out.sarif + """) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + end + + test "explicit findings exit handling preserves results while rejecting scan errors" do + repo = + create_repo_with_workflow(""" + name: Scan + jobs: + scan: + runs-on: ubuntu-latest + steps: + - run: | + set -euo pipefail + scan_status=0 + hypatia scan . > findings.json || scan_status=$? + case "$scan_status" in + 0|1) ;; + *) exit "$scan_status" ;; + esac + jq -e 'type == "array"' findings.json + jq '{version: "2.1.0", runs: [{tool: {driver: {name: "fixture"}}, results: .}]}' findings.json > out.sarif + - uses: github/codeql-action/upload-sarif@v4.32.6 + with: + sarif_file: out.sarif + """) + + on_exit(fn -> File.rm_rf!(repo) end) assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) - File.rm_rf!(repo) end test "does not fire once the mask is removed" do @@ -1397,7 +1566,7 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do - run: | set -euo pipefail hypatia scan . --exit-zero > hypatia-findings.json - node write-sarif.cjs + jq '{version: "2.1.0", runs: [{tool: {driver: {name: "fixture"}}, results: .}]}' hypatia-findings.json > hypatia.sarif - uses: github/codeql-action/upload-sarif@v4.32.6 with: sarif_file: hypatia.sarif @@ -1436,6 +1605,7 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do steps: - run: | COUNT=$(jq '. | length' findings.json 2>/dev/null || echo 0) + jq '{version: "2.1.0", runs: [{tool: {driver: {name: "fixture"}}, results: .}]}' findings.json > out.sarif - uses: github/codeql-action/upload-sarif@v4.32.6 with: sarif_file: out.sarif @@ -1444,5 +1614,314 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do assert [%{rule: "WH014"}] = WorkflowHardening.wh014_masked_scanner_upload(repo) File.rm_rf!(repo) end + + test "unrelated same-job JSON masks do not qualify another report's upload" do + for mask <- [ + "COUNT=$(jq 'length' optional-metadata.json || echo 0)", + "optional-tool > optional-metadata.json || true" + ] do + repo = + wh014_repo( + mask <> + "\njq '.' optional-metadata.json > optional-normalized.json" <> + "\ncp optional-normalized.json optional-copy.json" <> + "\nhypatia scan . > findings.json" <> + "\njq '.' findings.json > normalized.json" <> + "\njq '{runs: [{results: .}]}' normalized.json > out.sarif" + ) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + end + + test "continued jq commands and substitutions retain their zero fallback" do + for script <- [ + ~S""" + hypatia scan . > findings.json + COUNT=$(jq \ + 'length' findings.json 2>/dev/null || + echo 0) + jq '{runs: [{results: .}]}' findings.json > out.sarif + """, + ~S""" + hypatia scan . > findings.json + COUNT="$(jq 'length' \ + findings.json || echo 0)" + jq '{runs: [{results: .}]}' findings.json > out.sarif + """, + ~S""" + hypatia scan . > findings.json + COUNT=$(cat findings.json | jq 'length' || echo 0) + jq '{runs: [{results: .}]}' findings.json > out.sarif + """, + ~S""" + hypatia scan . > findings.json + COUNT=$(jq 'length' < findings.json || echo 0) + jq '{runs: [{results: .}]}' findings.json > out.sarif + """ + ] do + repo = wh014_repo(script) + on_exit(fn -> File.rm_rf!(repo) end) + + assert [%{rule: "WH014", action: :report, detail: %{jobs: ["scan"]}}] = + WorkflowHardening.wh014_masked_scanner_upload(repo) + end + end + + test "quoted and commented mask examples are data, including quoted jq arguments" do + repo = + wh014_repo(~S""" + hypatia scan . > findings.json + echo "jq 'length' findings.json || echo 0" + printf '%s\n' "$$(jq 'length' findings.json || echo 0)" + printf '%s\n' 'hypatia scan . > findings.json || true' + # COUNT=$(jq 'length' findings.json || echo 0) + jq -e 'type == "array"' findings.json # || echo 0 + jq --arg note '|| echo 0' '.' findings.json > normalized.json + jq '{runs: [{results: .}]}' normalized.json > out.sarif + """) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + + test "independent commands do not acquire an earlier jq command's fallback" do + for separator <- ["\n", "; ", " & "] do + repo = + wh014_repo( + "hypatia scan . > findings.json\njq 'length' findings.json" <> + separator <> + "false || echo 0\njq '{runs: [{results: .}]}' findings.json > out.sarif" + ) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + end + + test "literal report flow crosses conversions, pipes, copies and later steps" do + repo = + create_repo_with_workflow(~S""" + jobs: + scan: + runs-on: ubuntu-latest + steps: + - run: | + hypatia scan . > ./findings.json || true + jq '.' findings.json > normalized.json + - run: | + cat normalized.json | jq '{runs: [{results: .}]}' > generated.sarif + cp generated.sarif copied.sarif + mv copied.sarif out.sarif + - uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: "./out.sarif" + """) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [%{rule: "WH014"}] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + + test "a slurpfile conversion exposes its literal report input" do + repo = + wh014_repo(~S""" + COUNT=$(jq 'length' findings.json || echo 0) + jq -n --slurpfile data findings.json '{runs: [{results: $data[0]}]}' > out.sarif + """) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [%{rule: "WH014"}] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + + test "a known overwrite replaces the earlier report relationship" do + repo = + wh014_repo(~S""" + hypatia scan . > findings.json || true + jq '{runs: [{results: .}]}' findings.json > out.sarif + other-scanner > fresh.json + jq '{runs: [{results: .}]}' fresh.json > out.sarif + """) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + + test "a mask after the upload cannot qualify an earlier upload" do + repo = + create_repo_with_workflow(~S""" + jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: out.sarif + - run: | + hypatia scan . > findings.json || true + jq '{runs: [{results: .}]}' findings.json > out.sarif + """) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + + test "literal working directories distinguish same-named report files" do + for {input, expected?} <- [{"findings.json", false}, {"../scanner/findings.json", true}] do + repo = + create_repo_with_workflow(""" + jobs: + scan: + runs-on: ubuntu-latest + steps: + - working-directory: scanner + run: hypatia scan . > findings.json || true + - working-directory: render + run: jq '{runs: [{results: .}]}' #{input} > ../out.sarif + - uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: out.sarif + """) + + on_exit(fn -> File.rm_rf!(repo) end) + findings = WorkflowHardening.wh014_masked_scanner_upload(repo) + assert (findings != []) == expected? + end + end + + test "an implicit external converter is unanalysed, not completion evidence" do + repo = + wh014_repo(~S""" + hypatia scan . > findings.json || true + COUNT=$(jq 'length' findings.json || echo 0) + node write-sarif.cjs + """) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + + test "a printed upload action is not an upload step" do + repo = + create_repo_with_workflow(~S""" + jobs: + scan: + runs-on: ubuntu-latest + steps: + - run: | + hypatia scan . > findings.json || true + jq '{runs: [{results: .}]}' findings.json > out.sarif + echo 'uses: github/codeql-action/upload-sarif@v4' + echo 'sarif_file: out.sarif' + """) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + + test "fd duplication preserves scanner and count fallback association" do + for redirect <- ["2>&1", ">&2", "<&0"], + mask <- [:scanner, :count] do + command = + case mask do + :scanner -> + "hypatia scan . > findings.json #{redirect} || true" + + :count -> + "hypatia scan . > findings.json\n" <> + "COUNT=$(jq 'length' findings.json #{redirect} || echo 0)" + end + + repo = + wh014_repo(command <> "\njq '{runs: [{results: .}]}' findings.json > out.sarif") + + on_exit(fn -> File.rm_rf!(repo) end) + assert [%{rule: "WH014"}] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + end + + test "fd duplication does not absorb a real background or separate command" do + for command <- [ + "hypatia scan . > findings.json 2>&1 & false || true", + "hypatia scan . > findings.json 2>&1; false || true", + "hypatia scan . > findings.json\n" <> + "jq 'length' findings.json 2>&1 & false || echo 0", + "hypatia scan . > findings.json\n" <> + "jq 'length' findings.json <&0\nfalse || echo 0" + ] do + repo = + wh014_repo(command <> "\njq '{runs: [{results: .}]}' findings.json > out.sarif") + + on_exit(fn -> File.rm_rf!(repo) end) + assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + end + + test "bare-dash upload steps use the body's key column" do + for marker <- ["-", "- ", "- # literal upload"], + named? <- [false, true], + report <- ["out.sarif", "unrelated.sarif"] do + name = if named?, do: " name: Upload report\n", else: "" + + workflow = + "jobs:\n scan:\n runs-on: ubuntu-latest\n steps:\n" <> + " - run: |\n" <> + " scanner > findings.json || true\n" <> + " jq '.' findings.json > out.sarif\n" <> + " #{marker}\n" <> + " # The first real mapping entry establishes indentation.\n" <> + name <> + " uses: github/codeql-action/upload-sarif@v4\n" <> + " with:\n sarif_file: #{report}\n" + + repo = create_repo_with_workflow(workflow) + on_exit(fn -> File.rm_rf!(repo) end) + findings = WorkflowHardening.wh014_masked_scanner_upload(repo) + assert (findings != []) == (report == "out.sarif") + end + end + + test "bare-dash scalar content cannot masquerade as upload mapping keys" do + repo = + create_repo_with_workflow(~S""" + jobs: + scan: + runs-on: ubuntu-latest + steps: + - + run: | + scanner > findings.json || true + jq '.' findings.json > out.sarif + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: out.sarif + """) + + on_exit(fn -> File.rm_rf!(repo) end) + assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + + test "masked direct SARIF output reaches its literal upload" do + for script <- [ + "scanner --format sarif > out.sarif || true", + "scanner --format sarif > out.sarif 2>&1 || true" + ] do + repo = wh014_repo(script) + on_exit(fn -> File.rm_rf!(repo) end) + assert [%{rule: "WH014"}] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + end + + test "direct SARIF masks retain unrelated-output and overwrite boundaries" do + for script <- [ + "metadata > unrelated.sarif || true\nscanner > out.sarif", + "scanner > out.sarif || true\nscanner > out.sarif", + "scanner > out.sarif\nfalse || true" + ] do + repo = wh014_repo(script) + on_exit(fn -> File.rm_rf!(repo) end) + assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) + end + end end end