From 9b2c703f9f880ceffabf102c2e85ee61f7042960 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:06:51 +0000 Subject: [PATCH 01/21] fix(learning): qualify graph evidence and detect masked scanner failures --- lib/neural/graph_of_trust.ex | 24 +++- lib/rules/workflow_hardening.ex | 69 +++++----- test/graph_of_trust_qualification_test.exs | 151 +++++++++++++++++++++ test/workflow_hardening_test.exs | 94 ++++++++++++- 4 files changed, 295 insertions(+), 43 deletions(-) create mode 100644 test/graph_of_trust_qualification_test.exs diff --git a/lib/neural/graph_of_trust.ex b/lib/neural/graph_of_trust.ex index a53305a8..c888690a 100644 --- a/lib/neural/graph_of_trust.ex +++ b/lib/neural/graph_of_trust.ex @@ -26,6 +26,8 @@ defmodule Hypatia.Neural.GraphOfTrust do require Logger + alias Hypatia.OutcomeTracker + @verisimdb_data_path Application.compile_env(:hypatia, :verisimdb_data_path, "data/verisim") @damping_factor 0.85 @convergence_threshold 0.001 @@ -41,10 +43,26 @@ defmodule Hypatia.Neural.GraphOfTrust do # --- Public API --- - @doc "Build the trust graph from outcome data, including cross-repo edges." + @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. Qualified negative evidence + retains the existing graph policy: it does not create positive edges. + """ def build do - outcomes = load_all_outcomes() - recipes = load_all_recipes() + build(load_all_outcomes(), load_all_recipes()) + end + + @doc false + def build(outcomes, recipes) when is_list(outcomes) and is_list(recipes) 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 = + Enum.filter(outcomes, fn outcome -> + is_map(outcome) and OutcomeTracker.qualified_outcome?(outcome) + end) nodes = build_nodes(outcomes, recipes) edges = build_edges(outcomes) diff --git a/lib/rules/workflow_hardening.ex b/lib/rules/workflow_hardening.ex index cd5d3bf5..e8725cb5 100644 --- a/lib/rules/workflow_hardening.ex +++ b/lib/rules/workflow_hardening.ex @@ -1569,45 +1569,38 @@ defmodule Hypatia.Rules.WorkflowHardening do @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: + The rule reports workflows containing both: + + 1. a SARIF upload to code scanning; + 2. a masked JSON-producing invocation or count (`|| true`, `|| echo 0`). - 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. + 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. - 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. + This report-only rule matches workflow text; it does not trace job or data + dependencies or attest that a scan actually completed. """ def wh014_masked_scanner_upload(repo_path) do repo_path @@ -1618,29 +1611,29 @@ defmodule Hypatia.Rules.WorkflowHardening do 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) - if uploads? and masked? and not asserts? do + if uploads? and masked? 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} uploads SARIF to code scanning but masks a scanner " <> + "or findings-count failure (`|| true` / `|| echo 0`). 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: %{ 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/test/graph_of_trust_qualification_test.exs b/test/graph_of_trust_qualification_test.exs new file mode 100644 index 00000000..8544aecb --- /dev/null +++ b/test/graph_of_trust_qualification_test.exs @@ -0,0 +1,151 @@ +# 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 +end diff --git a/test/workflow_hardening_test.exs b/test/workflow_hardening_test.exs index 18e879b4..d36ee192 100644 --- a/test/workflow_hardening_test.exs +++ b/test/workflow_hardening_test.exs @@ -1364,7 +1364,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 @@ -1381,8 +1381,98 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do 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 + - 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 + - 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 "valid complete empty and non-empty reports 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 + - 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 + - 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 From 82396d81677b11d17c7a0dede5bf0efcf768d5a1 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:35:37 +0000 Subject: [PATCH 02/21] fix: bind learning cursors and recognize actual Agda postulates Bind incremental outcome positions to their runtime source path, preserve partial JSONL tails without duplication, and fail visibly on read errors. Recognize Agda keyword tokens after masking nested comments and strings; safe identifiers containing postulate remain identifiers while real postulate declarations retain their critical finding. Move all five standards callers to published authority a797d30, which repairs the exact ClusterFuzzLite allowlist drift. Native lock regeneration is a separate official-generator operation; no lock bytes are hand edited. Validation: 87 combined scheduler/code-safety tests; prior full scheduler integration 2360 tests, zero failures, 242 existing exclusions. Actual Agda safe/unsafe fixtures and packaged scanner receipt controls passed. --- .github/workflows/governance.yml | 16 +- .github/workflows/hypatia-scan.yml | 2 +- .github/workflows/mirror.yml | 2 +- .github/workflows/scorecard.yml | 2 +- .github/workflows/secret-scanner.yml | 2 +- docs/consumers/standards-governance.adoc | 23 ++- lib/learning_scheduler.ex | 218 +++++++++++++---------- lib/rules/code_safety.ex | 70 +++++++- test/code_safety_test.exs | 86 +++++++++ test/learning_scheduler_test.exs | 142 ++++++++++++++- 10 files changed, 443 insertions(+), 120 deletions(-) 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/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/lib/learning_scheduler.ex b/lib/learning_scheduler.ex index 0297442e..776b01db 100644 --- a/lib/learning_scheduler.ex +++ b/lib/learning_scheduler.ex @@ -30,7 +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, @@ -64,7 +63,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. @@ -529,9 +528,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 +541,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 +561,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 +571,19 @@ defmodule Hypatia.LearningScheduler do "(size #{size} < offset #{pos.offset}), resetting" ) - {Map.put(positions, file, %{offset: 0, incomplete_tail: ""}), count, recipes} + ingest_local_outcome_file( + path, + file, + normalise_position(0), + {positions, 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 +599,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 +708,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 +735,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 +760,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} @@ -803,10 +818,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 +864,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 +890,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 +917,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 +929,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} -> 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/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/learning_scheduler_test.exs b/test/learning_scheduler_test.exs index 93182144..feca7a1b 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,36 @@ 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 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 +394,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 +468,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") From a3bbebfbd3c124d82887fc1454e8790051bb15ac Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:49:03 +0000 Subject: [PATCH 03/21] fix(ci): apply verified native lock for the repaired authority Apply only the unmodified official generator candidate from cicd-suite run41899379027 for exact subject82396d8. Candidate SHA256: 48ea3766b9660fba71573db6d9d7f23576296e4dfafb97ee57360e328ae9ce6e. Artifact SHA256: 7678a20b036057bfee7e86548091b59141fbb233332c4d40ff003022374e2d80. The same-run receipt, official binary identity and all checksum entries were verified. All 11 standards integration positive/negative/unavailable controls pass with this lock. No workflow bytes or security settings change. --- .github/workflows/actions.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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: From 674bfff74279e54e79554cf8b0c7af804600dc92 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:00:17 +0000 Subject: [PATCH 04/21] fix(learning): retain a truncation reset across read failures Persist the path-bound zero cursor before I/O so later growth cannot skip a replacement prefix. Add a regression that fails on the previous source and verifies exact-once ingestion after recovery. Bound transient Nickel download retries while preserving its digest and strict schema checks. --- .github/workflows/escript-soundness.yml | 6 ++- lib/learning_scheduler.ex | 10 ++++- test/learning_scheduler_test.exs | 53 +++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) 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/lib/learning_scheduler.ex b/lib/learning_scheduler.ex index 776b01db..38f0817b 100644 --- a/lib/learning_scheduler.ex +++ b/lib/learning_scheduler.ex @@ -571,11 +571,17 @@ defmodule Hypatia.LearningScheduler do "(size #{size} < offset #{pos.offset}), resetting" ) + # 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, - normalise_position(0), - {positions, count, recipes} + reset, + {Map.put(positions, file, reset), count, recipes} ) {:ok, %{size: size}} when size == pos.offset and pos.incomplete_tail == "" -> diff --git a/test/learning_scheduler_test.exs b/test/learning_scheduler_test.exs index feca7a1b..f36ab054 100644 --- a/test/learning_scheduler_test.exs +++ b/test/learning_scheduler_test.exs @@ -372,6 +372,59 @@ defmodule Hypatia.LearningSchedulerTest do 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 ────────────────────────────────────── From 9105eaa998966bcbef3e96ea25f41920b77f3898 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:03:45 +0000 Subject: [PATCH 05/21] fix(merge): bind observations and contain unverified execution Consume only complete source-bound observation batches and exact-head attestation documents. Keep unverified signatures capped at review, refuse the unqualified mesh executor, and clear stale success after failed cycles. Preserve independent policy and manual execution gates. --- ...05-execution-substrate-bag-of-actions.adoc | 42 +- ...-observation-and-actuation-provenance.adoc | 90 ++++ lib/merge_orchestration/baton_emitter.ex | 97 +--- lib/merge_orchestration/dispatcher.ex | 31 +- lib/merge_orchestration/loop.ex | 126 +++-- lib/merge_orchestration/scheduler.ex | 7 +- lib/merge_orchestration/sensor.ex | 136 ++++-- lib/merge_orchestration/strategist.ex | 26 +- lib/merge_orchestration/ticker.ex | 19 +- .../baton_emitter_test.exs | 80 ++-- test/merge_orchestration/dispatcher_test.exs | 23 + test/merge_orchestration/loop_test.exs | 437 ++++++++---------- test/merge_orchestration/scheduler_test.exs | 52 +-- test/merge_orchestration/sensor_test.exs | 160 +++++-- test/merge_orchestration/ticker_test.exs | 56 +-- test/support/merge_observation_store.ex | 99 ++++ 16 files changed, 917 insertions(+), 564 deletions(-) create mode 100644 docs/design/merge-orchestration/06-observation-and-actuation-provenance.adoc create mode 100644 test/support/merge_observation_store.ex 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..4fe495bd 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,12 @@ [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]. ==== toc::[] @@ -18,13 +20,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 +72,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 +124,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..3f82f334 --- /dev/null +++ b/docs/design/merge-orchestration/06-observation-and-actuation-provenance.adoc @@ -0,0 +1,90 @@ +// 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. Raw Strategist policy tests are models with +explicit assumptions, not receipts 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. + +== 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. 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. Policy-model threshold tests and the independent KinGate tests remain +separate from production authenticity claims. + +No live GitHub merge, store publication, credential repair, scheduler enablement +or signature/trust implementation is established by these offline tests. diff --git a/lib/merge_orchestration/baton_emitter.ex b/lib/merge_orchestration/baton_emitter.ex index 0e76db0a..210168f3 100644 --- a/lib/merge_orchestration/baton_emitter.ex +++ b/lib/merge_orchestration/baton_emitter.ex @@ -2,95 +2,26 @@ # 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 """ - 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: {:error, :qualified_merge_executor_unavailable} + @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..06a19c7c 100644 --- a/lib/merge_orchestration/dispatcher.ex +++ b/lib/merge_orchestration/dispatcher.ex @@ -15,7 +15,7 @@ defmodule Hypatia.MergeOrchestration.Dispatcher do is the thin operational glue that emits the merge-decision JSONL. """ - alias Hypatia.MergeOrchestration.Strategist + alias Hypatia.MergeOrchestration.{Sensor, Strategist} @doc "Decide, then return `{decision, manifest_entry, lease}`." def dispatch(ctx) do @@ -61,27 +61,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..9b5a2891 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/^[0-9a-f]{64}$/, 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..ecc6ec91 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,83 @@ 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/^hyperpolymath\/[A-Za-z0-9_.-]+$/, 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/^[0-9a-f]{40}$/, sha) + + @doc false + def valid_event?(event), + do: is_binary(event) and Regex.match?(~r/^[A-Za-z0-9][A-Za-z0-9_.:\/-]{0,255}$/, 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 +233,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 +244,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 +286,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 +295,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 +306,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 +331,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 +352,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..0bcd176d 100644 --- a/lib/merge_orchestration/strategist.ex +++ b/lib/merge_orchestration/strategist.ex @@ -13,7 +13,9 @@ 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. """ alias Hypatia.MergeOrchestration.KinCouncil @@ -36,6 +38,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 +59,21 @@ 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, Map.get(ctx, :execution_cap, :arm_auto)) + + {capped, + if(capped != base, + do: Map.get(ctx, :execution_blocker, "runtime-execution-cap"), + else: reason + )} 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/test/merge_orchestration/baton_emitter_test.exs b/test/merge_orchestration/baton_emitter_test.exs index a32d06ac..25b74e4d 100644 --- a/test/merge_orchestration/baton_emitter_test.exs +++ b/test/merge_orchestration/baton_emitter_test.exs @@ -4,60 +4,48 @@ 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 {:error, :qualified_merge_executor_unavailable} = + BatonEmitter.to_spec(decision, %{"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" + for method <- [:rebase, :merge_commit, :unknown] do + assert {:error, :qualified_merge_executor_unavailable} = + BatonEmitter.to_spec(%{decision | method: method}) + end end - 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 - end + 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} + } - test "emit submits one Baton per ARMED entry only; the Bag.Mesh call is injected" do 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..247feb8e 100644 --- a/test/merge_orchestration/dispatcher_test.exs +++ b/test/merge_orchestration/dispatcher_test.exs @@ -8,6 +8,10 @@ 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, change_class: :chore, change_level: :object, pool: :p2, @@ -23,6 +27,25 @@ 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 "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/loop_test.exs b/test/merge_orchestration/loop_test.exs index 5957e5bd..276c838c 100644 --- a/test/merge_orchestration/loop_test.exs +++ b/test/merge_orchestration/loop_test.exs @@ -2,290 +2,251 @@ # 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"})) - - File.write!( - Path.join([store, "attestations", "ci.json"]), - e.(%{ - "subject" => %{"repo" => "hyperpolymath/a", "number" => 1}, - "bot" => "ci", - "verdict" => "approve", - "confidence" => 0.99 - }) - ) - - store + 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 - # ── 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") - - r = - Loop.plan( - [bump("hyperpolymath/a", 1), bump("hyperpolymath/a", 2)], - pool_p2(), - approve_ci(), - %{}, - "hypatia", - file_acquire(leases) - ) + 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"]}) + ] - 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) + 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: 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 "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)], - 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 == 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) + assert result.stats.armed == 0 + assert result.stats.flagged == 1 + assert hd(result.entries)["strategy"] == "report_only" end - # ── a full mixed cycle: arm / arm / review / meta-flag ─────────────────────── + 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 - test "a mixed cycle tallies armed / review / flagged, gating only the arms" do - leases = tmp("loop-mixed") + 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 - pool = fn - "hyperpolymath/c" -> "P1" - _ -> "P2" - end + 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 - 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"] - } + 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 ] - r = Loop.plan(observations, pool, approve_ci(), %{}, "hypatia", file_acquire(leases)) + 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) - assert r.stats == %{total: 4, armed: 2, deferred: 0, blocked: 0, review: 1, flagged: 1} - assert length(r.leases) == 2 - File.rm_rf!(leases) - end + File.write!( + receipt, + receipt |> File.read!() |> Jason.decode!() |> transform.() |> Jason.encode!() + ) - # ── the whole thing over a real store of files (codec injected) ────────────── + assert_raise ArgumentError, ~r/unqualified observation batch/, fn -> + Loop.run(store: store) + end - test "run/1 reads a store of observation/pool/attestation files, gates, and writes the manifest + leases" do - store = tmp("loop-store") + assert File.read!(previous) == "previous review-only manifest" + end - for sub <- ~w(observations pools attestations leases), - do: File.mkdir_p!(Path.join(store, sub)) + store = Evidence.seed() + cleanup(store) + File.rm!(Evidence.receipt_path(store)) - e = enc() + assert_raise ArgumentError, ~r/unqualified observation batch/, 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/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..403fc1e3 100644 --- a/test/merge_orchestration/sensor_test.exs +++ b/test/merge_orchestration/sensor_test.exs @@ -3,6 +3,7 @@ defmodule Hypatia.MergeOrchestration.SensorTest do use ExUnit.Case, async: true alias Hypatia.MergeOrchestration.Sensor + alias Hypatia.MergeOrchestration.TestObservationStore, as: Evidence defp obs(o), do: Map.merge(%{"repo" => "hyperpolymath/panll", "number" => 1, "files" => []}, o) @@ -88,9 +89,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 +111,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 +153,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 +161,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 +219,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 +254,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 +272,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/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/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 From 14fdb9483ca913283ca49700ed0af5b8a3290234 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:12:59 +0000 Subject: [PATCH 06/21] fix(rules): scope masked SARIF evidence to its producing job Correlate upload and masked JSON/count evidence within an extracted job. Diagnostic git counts and unrelated jobs cannot create a WH014 finding. Retain genuine masked-scan positives and document the static rule's cross-job dataflow boundary. --- lib/rules/workflow_hardening.ex | 24 ++++++++---- test/workflow_hardening_test.exs | 63 ++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/lib/rules/workflow_hardening.ex b/lib/rules/workflow_hardening.ex index e8725cb5..6a0abeca 100644 --- a/lib/rules/workflow_hardening.ex +++ b/lib/rules/workflow_hardening.ex @@ -1568,7 +1568,7 @@ defmodule Hypatia.Rules.WorkflowHardening do # 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/ + @masked_count_re ~r/\bjq\b[^\n]*\|\|\s*echo\s+0\b/ @doc """ WH014 — a scanner or findings-count failure is masked in a workflow that uploads SARIF to GitHub code scanning. @@ -1588,7 +1588,7 @@ defmodule Hypatia.Rules.WorkflowHardening do carrying **84** findings on 09-11 was followed by six green runs uploading **0** results, closing all 84. - The rule reports workflows containing both: + The rule reports jobs containing both: 1. a SARIF upload to code scanning; 2. a masked JSON-producing invocation or count (`|| true`, `|| echo 0`). @@ -1599,8 +1599,10 @@ defmodule Hypatia.Rules.WorkflowHardening do 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 matches workflow text; it does not trace job or data - dependencies or attest that a scan actually completed. + This report-only rule correlates masks with uploads within the same extracted + job. Diagnostic non-JSON counts do not qualify as findings parsing. It does + not trace cross-job artefact or variable dependencies, parse shell control + flow, or attest that a scan actually completed. """ def wh014_masked_scanner_upload(repo_path) do repo_path @@ -1609,17 +1611,22 @@ 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) + affected_jobs = + content + |> extract_job_blocks() + |> Enum.filter(fn {_job, _line, body} -> + Regex.match?(@sarif_upload_re, body) and + (Regex.match?(@masked_scan_re, body) or Regex.match?(@masked_count_re, body)) + end) - if uploads? and masked? do + if affected_jobs != [] do [ %{ rule: "WH014", file: rel, severity: :high, reason: - "workflow #{rel} uploads SARIF to code scanning but masks a scanner " <> + "workflow #{rel} has a job that uploads SARIF to code scanning but masks a scanner " <> "or findings-count failure (`|| true` / `|| echo 0`). 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 " <> @@ -1627,6 +1634,7 @@ defmodule Hypatia.Rules.WorkflowHardening do action: :report, fix_recipe: "assert-findings-before-sarif-upload", detail: %{ + jobs: Enum.map(affected_jobs, fn {job, _line, _body} -> job end), fix: "Preserve the scanner exit status and reject execution or coverage " <> "errors before uploading. For Hypatia, exit 0 is a successful scan, " <> diff --git a/test/workflow_hardening_test.exs b/test/workflow_hardening_test.exs index d36ee192..261c0c7f 100644 --- a/test/workflow_hardening_test.exs +++ b/test/workflow_hardening_test.exs @@ -1338,6 +1338,69 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do end describe "wh014_masked_scanner_upload/1" 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 + upload with no findings assertion (the measured shape)" do repo = create_repo_with_workflow(""" From 373c8abd4c31d439eac3e391e723abf541657db6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:22:36 +0000 Subject: [PATCH 07/21] fix(neural): rebuild trust from current qualified support Keep unsupported and negative-only nodes neutral and outside ranking normalization. Persist scores only as diagnostic history; never restore them over current outcomes. Reproduce poisoned startup and runtime-store counterexamples. Combined suite: 2385 tests passed, 242 existing exclusions. --- .../2026-11-04-trust-score-boundary.adoc | 50 ++++++++ lib/neural/coordinator.ex | 48 +++---- lib/neural/graph_of_trust.ex | 53 ++++++-- lib/neural/persistence.ex | 16 ++- test/graph_of_trust_qualification_test.exs | 53 ++++++++ test/neural_trust_restart_test.exs | 120 ++++++++++++++++++ 6 files changed, 286 insertions(+), 54 deletions(-) create mode 100644 docs/assurance/2026-11-04-trust-score-boundary.adoc create mode 100644 test/neural_trust_restart_test.exs 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..f87151f2 --- /dev/null +++ b/docs/assurance/2026-11-04-trust-score-boundary.adoc @@ -0,0 +1,50 @@ +// 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. + +== 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/lib/neural/coordinator.ex b/lib/neural/coordinator.ex index 6b34bd92..d4fe52ba 100644 --- a/lib/neural/coordinator.ex +++ b/lib/neural/coordinator.ex @@ -90,7 +90,9 @@ defmodule Hypatia.Neural.Coordinator 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) + # Trust is derived from today's qualified source, not a learned weight + # cache. A persisted score can outlive both its evidence and its model. + trust_graph = GraphOfTrust.build() # M17 — restore GNN / VAE / Seq via their GenServer restore APIs. # These networks are siblings under the same supervisor; they're @@ -114,7 +116,7 @@ defmodule Hypatia.Neural.Coordinator do notes = [ rbf_restore_note(rbf), moe_restore_note(moe, persisted), - trust_restore_note(persisted), + trust_rebuild_note(persisted), new_networks_restore_note(persisted) ] @@ -173,11 +175,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 +292,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 @@ -460,6 +441,7 @@ defmodule Hypatia.Neural.Coordinator do 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 = %{ diff --git a/lib/neural/graph_of_trust.ex b/lib/neural/graph_of_trust.ex index c888690a..a64371ef 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 @@ -28,7 +30,6 @@ defmodule Hypatia.Neural.GraphOfTrust do alias Hypatia.OutcomeTracker - @verisimdb_data_path Application.compile_env(:hypatia, :verisimdb_data_path, "data/verisim") @damping_factor 0.85 @convergence_threshold 0.001 @max_iterations 50 @@ -168,11 +169,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) + + supported_nodes = + supported_edges + |> Enum.flat_map(fn {source, target, _weight} -> [source, target] end) + |> MapSet.new() - # Initialize all nodes to 1/n + 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 = @@ -181,9 +208,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 @@ -367,7 +392,7 @@ defmodule Hypatia.Neural.GraphOfTrust do # 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"]) + scan_path = Path.join(Hypatia.Paths.scans() |> Path.expand(), "#{repo}.json") case File.read(scan_path) do {:ok, content} -> @@ -386,7 +411,7 @@ defmodule Hypatia.Neural.GraphOfTrust do end defp load_all_outcomes 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} -> @@ -415,7 +440,7 @@ defmodule Hypatia.Neural.GraphOfTrust do end defp load_all_recipes 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} -> diff --git a/lib/neural/persistence.ex b/lib/neural/persistence.ex index 254d5656..d4977b91 100644 --- a/lib/neural/persistence.ex +++ b/lib/neural/persistence.ex @@ -17,14 +17,13 @@ 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 with saved weights (or defaults) + 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() @@ -65,6 +64,9 @@ defmodule Hypatia.Neural.Persistence do defp save_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" => @@ -174,7 +176,7 @@ defmodule Hypatia.Neural.Persistence do # --- Generic Save/Load --- defp save_state(network, data) do - path = Path.join(Path.expand(@state_dir), "#{network}.json") + path = Path.join(Hypatia.Paths.neural_states() |> Path.expand(), "#{network}.json") case Jason.encode(data, pretty: true) do {:ok, json} -> File.write(path, json) @@ -183,7 +185,7 @@ defmodule Hypatia.Neural.Persistence do end defp load_state(network) do - path = Path.join(Path.expand(@state_dir), "#{network}.json") + path = Path.join(Hypatia.Paths.neural_states() |> Path.expand(), "#{network}.json") case File.read(path) do {:ok, content} -> @@ -198,7 +200,7 @@ defmodule Hypatia.Neural.Persistence do end defp ensure_state_dir do - dir = Path.expand(@state_dir) + dir = Hypatia.Paths.neural_states() |> Path.expand() File.mkdir_p(dir) end end diff --git a/test/graph_of_trust_qualification_test.exs b/test/graph_of_trust_qualification_test.exs index 8544aecb..2fc7511d 100644 --- a/test/graph_of_trust_qualification_test.exs +++ b/test/graph_of_trust_qualification_test.exs @@ -148,4 +148,57 @@ defmodule Hypatia.Neural.GraphOfTrustQualificationTest do {"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/neural_trust_restart_test.exs b/test/neural_trust_restart_test.exs new file mode 100644 index 00000000..d1a8550f --- /dev/null +++ b/test/neural_trust_restart_test.exs @@ -0,0 +1,120 @@ +# 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([]) + 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([]) + assert state.trust_graph.nodes == %{"replacement-only-recipe" => :recipe} + assert state.trust_graph.trust_scores == %{"replacement-only-recipe" => 0.5} + 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([]) + 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([]) + assert GraphOfTrust.trust_score(supported.trust_graph, "replacement-only-recipe") > 0.5 + refute Map.has_key?(supported.trust_graph.trust_scores, "poison") + end +end From 19bb0b2e90b54c8bcfb348e04966c4a271b57c38 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:35:14 +0000 Subject: [PATCH 08/21] fix(learning): resolve outcomes and confidence in the same runtime store Use current configured paths for replay identities, outcome logs, recipes, fleet output and annealing state. Preserve explicit annealing overrides. Two controls reproduce wrong-store reads/writes before repair; 70 related tests and the full 2389-test suite pass (242 existing exclusions). --- .../2026-11-04-trust-score-boundary.adoc | 5 + lib/outcome_tracker.ex | 87 +++++++------ test/outcome_tracker_runtime_store_test.exs | 117 ++++++++++++++++++ 3 files changed, 173 insertions(+), 36 deletions(-) create mode 100644 test/outcome_tracker_runtime_store_test.exs diff --git a/docs/assurance/2026-11-04-trust-score-boundary.adoc b/docs/assurance/2026-11-04-trust-score-boundary.adoc index f87151f2..6c58ad1f 100644 --- a/docs/assurance/2026-11-04-trust-score-boundary.adoc +++ b/docs/assurance/2026-11-04-trust-score-boundary.adoc @@ -32,6 +32,11 @@ 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 diff --git a/lib/outcome_tracker.ex b/lib/outcome_tracker.ex index 679d1466..b16db7b3 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 @@ -393,7 +382,6 @@ defmodule Hypatia.OutcomeTracker do recipe_path = find_recipe_file(recipe_id) cond do - recipe_path == nil -> Logger.warning("No recipe file found for #{recipe_id}") :recipe_not_found @@ -421,12 +409,14 @@ defmodule Hypatia.OutcomeTracker do # 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 = @@ -452,7 +442,10 @@ defmodule Hypatia.OutcomeTracker do {:ok, new_confidence} {:error, reason} -> - Logger.warning("Failed to persist recipe confidence: #{inspect(reason)}") + Logger.warning( + "Failed to persist recipe confidence: #{inspect(reason)}" + ) + {:error, reason} end @@ -476,20 +469,27 @@ defmodule Hypatia.OutcomeTracker do @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 +796,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} -> @@ -826,7 +826,7 @@ defmodule Hypatia.OutcomeTracker do defp write_outcome_log(record) 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(Path.expand(Paths.outcomes()), "#{now.year}-#{month_str}.jsonl") with :ok <- File.mkdir_p(Path.dirname(path)), {:ok, json} <- Jason.encode(record) do @@ -870,7 +870,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" ]) @@ -900,7 +900,7 @@ defmodule Hypatia.OutcomeTracker do end defp load_outcomes_for_recipe(recipe_id) 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} -> @@ -935,7 +935,7 @@ defmodule Hypatia.OutcomeTracker do end defp find_recipe_file(recipe_id) do - recipes_dir = Path.join(Path.expand(@verisimdb_data_path), "recipes") + recipes_dir = Path.expand(Paths.recipes()) case File.ls(recipes_dir) do {:ok, files} -> @@ -1011,7 +1011,9 @@ defmodule Hypatia.OutcomeTracker do _ -> ConfidenceAnnealing.from_existing( - counts.successes, counts.failures, counts.false_positives + counts.successes, + counts.failures, + counts.false_positives ) end @@ -1027,10 +1029,14 @@ 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 @@ -1044,6 +1050,7 @@ defmodule Hypatia.OutcomeTracker do if current != saved and (saved != nil or qualified_count(counts) > 0), do: save_annealing_state(recipe_id, current) + current end @@ -1058,11 +1065,11 @@ 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) @@ -1116,6 +1123,14 @@ defmodule Hypatia.OutcomeTracker do defp annealing_state_path(recipe_id) 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") + + directory = + Application.get_env( + :hypatia, + :annealing_state_path, + Path.join(Paths.verisimdb_data(), "annealing-states") + ) + + Path.join(Path.expand(directory), "#{safe_id}.json") 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..27f22763 --- /dev/null +++ b/test/outcome_tracker_runtime_store_test.exs @@ -0,0 +1,117 @@ +# 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 + + 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 From 65a3d32bfbf7c3926658ca8083bbdf326c0ca707 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:17:01 +0000 Subject: [PATCH 09/21] fix(learning): admit canonical standards subjects and read runtime fleet paths Use the existing canonical standards mapping without expanding the owner allowlist. Read fleet outcomes through the same runtime path resolver as OutcomeTracker. Add regression cases for canonical batch admission, exact attestation matching and review/meta limits, plus runtime fleet switching, replay and incomplete input. Unreferenced review candidate only. Tests and formatting have not been executed because the workspace is unavailable. No branch or pull request is updated by creating this object. --- lib/learning_scheduler.ex | 10 +-- lib/merge_orchestration/sensor.ex | 4 +- test/merge_orchestration/loop_test.exs | 20 ++++++ test/merge_orchestration/sensor_test.exs | 36 ++++++++++ test/outcome_tracker_runtime_store_test.exs | 78 +++++++++++++++++++++ 5 files changed, 140 insertions(+), 8 deletions(-) diff --git a/lib/learning_scheduler.ex b/lib/learning_scheduler.ex index 38f0817b..5d1c8d69 100644 --- a/lib/learning_scheduler.ex +++ b/lib/learning_scheduler.ex @@ -30,11 +30,6 @@ defmodule Hypatia.LearningScheduler do # pins. @action_sha_verify_interval_seconds 24 * 60 * 60 @action_sha_cache_path "data/verified-action-shas.json" - @fleet_path Application.compile_env( - :hypatia, - :fleet_path, - "~/Documents/hyperpolymath-repos/gitbot-fleet" - ) @annealing_state_path Application.compile_env( :hypatia, :annealing_state_path, @@ -807,10 +802,11 @@ 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" ]) diff --git a/lib/merge_orchestration/sensor.ex b/lib/merge_orchestration/sensor.ex index ecc6ec91..8dc3fa1f 100644 --- a/lib/merge_orchestration/sensor.ex +++ b/lib/merge_orchestration/sensor.ex @@ -125,7 +125,9 @@ defmodule Hypatia.MergeOrchestration.Sensor do @doc false def valid_subject?(repo, number, head) do - is_binary(repo) and Regex.match?(~r/^hyperpolymath\/[A-Za-z0-9_.-]+$/, repo) and + 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 diff --git a/test/merge_orchestration/loop_test.exs b/test/merge_orchestration/loop_test.exs index 276c838c..58f8793d 100644 --- a/test/merge_orchestration/loop_test.exs +++ b/test/merge_orchestration/loop_test.exs @@ -65,6 +65,26 @@ defmodule Hypatia.MergeOrchestration.LoopTest do assert File.ls!(Path.join(store, "leases")) == [] end + 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) diff --git a/test/merge_orchestration/sensor_test.exs b/test/merge_orchestration/sensor_test.exs index 403fc1e3..167545c4 100644 --- a/test/merge_orchestration/sensor_test.exs +++ b/test/merge_orchestration/sensor_test.exs @@ -72,6 +72,42 @@ 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 "ordinary library code is object-level" do assert {_, :object, _} = Sensor.classify(obs(%{"files" => ["lib/x.ex", "lib/y.ex"]})) end diff --git a/test/outcome_tracker_runtime_store_test.exs b/test/outcome_tracker_runtime_store_test.exs index 27f22763..05982b6e 100644 --- a/test/outcome_tracker_runtime_store_test.exs +++ b/test/outcome_tracker_runtime_store_test.exs @@ -77,6 +77,84 @@ defmodule Hypatia.OutcomeTrackerRuntimeStoreTest do 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() From 7a2719fb0374a596b7702fdcd1f72cc7cfc8480f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:30:50 +0000 Subject: [PATCH 10/21] test: isolate anomaly detector state and trace learning regressions Use a fresh test-supervised detector while retaining the real telemetry, Watcher and Registry delivery path. Order the window assertion from the Watcher process after its broadcasts, replace elapsed-time assumptions with the exact observed window, and cover repeated-alert deduplication. The original 1000 ms alert assertion remains unchanged. Trace the three test files containing the five new runtime-store and canonical-subject regressions in the existing E0 hosted step. The failing hosted run did not retain detector state, so this addresses observable test isolation and ordering defects without attributing that single failure conclusively to either. Source reviewed only; execution and formatting qualification are pending hosted CI. --- .github/workflows/tests.yml | 7 +- test/anomaly_detector_test.exs | 210 +++++++++++++++++---------------- 2 files changed, 116 insertions(+), 101 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ea443e83..ad225649 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -110,7 +110,12 @@ 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 - name: Run E2E test suite run: bash tests/e2e.sh 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 From 065eedc8dc0fa11b83887173b268217e927abf5b Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:46:05 +0000 Subject: [PATCH 11/21] fix: correlate masked reports with literal SARIF upload paths Scope WH014 to visible same-job report flow, preserving command boundaries, continuations and quoted data. Keep unsupported shell/data flow explicitly unanalysed rather than treating absence as completion evidence. Expose the authored regression cases in the existing named E0 test step. New source is pending hosted compilation, formatting and regression results. --- .github/workflows/tests.yml | 3 +- lib/rules/workflow_hardening.ex | 459 +++++++++++++++++++++++++++++-- test/workflow_hardening_test.exs | 233 +++++++++++++++- 3 files changed, 671 insertions(+), 24 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ad225649..e0368254 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -115,7 +115,8 @@ jobs: 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/merge_orchestration/loop_test.exs \ + test/workflow_hardening_test.exs - name: Run E2E test suite run: bash tests/e2e.sh diff --git a/lib/rules/workflow_hardening.ex b/lib/rules/workflow_hardening.ex index 6a0abeca..9393c315 100644 --- a/lib/rules/workflow_hardening.ex +++ b/lib/rules/workflow_hardening.ex @@ -1560,15 +1560,430 @@ 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") + + case Regex.run(~r/^([ \t]*-[ \t]+)(.*)$/, item) do + [_, prefix, value] -> + column = String.length(prefix) + {column, [String.duplicate(" ", column) <> value | rest]} + + _ -> + {nil, []} + 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) + + 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 -> + masked_json? = output_mask? and String.ends_with?(path, ".json") + if inherited? or masked_json? 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]) + + 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/\bjq\b[^\n]*\|\|\s*echo\s+0\b/ @doc """ WH014 — a scanner or findings-count failure is masked in a workflow that uploads SARIF to GitHub code scanning. @@ -1588,10 +2003,11 @@ defmodule Hypatia.Rules.WorkflowHardening do carrying **84** findings on 09-11 was followed by six green runs uploading **0** results, closing all 84. - The rule reports jobs containing both: - - 1. a SARIF upload to code scanning; - 2. a masked JSON-producing invocation or count (`|| true`, `|| echo 0`). + The rule reports a masked JSON-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 @@ -1599,10 +2015,18 @@ defmodule Hypatia.Rules.WorkflowHardening do 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 correlates masks with uploads within the same extracted - job. Diagnostic non-JSON counts do not qualify as findings parsing. It does - not trace cross-job artefact or variable dependencies, parse shell control - flow, or attest that a scan actually completed. + 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 @@ -1615,8 +2039,8 @@ defmodule Hypatia.Rules.WorkflowHardening do content |> extract_job_blocks() |> Enum.filter(fn {_job, _line, body} -> - Regex.match?(@sarif_upload_re, body) and - (Regex.match?(@masked_scan_re, body) or Regex.match?(@masked_count_re, body)) + directory = wh014_default_directory(workflow_preamble(content), @wh014_workspace) + wh014_masked_upload?(body, directory) end) if affected_jobs != [] do @@ -1626,8 +2050,8 @@ defmodule Hypatia.Rules.WorkflowHardening do file: rel, severity: :high, reason: - "workflow #{rel} has a job that uploads SARIF to code scanning but masks a scanner " <> - "or findings-count failure (`|| true` / `|| echo 0`). A failed or " <> + "workflow #{rel} has a literal report path from a masked JSON-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.", @@ -1635,6 +2059,7 @@ defmodule Hypatia.Rules.WorkflowHardening do 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: "Preserve the scanner exit status and reject execution or coverage " <> "errors before uploading. For Hypatia, exit 0 is a successful scan, " <> diff --git a/test/workflow_hardening_test.exs b/test/workflow_hardening_test.exs index 261c0c7f..14d70f97 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,6 +1337,17 @@ 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 "a diagnostic count in another job cannot qualify a SARIF mask" do repo = @@ -1401,7 +1412,7 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do assert [] = WorkflowHardening.wh014_masked_scanner_upload(repo) end - test "fires on mask + upload with no findings assertion (the measured shape)" do + test "fires on mask plus a visible conversion and upload with no findings assertion" do repo = create_repo_with_workflow(""" name: Scan @@ -1413,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 @@ -1439,6 +1450,7 @@ 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 @@ -1460,6 +1472,7 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do 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 @@ -1480,6 +1493,7 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do - 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 @@ -1489,7 +1503,7 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do assert [%{rule: "WH014"}] = WorkflowHardening.wh014_masked_scanner_upload(repo) end - test "valid complete empty and non-empty reports do not require a minimum finding count" do + 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(""" @@ -1502,6 +1516,7 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do 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 @@ -1529,6 +1544,7 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do *) 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 @@ -1550,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 @@ -1589,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 @@ -1597,5 +1614,209 @@ 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 end end From e166d5fb3007ad41dc8edc62d9997201dabc56f9 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:52:51 +0000 Subject: [PATCH 12/21] fix: bind learning readers and merge authority to explicit runtime evidence Resolve connected learning stores and caches at call time, qualify corpus negatives, and share annealing writer/reporter paths. Keep explicit backend and corpus overrides. Reject unknown execution caps and malformed evidence identifiers; propagate unavailable Baton execution through the CLI. Add named hosted qualification for these source boundaries. New controls are authored but not yet executed. Coordinator model lifecycle provenance is separate pending work; no production learning or actuation is claimed. --- .github/workflows/tests.yml | 6 + ...05-execution-substrate-bag-of-actions.adoc | 5 +- ...-observation-and-actuation-provenance.adoc | 26 +- lib/learning_scheduler.ex | 23 +- lib/merge_orchestration/baton_emitter.ex | 5 +- lib/merge_orchestration/dispatcher.ex | 3 +- lib/merge_orchestration/loop.ex | 2 +- lib/merge_orchestration/sensor.ex | 4 +- lib/merge_orchestration/strategist.ex | 13 +- lib/mix/tasks/hypatia.merge_orchestrate.ex | 44 +- lib/neural/rebalancer.ex | 20 +- lib/neural/training_pipeline.ex | 10 +- lib/outcome_tracker.ex | 9 +- lib/paths.ex | 14 + lib/pattern_registry.ex | 6 +- lib/vcl/client.ex | 11 +- lib/vcl/file_executor.ex | 79 +-- lib/vcl/query.ex | 6 +- lib/verisim_connector.ex | 39 +- test/learning_runtime_store_test.exs | 524 ++++++++++++++++++ .../baton_emitter_test.exs | 2 + test/merge_orchestration/dispatcher_test.exs | 37 ++ .../kin_competence_test.exs | 6 +- test/merge_orchestration/loop_test.exs | 17 + .../merge_orchestrate_task_test.exs | 45 ++ test/merge_orchestration/sensor_test.exs | 31 +- test/merge_orchestration/strategist_test.exs | 32 +- 27 files changed, 891 insertions(+), 128 deletions(-) create mode 100644 test/learning_runtime_store_test.exs create mode 100644 test/merge_orchestration/merge_orchestrate_task_test.exs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e0368254..55103374 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -116,6 +116,12 @@ jobs: 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/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 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 4fe495bd..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 @@ -11,7 +11,10 @@ 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]. +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::[] diff --git a/docs/design/merge-orchestration/06-observation-and-actuation-provenance.adoc b/docs/design/merge-orchestration/06-observation-and-actuation-provenance.adoc index 3f82f334..1adc724a 100644 --- a/docs/design/merge-orchestration/06-observation-and-actuation-provenance.adoc +++ b/docs/design/merge-orchestration/06-observation-and-actuation-provenance.adoc @@ -8,8 +8,10 @@ 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. Raw Strategist policy tests are models with -explicit assumptions, not receipts from a live verified bot. +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 @@ -20,6 +22,14 @@ 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 @@ -58,7 +68,9 @@ retained in the council input and its authenticity remains explicitly 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. Pool strings use the farm's canonical +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. @@ -83,8 +95,12 @@ 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. Policy-model threshold tests and the independent KinGate tests remain -separate from production authenticity claims. +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/learning_scheduler.ex b/lib/learning_scheduler.ex index 5d1c8d69..9d17c163 100644 --- a/lib/learning_scheduler.ex +++ b/lib/learning_scheduler.ex @@ -30,11 +30,6 @@ defmodule Hypatia.LearningScheduler do # pins. @action_sha_verify_interval_seconds 24 * 60 * 60 @action_sha_cache_path "data/verified-action-shas.json" - @annealing_state_path Application.compile_env( - :hypatia, - :annealing_state_path, - "data/verisim/annealing-states" - ) # --- Client API --- @@ -812,7 +807,18 @@ defmodule Hypatia.LearningScheduler do 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} @@ -972,8 +978,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 210168f3..dcc82f7b 100644 --- a/lib/merge_orchestration/baton_emitter.ex +++ b/lib/merge_orchestration/baton_emitter.ex @@ -15,8 +15,11 @@ defmodule Hypatia.MergeOrchestration.BatonEmitter do alternate executor can silently bypass its checks. """ + @doc "Whether a qualified merge executor is available; performs no submission." + def availability, do: {:error, :qualified_merge_executor_unavailable} + @doc "No mutating spec can be constructed without a qualified merge executor." - def to_spec(_decision, _lease \\ %{}), do: {:error, :qualified_merge_executor_unavailable} + def to_spec(_decision, _lease \\ %{}), do: availability() @doc "Return explicit refusals for armed candidates; never submit a mutation." def emit(%{results: results}, _opts \\ []) do diff --git a/lib/merge_orchestration/dispatcher.ex b/lib/merge_orchestration/dispatcher.ex index 06a19c7c..4392bbe4 100644 --- a/lib/merge_orchestration/dispatcher.ex +++ b/lib/merge_orchestration/dispatcher.ex @@ -12,7 +12,8 @@ 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.{Sensor, Strategist} diff --git a/lib/merge_orchestration/loop.ex b/lib/merge_orchestration/loop.ex index 9b5a2891..33d24c87 100644 --- a/lib/merge_orchestration/loop.ex +++ b/lib/merge_orchestration/loop.ex @@ -271,7 +271,7 @@ defmodule Hypatia.MergeOrchestration.Loop do ) 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/^[0-9a-f]{64}$/, digest) + is_binary(digest) and Regex.match?(~r/\A[0-9a-f]{64}\z/, digest) end defp valid_batch_entry?(_, _), do: false diff --git a/lib/merge_orchestration/sensor.ex b/lib/merge_orchestration/sensor.ex index 8dc3fa1f..bc763462 100644 --- a/lib/merge_orchestration/sensor.ex +++ b/lib/merge_orchestration/sensor.ex @@ -132,11 +132,11 @@ defmodule Hypatia.MergeOrchestration.Sensor do end @doc false - def valid_sha?(sha), do: is_binary(sha) and Regex.match?(~r/^[0-9a-f]{40}$/, sha) + 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-Za-z0-9][A-Za-z0-9_.:\/-]{0,255}$/, 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) diff --git a/lib/merge_orchestration/strategist.ex b/lib/merge_orchestration/strategist.ex index 0bcd176d..b4f9ee33 100644 --- a/lib/merge_orchestration/strategist.ex +++ b/lib/merge_orchestration/strategist.ex @@ -15,7 +15,9 @@ defmodule Hypatia.MergeOrchestration.Strategist do attestations (artifact 3); a license/SPDX touch adds a symbolic owner-only 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. + 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 @@ -67,7 +69,7 @@ defmodule Hypatia.MergeOrchestration.Strategist do true -> {base, reason} = confidence |> confidence_to_safety() |> clamp_to_pool(ctx.pool) - capped = min_safety(base, Map.get(ctx, :execution_cap, :arm_auto)) + capped = min_safety(base, execution_cap(ctx)) {capped, if(capped != base, @@ -77,6 +79,13 @@ defmodule Hypatia.MergeOrchestration.Strategist do 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 + defp confidence_to_safety(conf) when conf >= @auto_threshold, do: :arm_auto defp confidence_to_safety(conf) when conf >= @review_threshold, do: :review defp confidence_to_safety(_), do: :flag diff --git a/lib/mix/tasks/hypatia.merge_orchestrate.ex b/lib/mix/tasks/hypatia.merge_orchestrate.ex index 69fcee95..88979ff8 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,26 @@ 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 + case BatonEmitter.availability() do + :ok -> + :ok - Mix.shell().info("[merge-orchestration] #{inspect(result.stats)} -> #{summary}") + {:error, reason} -> + Mix.raise( + "--actuation #{mode} unavailable: #{reason}; no cycle started, " <> + "no Baton submitted and no manifest written. Use --actuation manifest." + ) + end end defp put_actuation(cycle_opts, nil), do: cycle_opts 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/training_pipeline.ex b/lib/neural/training_pipeline.ex index ff66003d..b47ea230 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 """ @@ -47,7 +45,7 @@ defmodule Hypatia.Neural.TrainingPipeline do 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") + outcomes_dir = Path.expand(Paths.outcomes()) outcomes = case File.ls(outcomes_dir) do @@ -179,7 +177,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, Paths.verisimdb_data()) templates = Rebalancer.load_failure_templates(data_path) if templates == [] do @@ -244,7 +242,7 @@ defmodule Hypatia.Neural.TrainingPipeline do 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") + registry_path = Path.join(Path.expand(Paths.patterns()), "registry.json") patterns = case File.read(registry_path) do diff --git a/lib/outcome_tracker.ex b/lib/outcome_tracker.ex index b16db7b3..4bd6f874 100644 --- a/lib/outcome_tracker.ex +++ b/lib/outcome_tracker.ex @@ -1124,13 +1124,6 @@ defmodule Hypatia.OutcomeTracker do # Sanitise recipe_id for filesystem safety safe_id = String.replace(recipe_id, ~r/[^a-zA-Z0-9_\-]/, "_") - directory = - Application.get_env( - :hypatia, - :annealing_state_path, - Path.join(Paths.verisimdb_data(), "annealing-states") - ) - - Path.join(Path.expand(directory), "#{safe_id}.json") + Path.join(Path.expand(Paths.annealing_states()), "#{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..393f1e3f 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", @@ -311,11 +311,11 @@ defmodule Hypatia.PatternRegistry do end defp registry_path do - Path.join(Path.expand(@verisimdb_data_path), "patterns/registry.json") + Path.join(Path.expand(Paths.patterns()), "registry.json") end defp substitutions_path do - Path.join(Path.expand(@verisimdb_data_path), "recipes/proven-substitutions.json") + Path.join(Path.expand(Paths.recipes()), "proven-substitutions.json") end defp load_registry do 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/learning_runtime_store_test.exs b/test/learning_runtime_store_test.exs new file mode 100644 index 00000000..3f64f0d5 --- /dev/null +++ b/test/learning_runtime_store_test.exs @@ -0,0 +1,524 @@ +# 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 + } + + 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 + + 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 "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], [0.0]} = TrainingPipeline.build_rbf_training_data() + 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], [0.0]} = TrainingPipeline.build_rbf_training_data() + 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") + 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") + 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 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 + %{ + 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 + } + end +end diff --git a/test/merge_orchestration/baton_emitter_test.exs b/test/merge_orchestration/baton_emitter_test.exs index 25b74e4d..80ec86d7 100644 --- a/test/merge_orchestration/baton_emitter_test.exs +++ b/test/merge_orchestration/baton_emitter_test.exs @@ -15,6 +15,8 @@ defmodule Hypatia.MergeOrchestration.BatonEmitterTest do route: %{authority_bot: "ci"} } + assert BatonEmitter.availability() == {:error, :qualified_merge_executor_unavailable} + assert {:error, :qualified_merge_executor_unavailable} = BatonEmitter.to_spec(decision, %{"lease_id" => "L1"}) diff --git a/test/merge_orchestration/dispatcher_test.exs b/test/merge_orchestration/dispatcher_test.exs index 247feb8e..f9b5aa58 100644 --- a/test/merge_orchestration/dispatcher_test.exs +++ b/test/merge_orchestration/dispatcher_test.exs @@ -12,6 +12,7 @@ defmodule Hypatia.MergeOrchestration.DispatcherTest do 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, @@ -32,6 +33,42 @@ defmodule Hypatia.MergeOrchestration.DispatcherTest do 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() 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 58f8793d..ac73710f 100644 --- a/test/merge_orchestration/loop_test.exs +++ b/test/merge_orchestration/loop_test.exs @@ -153,6 +153,23 @@ defmodule Hypatia.MergeOrchestration.LoopTest do refute File.exists?(Path.join(store, "merge-decisions.jsonl")) end + 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!() + + changed = update_in(receipt, ["observations", Access.at(0), "sha256"], &(&1 <> "\n")) + + File.write!(receipt_path, Jason.encode!(changed)) + + assert_raise ArgumentError, ~r/missing, incomplete or malformed receipt/, fn -> + Loop.run(store: store) + end + + refute File.exists?(Path.join(store, "merge-decisions.jsonl")) + end + test "file tampering, missing members and symlinks cannot satisfy a complete receipt" do for action <- [:tamper, :missing, :symlink] do store = Evidence.seed() 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/sensor_test.exs b/test/merge_orchestration/sensor_test.exs index 167545c4..3768f514 100644 --- a/test/merge_orchestration/sensor_test.exs +++ b/test/merge_orchestration/sensor_test.exs @@ -2,7 +2,7 @@ # 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) @@ -108,6 +108,35 @@ defmodule Hypatia.MergeOrchestration.SensorTest do 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 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 From a0f61baf7c9b32d02e435287058c03fea5a30b70 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:54:42 +0000 Subject: [PATCH 13/21] fix: keep shell descriptor duplication inside the masked scan command Recognise >& and <& separately from real background sequencing in WH014. Keep descriptor targets out of literal file-flow inference. Add scanner, count and separate-command controls; pending hosted execution. --- lib/rules/workflow_hardening.ex | 16 ++++++++++++++ test/workflow_hardening_test.exs | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/lib/rules/workflow_hardening.ex b/lib/rules/workflow_hardening.ex index 9393c315..3d246b3d 100644 --- a/lib/rules/workflow_hardening.ex +++ b/lib/rules/workflow_hardening.ex @@ -1820,6 +1820,18 @@ defmodule Hypatia.Rules.WorkflowHardening do 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, ">"), @@ -1929,6 +1941,10 @@ defmodule Hypatia.Rules.WorkflowHardening do 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]) diff --git a/test/workflow_hardening_test.exs b/test/workflow_hardening_test.exs index 14d70f97..5c031cd8 100644 --- a/test/workflow_hardening_test.exs +++ b/test/workflow_hardening_test.exs @@ -1818,5 +1818,43 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do 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 end end From 176f5cc5a973bcb1740e630bfad1a0368c377a16 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:05:28 +0000 Subject: [PATCH 14/21] fix: keep Baton preflight consistent with its unavailable backend Elixir 1.19 correctly rejects the unreachable success arm in availability preflight. Match the backend's explicit refusal and raise before app start or a cycle, preserving the actual argv regression and manifest option. No merge executor or actuation authority is introduced. --- lib/mix/tasks/hypatia.merge_orchestrate.ex | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/lib/mix/tasks/hypatia.merge_orchestrate.ex b/lib/mix/tasks/hypatia.merge_orchestrate.ex index 88979ff8..5371ab34 100644 --- a/lib/mix/tasks/hypatia.merge_orchestrate.ex +++ b/lib/mix/tasks/hypatia.merge_orchestrate.ex @@ -50,16 +50,14 @@ defmodule Mix.Tasks.Hypatia.MergeOrchestrate do defp require_backend!(:manifest), do: :ok defp require_backend!(mode) when mode in [:baton, :both] do - case BatonEmitter.availability() do - :ok -> - :ok - - {:error, reason} -> - Mix.raise( - "--actuation #{mode} unavailable: #{reason}; no cycle started, " <> - "no Baton submitted and no manifest written. Use --actuation manifest." - ) - end + # 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.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 From 7809f71ccfef9d8588191339dc3addc1c76a462d Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:11:04 +0000 Subject: [PATCH 15/21] fix: follow bare YAML steps and masked direct SARIF reports Derive a bare sequence item's mapping column from its real body, keeping nested scalar text separate from upload keys. Retain masks for literal SARIF outputs as well as JSON before correlating with an actual upload. Add positive and unrelated/overwrite/scalar controls; hosted tests pending. No absence-of-finding or scan-completion assurance is inferred. --- lib/rules/workflow_hardening.ex | 33 +++++++++++----- test/workflow_hardening_test.exs | 67 ++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/lib/rules/workflow_hardening.ex b/lib/rules/workflow_hardening.ex index 3d246b3d..b6f5da15 100644 --- a/lib/rules/workflow_hardening.ex +++ b/lib/rules/workflow_hardening.ex @@ -1617,13 +1617,26 @@ defmodule Hypatia.Rules.WorkflowHardening do defp wh014_step_lines(step) do [item | rest] = String.split(step, "\n") - case Regex.run(~r/^([ \t]*-[ \t]+)(.*)$/, item) do - [_, prefix, value] -> - column = String.length(prefix) - {column, [String.duplicate(" ", column) <> value | rest]} + 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, []} + _ -> + {nil, []} + end end end @@ -1924,8 +1937,8 @@ defmodule Hypatia.Rules.WorkflowHardening do paths = Enum.reduce(outputs, paths, fn {kind, path}, acc -> - masked_json? = output_mask? and String.ends_with?(path, ".json") - if inherited? or masked_json? or (kind == :append and MapSet.member?(acc, path)), + # 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) @@ -2019,7 +2032,7 @@ defmodule Hypatia.Rules.WorkflowHardening do carrying **84** findings on 09-11 was followed by six green runs uploading **0** results, closing all 84. - The rule reports a masked JSON-producing invocation or count (`|| true`, + 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 @@ -2066,7 +2079,7 @@ defmodule Hypatia.Rules.WorkflowHardening do file: rel, severity: :high, reason: - "workflow #{rel} has a literal report path from a masked JSON-producing " <> + "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 " <> diff --git a/test/workflow_hardening_test.exs b/test/workflow_hardening_test.exs index 5c031cd8..b245ce41 100644 --- a/test/workflow_hardening_test.exs +++ b/test/workflow_hardening_test.exs @@ -1856,5 +1856,72 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do 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 From 6db86f52438e7450b7ae152b8170ffeef65870ff Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:16:15 +0000 Subject: [PATCH 16/21] fix(neural): bind models and persistence to their source lifecycle Capture the configured data root in the Coordinator, named models and Blackboard. Pin training and trust reads, bind queued requests, and refuse mismatched sources before publishing recommendations or saving snapshots. Require all named snapshots before persistence and propagate refusal through the existing learning, Kin, diagnostics and watcher consumers. Require explicit graph subjects before using qualified outcome rows as trust edges. Preserve raw diagnostic snapshots and qualified-outcome authority. Add isolated lifecycle and caller regression source. These tests are authored but not executed here; exact-source hosted compilation and test gates are required. No dispatch, restart, deployment or learning-performance claim. --- .claude/CLAUDE.md | 11 +- .machine_readable/descriptiles/PLAYBOOK.a2ml | 3 +- .machine_readable/descriptiles/STATE.a2ml | 10 + .../architecture/neural-source-lifecycle.adoc | 144 +++++ lib/fleet_dispatcher.ex | 27 +- lib/hypatia/diagnostics/monitor.ex | 134 ++-- lib/hypatia/watcher/anomaly_detector.ex | 17 +- lib/kin/gate.ex | 72 +-- lib/learning_scheduler.ex | 21 +- lib/neural/blackboard.ex | 151 ++++- lib/neural/coordinator.ex | 490 +++++++++------ lib/neural/graph_neural_network.ex | 133 ++-- lib/neural/graph_of_trust.ex | 70 ++- lib/neural/persistence.ex | 211 ++++--- lib/neural/sequence_model.ex | 126 ++-- lib/neural/source_store.ex | 180 ++++++ lib/neural/training_pipeline.ex | 22 +- lib/neural/variational_autoencoder.ex | 119 ++-- lib/outcome_tracker.ex | 24 +- test/learning_runtime_store_test.exs | 23 +- test/neural_source_store_lifecycle_test.exs | 585 ++++++++++++++++++ test/neural_trust_restart_test.exs | 27 +- 22 files changed, 2021 insertions(+), 579 deletions(-) create mode 100644 docs/architecture/neural-source-lifecycle.adoc create mode 100644 lib/neural/source_store.ex create mode 100644 test/neural_source_store_lifecycle_test.exs 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/.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..eb20c6d5 --- /dev/null +++ b/docs/architecture/neural-source-lifecycle.adoc @@ -0,0 +1,144 @@ +// 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 cohort blocks that lifecycle. Switching the configuration +back does not clear the refusal. An operator must inspect the source and make +an explicit decision 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. Queue admission and execution each check +the lifecycle; an admitted request can still be refused before execution if the +configuration changes. Old unbound casts are 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. +| {:error, reason}. Kin holds auto-execute for human review; manual tiers retain +their existing route. PatternAnalyzer already treats non-map replies as +unavailable and applies DispatchGuard's ceiling. + +| Coordinator.force_cycle/0, record_outcome/2 +| :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. FleetDispatcher's +neural notification returns :queued or an explicit error; its independent raw +observation log is not evidence of completed neural 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. 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. + +== 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. + +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/lib/fleet_dispatcher.ex b/lib/fleet_dispatcher.ex index 6e9169dc..17f26710 100644 --- a/lib/fleet_dispatcher.ex +++ b/lib/fleet_dispatcher.ex @@ -654,6 +654,27 @@ defmodule Hypatia.FleetDispatcher do execute_graphql(mutation, "robot-repo-automaton") end + @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 +689,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..641aca0b 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) + # Source changes need an operator decision; notification is not restart authority. + unless source_lifecycle_failure?(reason), do: attempt_recovery(reason) 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..923cc9da 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 @@ -427,55 +427,43 @@ defmodule Hypatia.Kin.Gate do end end - # Check 6: If neural networks are available, check consensus - defp check_neural_consensus(action, _state) do + # 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 \\ &Hypatia.Neural.Coordinator.dispatch_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 + case predictor.(finding) do + %{confidence: neural_conf} = recommendation when is_number(neural_conf) -> + cond do + not Hypatia.Neural.SourceStore.ready_report?(recommendation) -> + neural_unavailable_hold() + + abs(neural_conf - Map.get(action, :confidence, 0.0)) < 0.15 -> + :pass + + true -> + {:hold, "neural network disagrees with dispatch confidence -- human review recommended"} + end + + _ -> + neural_unavailable_hold() + end + rescue + _ -> neural_unavailable_hold() + catch + :exit, _ -> neural_unavailable_hold() end else :pass end end - defp check_neural_available do - case Process.whereis(Hypatia.Neural.Coordinator) do - nil -> false - pid -> Process.alive?(pid) - end - end - - defp neural_agrees?(action) do - try do - finding = %{"id" => action.pattern_id, "category" => "unknown"} - - 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 - - # if prediction fails, don't block - _ -> - true - end - rescue - _ -> true - catch - :exit, _ -> true - end + defp neural_unavailable_hold do + {:hold, "current-source neural assessment unavailable -- human review required"} end # --- Helpers --- diff --git a/lib/learning_scheduler.ex b/lib/learning_scheduler.ex index 9d17c163..6bf74ada 100644 --- a/lib/learning_scheduler.ex +++ b/lib/learning_scheduler.ex @@ -311,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 ------------------------------------- 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 d4fe52ba..941aaace 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,65 +90,95 @@ 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 is derived from today's qualified source, not a learned weight - # cache. A persisted score can outlive both its evidence and its model. - trust_graph = GraphOfTrust.build() - - # 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, blocked} -> + Logger.warning("Neural initialization refused: #{inspect(reason)}") + blocked + end + notes = [ - rbf_restore_note(rbf), - moe_restore_note(moe, persisted), - trust_rebuild_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] @@ -164,8 +198,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( @@ -326,35 +360,47 @@ 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 """ + Queue an outcome for this source-bound lifecycle. + + :ok acknowledges queue admission, not completed learning. Refused admission + returns {:error, reason}; a later execution refusal remains visible in status. + """ + def record_outcome(finding, outcome, server \\ __MODULE__) do + GenServer.call(server, {:queue, SourceStore.current(), {:outcome, finding, outcome}}) 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. """ - def dispatch_recommendation(finding) do - GenServer.call(__MODULE__, {:dispatch_rec, finding}, 30_000) + def dispatch_recommendation(finding, server \\ __MODULE__) do + GenServer.call(server, {:dispatch_rec, SourceStore.current(), finding}, 30_000) + 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." @@ -366,7 +412,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, @@ -386,82 +540,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, @@ -486,23 +580,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 @@ -523,18 +637,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: @@ -554,11 +670,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 @@ -570,9 +687,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 ───────────────────────── @@ -580,10 +697,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) @@ -598,13 +715,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} @@ -623,8 +740,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 @@ -632,7 +749,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) @@ -643,16 +760,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 @@ -666,7 +783,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 @@ -674,14 +791,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) @@ -691,12 +809,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) @@ -707,28 +825,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 a64371ef..3909fc7d 100644 --- a/lib/neural/graph_of_trust.ex +++ b/lib/neural/graph_of_trust.ex @@ -48,22 +48,36 @@ defmodule Hypatia.Neural.GraphOfTrust do 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. Qualified negative evidence + 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(load_all_outcomes(), load_all_recipes()) + 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 = - Enum.filter(outcomes, fn outcome -> - is_map(outcome) and OutcomeTracker.qualified_outcome?(outcome) + 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) @@ -73,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, @@ -282,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) @@ -308,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 @@ -337,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, @@ -369,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 = @@ -379,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 @@ -391,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(Hypatia.Paths.scans() |> Path.expand(), "#{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} -> @@ -410,8 +436,8 @@ defmodule Hypatia.Neural.GraphOfTrust do end end - defp load_all_outcomes do - outcomes_dir = Hypatia.Paths.outcomes() |> Path.expand() + defp load_all_outcomes(source) do + outcomes_dir = Path.join(source, "outcomes") case File.ls(outcomes_dir) do {:ok, files} -> @@ -439,8 +465,8 @@ defmodule Hypatia.Neural.GraphOfTrust do end end - defp load_all_recipes do - recipes_dir = Hypatia.Paths.recipes() |> Path.expand() + 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 d4977b91..9fc9f701 100644 --- a/lib/neural/persistence.ex +++ b/lib/neural/persistence.ex @@ -17,52 +17,140 @@ defmodule Hypatia.Neural.Persistence do On startup: 1. Load saved state from flat files - 2. Initialize learned 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 - @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", @@ -78,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), @@ -103,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) @@ -119,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) @@ -135,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, @@ -151,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(Hypatia.Paths.neural_states() |> Path.expand(), "#{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(Hypatia.Paths.neural_states() |> Path.expand(), "#{network}.json") + defp load_state(source, network) do + path = Path.join([source, "neural-states", "#{network}.json"]) case File.read(path) do {:ok, content} -> @@ -198,9 +258,4 @@ defmodule Hypatia.Neural.Persistence do :not_found end end - - defp ensure_state_dir do - dir = Hypatia.Paths.neural_states() |> Path.expand() - File.mkdir_p(dir) - end 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..eb7d4357 --- /dev/null +++ b/lib/neural/source_store.ex @@ -0,0 +1,180 @@ +# 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. + + 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 + 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 b47ea230..2f3c4c0d 100644 --- a/lib/neural/training_pipeline.ex +++ b/lib/neural/training_pipeline.ex @@ -44,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.expand(Paths.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 @@ -79,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 @@ -147,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") @@ -177,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, Paths.verisimdb_data()) + data_path = Keyword.get(rebalance_opts, :data_path, data_root) templates = Rebalancer.load_failure_templates(data_path) if templates == [] do @@ -241,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(Paths.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 @@ -305,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 @@ -343,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 4bd6f874..3e386fc2 100644 --- a/lib/outcome_tracker.ex +++ b/lib/outcome_tracker.ex @@ -899,8 +899,8 @@ defmodule Hypatia.OutcomeTracker do end end - defp load_outcomes_for_recipe(recipe_id) do - outcomes_dir = Path.expand(Paths.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} -> @@ -978,6 +978,26 @@ defmodule Hypatia.OutcomeTracker do 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. diff --git a/test/learning_runtime_store_test.exs b/test/learning_runtime_store_test.exs index 3f64f0d5..5e980200 100644 --- a/test/learning_runtime_store_test.exs +++ b/test/learning_runtime_store_test.exs @@ -13,7 +13,8 @@ defmodule Hypatia.LearningRuntimeStoreTest do Persistence, RadialNeuralNetwork, Rebalancer, - TrainingPipeline + TrainingPipeline, + SourceStore } alias Hypatia.VCL.{Client, FileExecutor} @@ -200,7 +201,8 @@ defmodule Hypatia.LearningRuntimeStoreTest do [first_pattern] = Map.values(first_registry["patterns"]) assert first_pattern["triangle_tier"] == "control" assert first_pattern["repos_affected_list"] == ["first"] - assert {[first_vector], [0.0]} = TrainingPipeline.build_rbf_training_data() + 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")) @@ -220,7 +222,8 @@ defmodule Hypatia.LearningRuntimeStoreTest do 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], [0.0]} = TrainingPipeline.build_rbf_training_data() + assert {[^first_vector], restored_targets} = TrainingPipeline.build_rbf_training_data() + assert restored_targets == [0.0] end test "corpus templates follow runtime defaults and retain an explicit store override", ctx do @@ -384,7 +387,7 @@ defmodule Hypatia.LearningRuntimeStoreTest do write_json(Path.join([ctx.second, "recipes", "recipe-second.json"]), %{"id" => "recipe-second"}) first_state = neural_state("first") - Persistence.save_all(first_state) + 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} @@ -394,7 +397,7 @@ defmodule Hypatia.LearningRuntimeStoreTest do Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) assert %{trust: :not_found, rbf: :not_found} = Persistence.load_all() second_state = neural_state("second") - Persistence.save_all(second_state) + 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"} @@ -512,13 +515,21 @@ defmodule Hypatia.LearningRuntimeStoreTest do 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 + cycle_count: 0, + named_networks: networks } + |> Map.merge(SourceStore.fields()) end end diff --git a/test/neural_source_store_lifecycle_test.exs b/test/neural_source_store_lifecycle_test.exs new file mode 100644 index 00000000..50b54559 --- /dev/null +++ b/test/neural_source_store_lifecycle_test.exs @@ -0,0 +1,585 @@ +# 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, + 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 + + 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} + 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) + assert {:error, _} = 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 "all named snapshots must be available before the first persistence write", ctx do + {server, cohort} = coordinator() + state = :sys.get_state(server) + sequence = Keyword.fetch!(cohort, :named_networks).sequence + GenServer.stop(sequence) + + assert {:error, {:cohort_source_mismatch, :sequence, _}} = Persistence.save_all(state) + refute File.exists?(Path.join(ctx.first, "neural-states")) + assert {:error, _} = Coordinator.status(server) + assert :sys.get_state(server).source_status == :blocked + 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} + 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} + ] 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 + + assert Monitor.finish_observations(observed, fn _ -> :ok end, fn _, _ -> + flunk("a source refusal must remain manual-only") + end) == observed + assert observed.recovery_attempts == %{} + 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 "Scheduler and dispatcher distinguish queue admission from refusal without actuation", 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 {:error, _} = FleetDispatcher.record_neural_outcome(@finding, "success", server) + 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 do + networks = + Map.new(Persistence.default_networks(), fn {name, module} -> + {name, isolated_process(module, [])} + end) + + [named_networks: networks, blackboard: isolated_process(Blackboard, [isolated: true])] + 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 index d1a8550f..401a9bf6 100644 --- a/test/neural_trust_restart_test.exs +++ b/test/neural_trust_restart_test.exs @@ -48,7 +48,7 @@ defmodule Hypatia.Neural.TrustRestartTest do log = ExUnit.CaptureLog.capture_log(fn -> - assert {:ok, state} = Coordinator.init([]) + 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") == @@ -78,10 +78,11 @@ defmodule Hypatia.Neural.TrustRestartTest do on_exit(fn -> File.rm_rf!(replacement) end) Application.put_env(:hypatia, :verisimdb_data_path, replacement) - assert {:ok, state} = Coordinator.init([]) + 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} - Persistence.save_all(state) + 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 @@ -95,7 +96,7 @@ defmodule Hypatia.Neural.TrustRestartTest do # 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([]) + 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 @@ -113,8 +114,24 @@ defmodule Hypatia.Neural.TrustRestartTest do }) <> "\n" ) - assert {:ok, supported} = Coordinator.init([]) + 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 From a7779ea794f02fb4edb4c4a7c68135e2f71689d7 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:21:28 +0000 Subject: [PATCH 17/21] fix(neural): retain dispatch caps and a single diagnostics recovery pass Require an explicit non-novel auto-execute recommendation with bounded matching confidence before Kin accepts the automatic tier. Preserve manual tiers and source refusals. Keep notification free of recovery side effects, leaving one filtered recovery pass. Extend the authored isolated lifecycle controls for capped and malformed recommendations and notification/recovery call counts. Runtime qualification is pending; this commit is an unreferenced source candidate. --- .../architecture/neural-source-lifecycle.adoc | 10 ++-- lib/hypatia/diagnostics/monitor.ex | 4 +- lib/kin/gate.ex | 7 ++- test/neural_source_store_lifecycle_test.exs | 47 +++++++++++++++++-- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/docs/architecture/neural-source-lifecycle.adoc b/docs/architecture/neural-source-lifecycle.adoc index eb20c6d5..fbea70d2 100644 --- a/docs/architecture/neural-source-lifecycle.adoc +++ b/docs/architecture/neural-source-lifecycle.adoc @@ -61,8 +61,10 @@ matching current-format trust snapshot exists. | Coordinator.analyze/1, dispatch_recommendation/1 | Source-labelled analysis/recommendation map; optional server argument supports an isolated actual cohort. -| {:error, reason}. Kin holds auto-execute for human review; manual tiers retain -their existing route. PatternAnalyzer already treats non-map replies as +| {: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, record_outcome/2 @@ -100,7 +102,9 @@ 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. Source-lifecycle failures are excluded from automatic recovery, +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. diff --git a/lib/hypatia/diagnostics/monitor.ex b/lib/hypatia/diagnostics/monitor.ex index 641aca0b..8117cf0e 100644 --- a/lib/hypatia/diagnostics/monitor.ex +++ b/lib/hypatia/diagnostics/monitor.ex @@ -268,8 +268,8 @@ defmodule Hypatia.Diagnostics.Monitor do # Log for persistence Logger.error("DIAGNOSTICS ALERT: #{inspect(reason)}") - # Source changes need an operator decision; notification is not restart authority. - unless source_lifecycle_failure?(reason), do: attempt_recovery(reason) + # Notification has no recovery side effect. finish_observations filters + # source refusals before the sole attempt_recoveries path. end) end diff --git a/lib/kin/gate.ex b/lib/kin/gate.ex index 923cc9da..1e90887a 100644 --- a/lib/kin/gate.ex +++ b/lib/kin/gate.ex @@ -437,11 +437,16 @@ defmodule Hypatia.Kin.Gate do finding = %{"id" => action.pattern_id, "category" => "unknown"} case predictor.(finding) do - %{confidence: neural_conf} = recommendation when is_number(neural_conf) -> + %{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() + Map.get(recommendation, :strategy) != :auto_execute or + Map.get(recommendation, :is_novel) != false -> + {:hold, "neural assessment requires manual review -- automation cap retained"} + abs(neural_conf - Map.get(action, :confidence, 0.0)) < 0.15 -> :pass diff --git a/test/neural_source_store_lifecycle_test.exs b/test/neural_source_store_lifecycle_test.exs index 50b54559..f0b2822b 100644 --- a/test/neural_source_store_lifecycle_test.exs +++ b/test/neural_source_store_lifecycle_test.exs @@ -337,7 +337,13 @@ defmodule Hypatia.Neural.SourceStoreLifecycleTest do 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} + 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 <- [ @@ -346,7 +352,19 @@ defmodule Hypatia.Neural.SourceStoreLifecycleTest do %{confidence: 0.99}, %{ready | source_store: ctx.second}, %{ready | source_status: :blocked}, - %{ready | confidence: 0.5} + %{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 @@ -427,10 +445,29 @@ defmodule Hypatia.Neural.SourceStoreLifecycleTest do end) == observed end - assert Monitor.finish_observations(observed, fn _ -> :ok end, fn _, _ -> - flunk("a source refusal must remain manual-only") - end) == observed + 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 From c193ed6f5e591d4cf1d98fad70a335963d0e0e0a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:22:33 +0000 Subject: [PATCH 18/21] test(neural): trace source lifecycle controls in the focused gate --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 55103374..8d7f9c2c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -117,6 +117,7 @@ jobs: 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 \ From 9eb5b2dfc07f193cfcf3576ac0c15e5caa43e626 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:39:15 +0000 Subject: [PATCH 19/21] fix(outcomes): bind confidence and annealing writes to one captured store Capture the data root and effective annealing override once across the raw append and derived updates. Reuse qualified counts, refuse observed source changes and write failures, and keep diagnostic reconciliation read-only. Preserve the committed-event contract when a derived update remains pending. Add eight fixture-file regressions for source changes, overrides, write refusal and read-only behavior. These controls are authored but unexecuted; hosted compilation and tests must qualify this candidate before adoption. --- .../architecture/neural-source-lifecycle.adoc | 40 ++ lib/outcome_tracker.ex | 347 +++++++++++------- test/learning_runtime_store_test.exs | 218 +++++++++++ 3 files changed, 466 insertions(+), 139 deletions(-) diff --git a/docs/architecture/neural-source-lifecycle.adoc b/docs/architecture/neural-source-lifecycle.adoc index fbea70d2..f65e705d 100644 --- a/docs/architecture/neural-source-lifecycle.adoc +++ b/docs/architecture/neural-source-lifecycle.adoc @@ -122,6 +122,46 @@ 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. + == Qualification scope The regression source uses actual isolated Coordinator, Blackboard and named diff --git a/lib/outcome_tracker.ex b/lib/outcome_tracker.ex index 3e386fc2..7922e58a 100644 --- a/lib/outcome_tracker.ex +++ b/lib/outcome_tracker.ex @@ -114,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) @@ -129,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)}") @@ -367,103 +378,107 @@ 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 - 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 + raw_confidence = + if qualified_total == 0 do + base_confidence + else + bayesian_update(base_confidence, successes, failures + false_positives) + end - {:error, reason} -> - Logger.error("Failed to read recipe file: #{inspect(reason)}") - {:error, reason} - end + saved = read_saved_annealing_state(recipe_id, store) + annealing_state = reconcile_annealing_state(saved, counts) + + 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 @@ -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(Paths.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 @@ -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)) @@ -934,8 +951,8 @@ defmodule Hypatia.OutcomeTracker do end end - defp find_recipe_file(recipe_id) do - recipes_dir = Path.expand(Paths.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,8 +987,8 @@ 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) @@ -1014,31 +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 + + _ -> + ConfidenceAnnealing.from_existing( + counts.successes, + counts.failures, + counts.false_positives + ) + end - save_annealing_state(recipe_id, state) - state + 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), @@ -1062,20 +1089,25 @@ defmodule Hypatia.OutcomeTracker do 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) + # 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 - current + 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) do - case File.read(annealing_state_path(recipe_id)) do + 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} -> @@ -1090,10 +1122,8 @@ defmodule Hypatia.OutcomeTracker do 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, @@ -1106,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 @@ -1140,10 +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(Paths.annealing_states()), "#{safe_id}.json") + Path.join(store.annealing_dir, "#{safe_id}.json") end end diff --git a/test/learning_runtime_store_test.exs b/test/learning_runtime_store_test.exs index 5e980200..29321328 100644 --- a/test/learning_runtime_store_test.exs +++ b/test/learning_runtime_store_test.exs @@ -154,6 +154,195 @@ defmodule Hypatia.LearningRuntimeStoreTest do 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") @@ -408,6 +597,35 @@ defmodule Hypatia.LearningRuntimeStoreTest do 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") From 3e8d2af91c5c014a78ebaee3a543ceffbbac9225 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:03:43 +0000 Subject: [PATCH 20/21] fix(neural): bound inference waits and preserve queued source evidence Bound Kin's default predictor below its review deadline; keep peer outages retryable only after complete initialization. Submit outcomes without waiting behind training, retaining source and cohort checks at execution. Pin registry synchronization to one captured runtime root and return publication failures. Isolate global-root fixtures from background singleton timers and add busy-process, peer recovery, incomplete hydration, and registry preservation controls. Source candidate only; hosted qualification is still required. --- .../architecture/neural-source-lifecycle.adoc | 69 +++++- lib/fleet_dispatcher.ex | 1 + lib/kin/gate.ex | 13 +- lib/neural/coordinator.ex | 43 +++- lib/neural/source_store.ex | 5 + lib/pattern_registry.ex | 59 +++-- test/learning_runtime_store_test.exs | 62 +++++ test/neural_source_store_lifecycle_test.exs | 214 +++++++++++++++++- 8 files changed, 413 insertions(+), 53 deletions(-) diff --git a/docs/architecture/neural-source-lifecycle.adoc b/docs/architecture/neural-source-lifecycle.adoc index f65e705d..9b777734 100644 --- a/docs/architecture/neural-source-lifecycle.adoc +++ b/docs/architecture/neural-source-lifecycle.adoc @@ -14,9 +14,16 @@ 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 cohort blocks that lifecycle. Switching the configuration -back does not clear the refusal. An operator must inspect the source and make -an explicit decision before starting a fresh matching cohort. +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, @@ -32,10 +39,19 @@ 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. Queue admission and execution each check -the lifecycle; an admitted request can still be refused before execution if the -configuration changes. Old unbound casts are refused. Parallel phase errors are -returned to the guard instead of being discarded from an accepted trace. +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 @@ -60,19 +76,28 @@ matching current-format trust snapshot exists. | Coordinator.analyze/1, dispatch_recommendation/1 | Source-labelled analysis/recommendation map; optional server argument supports -an isolated actual cohort. +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, record_outcome/2 +| 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. FleetDispatcher's -neural notification returns :queued or an explicit error; its independent raw -observation log is not evidence of completed neural learning. +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 @@ -162,6 +187,16 @@ 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 @@ -170,6 +205,16 @@ 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: diff --git a/lib/fleet_dispatcher.ex b/lib/fleet_dispatcher.ex index 17f26710..8eb56f58 100644 --- a/lib/fleet_dispatcher.ex +++ b/lib/fleet_dispatcher.ex @@ -654,6 +654,7 @@ 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 diff --git a/lib/kin/gate.ex b/lib/kin/gate.ex index 1e90887a..5a9db7c3 100644 --- a/lib/kin/gate.ex +++ b/lib/kin/gate.ex @@ -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,11 +428,21 @@ defmodule Hypatia.Kin.Gate do end end + # 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 \\ &Hypatia.Neural.Coordinator.dispatch_recommendation/1) do + def neural_consensus(action, predictor \\ &bounded_recommendation/1) do if action.dispatch_tier == :auto_execute do try do finding = %{"id" => action.pattern_id, "category" => "unknown"} diff --git a/lib/neural/coordinator.ex b/lib/neural/coordinator.ex index 941aaace..5df1c7ff 100644 --- a/lib/neural/coordinator.ex +++ b/lib/neural/coordinator.ex @@ -116,9 +116,15 @@ defmodule Hypatia.Neural.Coordinator do {:ok, state} end) do {:ok, :ok, ready} -> ready - {:error, reason, blocked} -> + {:error, reason, incomplete} -> Logger.warning("Neural initialization refused: #{inspect(reason)}") - blocked + # 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 = [ @@ -368,13 +374,30 @@ defmodule Hypatia.Neural.Coordinator do end @doc """ - Queue an outcome for this source-bound lifecycle. - - :ok acknowledges queue admission, not completed learning. Refused admission - returns {:error, reason}; a later execution refusal remains visible in status. + 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 - GenServer.call(server, {:queue, SourceStore.current(), {:outcome, finding, outcome}}) + 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 """ @@ -383,9 +406,11 @@ defmodule Hypatia.Neural.Coordinator do Unlike the old fixed-weight aggregation, confidence is derived from 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, server \\ __MODULE__) do - GenServer.call(server, {:dispatch_rec, SourceStore.current(), 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." diff --git a/lib/neural/source_store.ex b/lib/neural/source_store.ex index eb7d4357..7e6529ad 100644 --- a/lib/neural/source_store.ex +++ b/lib/neural/source_store.ex @@ -9,6 +9,8 @@ defmodule Hypatia.Neural.SourceStore do 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. @@ -67,6 +69,9 @@ defmodule Hypatia.Neural.SourceStore do 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 diff --git a/lib/pattern_registry.ex b/lib/pattern_registry.ex index 393f1e3f..68596b46 100644 --- a/lib/pattern_registry.ex +++ b/lib/pattern_registry.ex @@ -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(Paths.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(Paths.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/test/learning_runtime_store_test.exs b/test/learning_runtime_store_test.exs index 29321328..65fc93f5 100644 --- a/test/learning_runtime_store_test.exs +++ b/test/learning_runtime_store_test.exs @@ -33,6 +33,24 @@ defmodule Hypatia.LearningRuntimeStoreTest 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) @@ -415,6 +433,50 @@ defmodule Hypatia.LearningRuntimeStoreTest do 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") diff --git a/test/neural_source_store_lifecycle_test.exs b/test/neural_source_store_lifecycle_test.exs index f0b2822b..f1653a6e 100644 --- a/test/neural_source_store_lifecycle_test.exs +++ b/test/neural_source_store_lifecycle_test.exs @@ -17,6 +17,7 @@ defmodule Hypatia.Neural.SourceStoreLifecycleTest do LiquidStateMachine, Persistence, SourceStore, + SequenceModel, TrainingPipeline } @@ -39,6 +40,19 @@ defmodule Hypatia.Neural.SourceStoreLifecycleTest 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 -> @@ -46,7 +60,7 @@ defmodule Hypatia.Neural.SourceStoreLifecycleTest do File.rm_rf!(root) end) - %{root: root, first: first, second: second} + %{root: root, first: first, second: second, original: original} end test "source identity is an expanded pathname and never a content attestation", ctx do @@ -127,7 +141,9 @@ defmodule Hypatia.Neural.SourceStoreLifecycleTest do assert {:error, _} = Coordinator.analyze(@finding, server) assert {:error, _} = Coordinator.dispatch_recommendation(@finding, server) assert {:error, _} = Coordinator.force_cycle(server) - assert {:error, _} = Coordinator.record_outcome(@finding, "success", 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 @@ -231,16 +247,79 @@ defmodule Hypatia.Neural.SourceStoreLifecycleTest do end end - test "all named snapshots must be available before the first persistence write", ctx do - {server, cohort} = coordinator() - state = :sys.get_state(server) - sequence = Keyword.fetch!(cohort, :named_networks).sequence + 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, _}} = Persistence.save_all(state) + 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 @@ -490,7 +569,83 @@ defmodule Hypatia.Neural.SourceStoreLifecycleTest do assert {:error, :neural_crashed} = Monitor.check_neural({:invalid_server, :fixture}) end - test "Scheduler and dispatcher distinguish queue admission from refusal without actuation", ctx do + 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) + 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) @@ -500,7 +655,8 @@ defmodule Hypatia.Neural.SourceStoreLifecycleTest do Application.put_env(:hypatia, :verisimdb_data_path, ctx.second) refute LearningScheduler.trigger_neural_cycle(server) - assert {:error, _} = FleetDispatcher.record_neural_outcome(@finding, "success", 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")) @@ -553,15 +709,51 @@ defmodule Hypatia.Neural.SourceStoreLifecycleTest do end end - defp isolated_cohort do + defp isolated_cohort(overrides \\ %{}) do networks = Map.new(Persistence.default_networks(), fn {name, module} -> - {name, isolated_process(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} From e9feaca5a5040c1e38fb7b58312858439fd2a7e7 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:07:51 +0000 Subject: [PATCH 21/21] test(neural): drain timed-out prediction before restoring test roots --- test/neural_source_store_lifecycle_test.exs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/neural_source_store_lifecycle_test.exs b/test/neural_source_store_lifecycle_test.exs index f1653a6e..5ad998c8 100644 --- a/test/neural_source_store_lifecycle_test.exs +++ b/test/neural_source_store_lifecycle_test.exs @@ -625,6 +625,9 @@ defmodule Hypatia.Neural.SourceStoreLifecycleTest do 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