Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
615da6bd09 | ||
|
|
693a494884 | ||
|
|
ee653c6a4c | ||
|
|
171f3fb250 | ||
|
|
9731181a6b | ||
|
|
544c19719e | ||
|
|
d571658b5a | ||
|
|
50c997beab | ||
|
|
9e6215e605 | ||
|
|
6429c5bb49 | ||
|
|
ea34f2d5ab | ||
|
|
0eff96a335 | ||
|
|
de68444e50 | ||
|
|
d58e88da5c | ||
|
|
c730196dba | ||
|
|
c750513fab | ||
|
|
27c4bf8d78 | ||
|
|
5b8023ca2a | ||
|
|
57eaa8f383 | ||
|
|
567fd39937 | ||
|
|
817a97abd9 | ||
|
|
1e6cb4d1ba | ||
|
|
4e11aa4268 | ||
|
|
1703c25367 | ||
|
|
7cc47de6de | ||
|
|
e93315d156 | ||
|
|
be49fb32d1 | ||
|
|
c41ed97373 | ||
|
|
558401844e | ||
|
|
26e4929b10 | ||
|
|
1462262476 | ||
|
|
0bf47265c8 | ||
|
|
02a9dc4a31 | ||
|
|
0f3aeff58b | ||
|
|
ff2abcd502 | ||
|
|
b61af7a526 | ||
|
|
497d14a449 | ||
|
|
2e68b6b054 | ||
|
|
969e39f6a3 | ||
|
|
273267763e | ||
|
|
8973caae77 | ||
|
|
3450a1cb83 | ||
|
|
9cb93e6573 | ||
|
|
4b0d1d24d2 | ||
|
|
114c394741 | ||
|
|
7c68e41a63 | ||
|
|
2ac2d0aefd | ||
|
|
6f44a87894 | ||
|
|
485623dbc4 | ||
|
|
43e7f5f7d2 | ||
|
|
54d2db97b0 | ||
|
|
523a859656 | ||
|
|
9b765852b8 | ||
|
|
f1e63b703e | ||
|
|
6d40f94f18 | ||
|
|
7a869476be | ||
|
|
e5292cce7b | ||
|
|
396ae120dc | ||
|
|
01b57297b8 | ||
|
|
497db5a144 | ||
|
|
9425ccc1f4 | ||
|
|
3ed547f86f | ||
|
|
5bc837c232 | ||
|
|
08bc1a6b62 | ||
|
|
201ca05b56 | ||
|
|
5cd90f2104 | ||
|
|
c8bc454e33 | ||
|
|
b81edb2029 | ||
|
|
87a890dce3 | ||
|
|
a0821be8a5 | ||
|
|
cb137bd37a | ||
|
|
72afb1a55a | ||
|
|
c9095f5af5 | ||
|
|
b5422f0772 | ||
|
|
da36b99184 | ||
|
|
873d0025ad | ||
|
|
a9ce967557 | ||
|
|
eb3a2c681c | ||
|
|
1bc1cdeaf0 | ||
|
|
31565844d7 | ||
|
|
c08b211f0e | ||
|
|
46c5032208 | ||
|
|
92584daeef | ||
|
|
bb5e243c88 | ||
|
|
ac936a8ba9 | ||
|
|
e6c8c5444d | ||
|
|
11270cb06a | ||
|
|
f8a568714b | ||
|
|
c3f81e60ec | ||
|
|
efb99d4682 | ||
|
|
489718ab1b | ||
|
|
9db580ac6d | ||
|
|
80ed978fbf | ||
|
|
3f119e50d8 | ||
|
|
517379313b | ||
|
|
643f9aa9d1 | ||
|
|
61a5082633 | ||
|
|
9121bbd8b0 | ||
|
|
cba9045982 | ||
|
|
0c92f482a7 | ||
|
|
3120e8ae08 | ||
|
|
ea3c20e205 | ||
|
|
a93327d107 | ||
|
|
e08625f629 | ||
|
|
021fef2065 | ||
|
|
3788875410 | ||
|
|
d96b89bd4c | ||
|
|
fe45c6a8dd | ||
|
|
c7b2edd4fc |
No files matched your search
@@ -15,6 +15,9 @@
|
||||
# Canary files are omitted: src/rune/canary_*.py are standalone CI
|
||||
# verification scripts executed directly by the canary job, never imported by
|
||||
# the test suite — counting them would make the global floor a fiction.
|
||||
# ffi_query_memory_smoke.py is the same class: a standalone evidence script
|
||||
# driven directly (and by tests/test_ffi_query_memory_smoke.py when the cdylib
|
||||
# is built), never imported by the suite.
|
||||
|
||||
[run]
|
||||
source = src/rune
|
||||
@@ -22,6 +25,7 @@ omit =
|
||||
tests/*
|
||||
scripts/*
|
||||
src/rune/canary_*.py
|
||||
src/rune/ffi_query_memory_smoke.py
|
||||
|
||||
[report]
|
||||
fail_under = 29
|
||||
|
||||
@@ -1,16 +1,46 @@
|
||||
---
|
||||
name: Bug Fix
|
||||
about: Fix a bug or issue
|
||||
about: Demonstrate currently incorrect behaviour — plain English first, proof required
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
## Plain summary
|
||||
<!-- 2-4 sentences, NON-TECHNICAL: what's broken and why it matters. -->
|
||||
<restate the issue title here in everyday words — this heading IS the
|
||||
plain-English summary; no meta-labels like "plain English" in it>
|
||||
<!-- The heading above carries the summary itself — a scan of issue
|
||||
headings alone should tell the whole story of the board. TOP PRIORITY:
|
||||
if a technical detail below contradicts this section, the issue is
|
||||
buggy — fix the issue. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
## Example
|
||||
<!-- A concrete before/after: "Doing X currently produces Y; it should produce Z." -->
|
||||
|
||||
What is broken: <what a person observes going wrong>
|
||||
|
||||
What should happen instead: <the correct behaviour>
|
||||
|
||||
How often / when: <always | after X | since change Y>
|
||||
|
||||
## Proof it is broken (required — no proof, no bug)
|
||||
<!-- A bug is DEMONSTRATED currently incorrect behaviour. Nothing else.
|
||||
If this section cannot be filled, file it as a question, not a bug. -->
|
||||
- [ ] Reproduction command or steps (deterministic where possible)
|
||||
- [ ] The exact wrong output observed: <paste/log excerpt>
|
||||
- [ ] Where the evidence lives: <log path, fixture, session id>
|
||||
|
||||
## Steps to reproduce
|
||||
<!-- How to trigger the bug -->
|
||||
|
||||
## What would let us close this
|
||||
- [ ] The reproduction command now shows correct behaviour
|
||||
- [ ] A regression test exists that fails before the fix and passes after
|
||||
- [ ] No other behaviour changed (or changes are listed and justified)
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
<!-- A bug fix is USUALLY a patch. Pick major or minor only with a reason. -->
|
||||
- [ ] patch — same behaviour, done correctly (usual for bugs)
|
||||
- [ ] major — the bug was in a published contract and fixing it breaks consumers (justify)
|
||||
- [ ] minor — fixing revealed a missing capability that had to be added (justify)
|
||||
|
||||
## Technical details
|
||||
<!-- WHAT (the bug) / WHERE (files) / EVIDENCE (how it was confirmed). -->
|
||||
@@ -29,10 +59,6 @@ assignees: conductor
|
||||
- [ ] Canary passes (name)
|
||||
- [ ] Closed with a properly formatted closing comment + commit references
|
||||
|
||||
## Steps to reproduce
|
||||
<!-- How to trigger the bug -->
|
||||
|
||||
|
||||
## Cognitive invariants (memory/cognition issues — both handoffs)
|
||||
<!-- Complete ONLY if this issue touches memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before creating modules)
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: New feature or improvement — DESCRIPTION GATE v4 format (plain summary + pipeline requirements)
|
||||
about: New feature or improvement — plain English first, verifiable close conditions, version intent
|
||||
title: "[FEATURE] "
|
||||
labels: feature
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
## Plain summary
|
||||
<!-- 2-4 sentences, NON-TECHNICAL: what this does and why it matters. Nick should
|
||||
understand the goal without decoding internals. -->
|
||||
<restate the issue title here in everyday words — this heading IS the
|
||||
plain-English summary; no meta-labels like "plain English" in it>
|
||||
<!-- The heading above carries the summary itself — a scan of issue
|
||||
headings alone should tell the whole story of the board. TOP PRIORITY:
|
||||
if a technical detail below contradicts this section, the issue is
|
||||
buggy — fix the issue. A non-technical reader must be able to retell this
|
||||
section. 3-6 sentences, no jargon. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
What is this about: <what>
|
||||
|
||||
Why do we need it: <the itch — what happens today that shouldn't, or what becomes possible>
|
||||
|
||||
Why is it still open: <"not started yet" | "part done, X remains" | "deliberately parked as a future idea">
|
||||
|
||||
## Example
|
||||
<!-- A concrete example of the intended behavior, in plain terms:
|
||||
"If X happens, the system will Y instead of Z." -->
|
||||
<!-- A concrete before/after in plain terms: "If X happens, the system will Y instead of Z." -->
|
||||
|
||||
## What would let us close this
|
||||
<!-- Checkable statements, verifiable by looking at something that EXISTS —
|
||||
a file, a log line, a passing test. Not "when appropriate". -->
|
||||
- [ ] <condition 1 — observable>
|
||||
- [ ] <condition 2>
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
<!-- The pipeline derives the real bump from merged conventional commits and
|
||||
compares against this declaration. A mismatch is flagged, not fatal:
|
||||
either the declaration was wrong or the work grew. Both facts matter. -->
|
||||
- [ ] major — breaking: changes a contract, schema, or interface others depend on
|
||||
- [ ] minor — new capability: something becomes possible that wasn't
|
||||
- [ ] patch — repair: same behaviour, done correctly
|
||||
- [ ] none — touches no released artifact (docs, memory, process notes)
|
||||
|
||||
## Technical details
|
||||
<!-- WHAT — one-line objective.
|
||||
@@ -37,7 +63,6 @@ DEPENDENCIES — parent epic + preceding stage/plan refs. -->
|
||||
- [ ] Canary passes (name)
|
||||
- [ ] Closed with a properly formatted closing comment + commit/PR references
|
||||
|
||||
|
||||
## Cognitive invariants (memory/cognition issues — both handoffs)
|
||||
<!-- Complete ONLY if this issue touches memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before creating modules)
|
||||
@@ -69,4 +94,4 @@ Hard invariants: derived cognition preserves evidentiary ancestry · epistemic c
|
||||
- [ ] **P0 — critical** — breaks the spine / core correctness, or blocks other work
|
||||
- [ ] **P1 — high** — material user value or a real defect (money, correctness bugs, next-stage dependency)
|
||||
- [ ] **P2 — medium** — quality/consolidation improvement (check the fold-into mapping first)
|
||||
- [ ] **P3 — low** — measurement / benchmark / deferred policy
|
||||
- [ ] **P3 — low** — measurement / benchmark / deferred policy
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: Research Submission
|
||||
about: Buy down uncertainty before building — question, falsifier, budget, durable findings
|
||||
title: "[RESEARCH] "
|
||||
labels: research
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
<restate the issue title here in everyday words — this heading IS the
|
||||
plain-English summary; no meta-labels like "plain English" in it>
|
||||
<!-- The heading above carries the summary itself — a scan of issue
|
||||
headings alone should tell the whole story of the board. TOP PRIORITY:
|
||||
if a technical detail below contradicts this section, the issue is
|
||||
buggy — fix the issue. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
The question: <what we're trying to learn, one sentence>
|
||||
|
||||
Why we're asking: <what decision this research will inform — a name, an issue
|
||||
number, or "exploratory: none yet">
|
||||
|
||||
What we believe today: <the current assumption, stated so it can be proven wrong>
|
||||
|
||||
## The shape of an answer
|
||||
- [ ] What evidence would settle the question: <observation, benchmark, or spike>
|
||||
- [ ] What evidence would KILL the current assumption (the falsifier): <be specific —
|
||||
this is stated BEFORE starting so the research cannot become a search for confirmation>
|
||||
- [ ] Budget: <time/effort cap — when research must stop and report back>
|
||||
|
||||
## What would let us close this
|
||||
- [ ] Findings written to a durable document: <path, e.g. research/issue-NNN/> —
|
||||
a comment thread is not a durable finding
|
||||
- [ ] The decision the research informs is recorded: made / deferred / changed
|
||||
- [ ] If a follow-up build issue was minted: <#N or "none needed">
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
- [ ] none — research changes no released artifact (usual)
|
||||
- [ ] minor/major — only if a code spike is being merged as part of this (justify)
|
||||
|
||||
## Technical detail
|
||||
<!-- Candidate approaches, prior art in the repo, related issues, constraints,
|
||||
rebuildable-projection or authority requirements if findings touch canonical state. -->
|
||||
|
||||
## Pipeline requirements
|
||||
- [ ] Findings committed under the stated durable path
|
||||
- [ ] Every citation verified against a real source (no fabricated references)
|
||||
- [ ] Closed with a properly formatted closing comment: findings summary + decision
|
||||
|
||||
## Cognitive invariants (memory/cognition research — both handoffs)
|
||||
<!-- Complete ONLY if findings touch memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before proposing modules)
|
||||
- [ ] **Read/write authority** — which canonical state is affected
|
||||
- [ ] **Provenance** — findings traceable to evidence
|
||||
- [ ] **Reversibility** — projections remain rebuildable; no unique truth moves into a derived index
|
||||
- [ ] **No duplicate authority** — why extend vs create
|
||||
|
||||
Hard invariants: derived cognition preserves evidentiary ancestry · retrieval never increases utility · raw evidence is never destroyed by promotion.
|
||||
|
||||
## Related umbrella
|
||||
- [ ] Memory
|
||||
- [ ] Governance
|
||||
- [ ] Identity
|
||||
- [ ] Growth
|
||||
- [ ] Perception
|
||||
- [ ] Infrastructure
|
||||
- [ ] Life (Nick's)
|
||||
- [ ] Custodian
|
||||
- [ ] Orchestration
|
||||
|
||||
## Priority (REQUIRED — the priority gate)
|
||||
<!-- Pick exactly one. An issue without a priority is not filed. -->
|
||||
- [ ] **P0 — critical** — blocks a decision that blocks other work
|
||||
- [ ] **P1 — high** — informs a next-stage dependency
|
||||
- [ ] **P2 — medium** — quality/consolidation understanding
|
||||
- [ ] **P3 — low** — watchlist / deferred / exploratory
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
backup:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
|
||||
@@ -11,8 +11,8 @@ jobs:
|
||||
# P0/P1/P2/P3. An issue that lands without one gets 'unprioritized' +
|
||||
# a demand comment — filing is not complete until prioritized.
|
||||
priority-gate:
|
||||
if: github.event_name == 'issues' && github.event.action != 'labeled'
|
||||
runs-on: rune-infra-pi
|
||||
if: github.event_name == 'issues' && github.event.action != 'labeled' && github.event.issue.state == 'open'
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- name: Enforce priority label
|
||||
uses: actions/github-script@v7
|
||||
@@ -44,12 +44,26 @@ jobs:
|
||||
});
|
||||
|
||||
auto-label:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- name: Analyze and label
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// CLOSED-ISSUE GUARD (#407): labels are filing-time metadata.
|
||||
// A closed issue's lifecycle has ended; re-running label
|
||||
// inference on it re-adds stale type labels (bug = currently
|
||||
// incorrect behavior — a closed fixed issue must not carry
|
||||
// it) and makes any human label cleanup self-defeating,
|
||||
// because every PUT fires a 'labeled' event that re-triggers
|
||||
// this job. Post-close label curation belongs to humans and
|
||||
// the conductor, not this workflow.
|
||||
const issueState = context.payload.issue?.state;
|
||||
if (issueState === 'closed') {
|
||||
console.log(`Issue #${context.payload.issue?.number} is closed — auto-label skips (labels are curated post-close).`);
|
||||
return;
|
||||
}
|
||||
const action = context.payload.action;
|
||||
const title = context.payload.issue?.title || context.payload.pull_request?.title || '';
|
||||
const body = context.payload.issue?.body || context.payload.pull_request?.body || '';
|
||||
const text = (title + ' ' + body).toLowerCase();
|
||||
@@ -58,9 +72,43 @@ jobs:
|
||||
const managedLabels = new Set([
|
||||
'memory', 'governance', 'identity', 'growth',
|
||||
'perception', 'infrastructure', 'life', 'custodian',
|
||||
'priority: high', 'bug', 'feature',
|
||||
'priority: high', 'bug', 'feature', 'research',
|
||||
]);
|
||||
|
||||
// Type label: DECLARED over GUESSED. The template spine
|
||||
// (kind: bug|feature|research|epic|...) is authoritative.
|
||||
// A recognized kind maps to its label; ANY other declared kind
|
||||
// (e.g. epic) means NO type label and NO keyword guessing —
|
||||
// epics are containers, not bugs. Keyword heuristics apply
|
||||
// only to pre-template issues with no spine at all.
|
||||
let isBug = false, isFeature = false, isResearch = false;
|
||||
const kindMatch = body && body.match(/^kind:\s*([a-z_-]+)\s*$/m);
|
||||
if (kindMatch) {
|
||||
const kind = kindMatch[1];
|
||||
isBug = kind === 'bug';
|
||||
isFeature = kind === 'feature';
|
||||
isResearch = kind === 'research';
|
||||
} else if (action === 'opened') {
|
||||
// KEYWORD FALLBACK (#407): pre-template issues only, and
|
||||
// ONLY at filing time. Re-running keyword guesses on every
|
||||
// edit/label event overrides human curation of spine-less
|
||||
// issues (documented rule: keyword heuristics are the
|
||||
// pre-template fallback, not a standing authority).
|
||||
const textBug = title.startsWith('[BUG]') || text.includes('bug') || text.includes('fix') || text.includes('broken');
|
||||
isBug = textBug;
|
||||
isFeature = !isBug && (title.startsWith('[FEATURE]') || text.includes('feature') || text.includes('improve') || text.includes('add'));
|
||||
isResearch = !isBug && !isFeature && title.startsWith('[RESEARCH]');
|
||||
}
|
||||
if (isBug) {
|
||||
desiredLabels.add('bug');
|
||||
}
|
||||
if (isFeature) {
|
||||
desiredLabels.add('feature');
|
||||
}
|
||||
if (isResearch) {
|
||||
desiredLabels.add('research');
|
||||
}
|
||||
|
||||
// Umbrella labels
|
||||
if (text.includes('memory') || text.includes('store') || text.includes('recall') || text.includes('consolidat')) {
|
||||
desiredLabels.add('memory');
|
||||
@@ -92,16 +140,6 @@ jobs:
|
||||
desiredLabels.add('priority: high');
|
||||
}
|
||||
|
||||
// Type labels
|
||||
const isBug = title.startsWith('[BUG]') || text.includes('bug') || text.includes('fix') || text.includes('broken');
|
||||
const isFeature = !isBug && (title.startsWith('[FEATURE]') || text.includes('feature') || text.includes('improve') || text.includes('add'));
|
||||
if (isBug) {
|
||||
desiredLabels.add('bug');
|
||||
}
|
||||
if (isFeature) {
|
||||
desiredLabels.add('feature');
|
||||
}
|
||||
|
||||
const issueNumber = context.payload.issue?.number || context.payload.pull_request?.number;
|
||||
const current = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner,
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
update-gitea:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- name: Check current Gitea version
|
||||
id: current
|
||||
|
||||
@@ -11,7 +11,7 @@ permissions: write-all
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -131,8 +131,12 @@ jobs:
|
||||
"pyproject.toml": [(f'version = "{VERSION}"', f'version = "{NEW}"')],
|
||||
}
|
||||
|
||||
# Fetch current master content, apply replacements, VERIFY all five
|
||||
# surfaces agree on NEW before committing anything (TUI gate).
|
||||
# Fetch current master content, apply replacements, VERIFY all six
|
||||
# surface patterns (five files + the TUI footer) agree on NEW before
|
||||
# committing anything (TUI gate). The footer check was added after
|
||||
# the 0.89.0 half-bump: a stale RELEASE_VERSION made both replace
|
||||
# pairs no-op; the gate's RECOVER regex fixed the header only and
|
||||
# the footer silently stayed 0.88.4, failing the banner test.
|
||||
edited = {}
|
||||
for path, pairs in surfaces.items():
|
||||
status, cur = api(f"/contents/{path}?ref=master")
|
||||
@@ -151,6 +155,7 @@ jobs:
|
||||
checks = [
|
||||
("src/rune/__init__.py", rf'__version__ = "({VER})"'),
|
||||
("src/rune/rune_tui.py", rf"Version: v({VER})"),
|
||||
("src/rune/rune_tui.py", rf"Rune v({VER}) \|"),
|
||||
("src/rune/canary_gitea_automation.py", rf"'__version__ = \"({VER})\"'"),
|
||||
("pyproject.toml", rf'version = "({VER})"'),
|
||||
("README.md", rf"v({VER})"),
|
||||
|
||||
@@ -67,6 +67,17 @@ jobs:
|
||||
unit-tests:
|
||||
runs-on: rune-ci-desktop
|
||||
if: ${{ !startsWith(gitea.event.pull_request.head.ref, 'chore/auto-bump') }}
|
||||
env:
|
||||
RUNE_HOME: /tmp/rune-ci-${{ github.run_id }}
|
||||
RUNE_MEMORY_DIR: /tmp/rune-ci-${{ github.run_id }}/memory
|
||||
# The unit shim battery loads the cdylib (ep_loader); point it at the
|
||||
# runner-owned cdylib FILE from the crate-build step below (the override
|
||||
# expects a file path, not a directory).
|
||||
EP_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libevidence_provenance.so
|
||||
SP_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/librune_spine.so
|
||||
MS_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libmemory_scoring.so
|
||||
CS_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libcapability_scoring.so
|
||||
TC_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libtoken_counting.so
|
||||
steps:
|
||||
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
@@ -102,6 +113,22 @@ jobs:
|
||||
fi
|
||||
python3 scripts/umbrella_impact.py check \
|
||||
--base "$(git merge-base origin/master HEAD)" --head HEAD ${BODY_FILE:+--body-file "$BODY_FILE"}
|
||||
- name: Build Rust evidence_provenance crate
|
||||
run: |
|
||||
# tests/test_evidence_provenance_shims.py drives the cdylib through
|
||||
# the shim layer. Toolchain law (see the canary job for the full
|
||||
# rationale): system rustc is broken on this host (LLVM_23.1
|
||||
# version-node symbol gap); use the healthy rustup toolchain, PATH
|
||||
# first so the rustup shim can't exec the broken system rustc.
|
||||
# Runner-owned /tmp target dir (the repo's target/ is nick's).
|
||||
export PATH="/home/nick/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH"
|
||||
export CARGO_TARGET_DIR="/tmp/rune-ci-target-${{ github.run_id }}"
|
||||
mkdir -p "$CARGO_TARGET_DIR"
|
||||
cargo build --release --manifest-path src/rust/evidence_provenance/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/rune_spine/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/memory_scoring/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/capability_scoring/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/token_counting/Cargo.toml
|
||||
- name: Test with coverage (global floor + changed-lines ratchet)
|
||||
run: |
|
||||
# #149 coverage ratchet — both floors live in .coveragerc; raise,
|
||||
@@ -113,12 +140,42 @@ jobs:
|
||||
.test-venv/bin/coverage xml
|
||||
.test-venv/bin/diff-cover coverage.xml --compare-branch origin/master --fail-under=70 --diff-range-notation ..
|
||||
|
||||
go-surface:
|
||||
runs-on: rune-ci-desktop
|
||||
if: ${{ !startsWith(gitea.event.pull_request.head.ref, 'chore/auto-bump') }}
|
||||
steps:
|
||||
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- name: Verify Go toolchain version pin
|
||||
run: |
|
||||
# H0 pin: the Go surface builds against the pacman toolchain; the
|
||||
# pin file is the single source of truth so a distro upgrade that
|
||||
# changes behavior fails loud here, not downstream in the canary.
|
||||
WANT=$(cat go-version.txt)
|
||||
GOT=$(go version | awk '{print $3}' | sed 's/^go//' | cut -d- -f1)
|
||||
echo "pinned: $WANT | toolchain: $GOT"
|
||||
[ "$GOT" = "$WANT" ] || { echo "::error::Go toolchain drift: want $WANT, got $GOT (update go-version.txt with the rollout)"; exit 1; }
|
||||
- name: go vet + go test + go build
|
||||
run: |
|
||||
cd src/go/rune-entry
|
||||
go vet ./...
|
||||
go test ./...
|
||||
go build -o /tmp/rune-entry-ci .
|
||||
|
||||
canary:
|
||||
runs-on: rune-ci-desktop
|
||||
if: ${{ !startsWith(gitea.event.pull_request.head.ref, 'chore/auto-bump') }}
|
||||
env:
|
||||
RUNE_HOME: /tmp/rune-ci-${{ github.run_id }}
|
||||
RUNE_MEMORY_DIR: /tmp/rune-ci-${{ github.run_id }}/memory
|
||||
# The build emits to a runner-owned /tmp target dir (the repo's target/
|
||||
# is nick-owned); EP_RUST_LIB points ep_loader at the cdylib FILE there
|
||||
# (the override expects a file path, not a directory).
|
||||
EP_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libevidence_provenance.so
|
||||
SP_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/librune_spine.so
|
||||
MS_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libmemory_scoring.so
|
||||
CS_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libcapability_scoring.so
|
||||
TC_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libtoken_counting.so
|
||||
steps:
|
||||
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
@@ -130,6 +187,31 @@ jobs:
|
||||
python3 -m venv .canary-venv
|
||||
.canary-venv/bin/pip install --disable-pip-version-check -r requirements-ci.txt
|
||||
.canary-venv/bin/pip install --disable-pip-version-check -e . --no-deps
|
||||
- name: Build Rust evidence_provenance crate
|
||||
run: |
|
||||
# The canary pair + the unit shim battery drive the COMPILED surfaces
|
||||
# (binary CLI parity, ep_loader FFI, ctypes cdylib loads).
|
||||
#
|
||||
# Toolchain law (troubleshooting history): the SYSTEM rustc is broken
|
||||
# on this host — librustc_driver-<sha>.so (pacman rust 1:1.99.0-1)
|
||||
# references std::string::_M_mutate under the LLVM_23.1 version node,
|
||||
# but /usr/lib/libLLVM.so.23.1 does not define that narrow-char
|
||||
# symbol under that node (it exports _M_create/_M_assign there, and
|
||||
# only the WIDE-char _M_mutate) — every compile dies with `symbol
|
||||
# lookup error` while `cargo --version` still prints. The rustup
|
||||
# toolchain under /home/nick/.rustup is healthy and ships its own
|
||||
# libLLVM, and /home/nick carries the x-ACL for this runner user.
|
||||
# Point PATH at the toolchain bin dir (bypasses the rustup shim,
|
||||
# which would exec the broken system rustc via RUSTUP_TOOLCHAIN).
|
||||
# Target dir is runner-owned (/tmp): the repo's target/ is nick's.
|
||||
export PATH="/home/nick/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH"
|
||||
export CARGO_TARGET_DIR="/tmp/rune-ci-target-${{ github.run_id }}"
|
||||
mkdir -p "$CARGO_TARGET_DIR"
|
||||
cargo build --release --manifest-path src/rust/evidence_provenance/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/rune_spine/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/memory_scoring/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/capability_scoring/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/token_counting/Cargo.toml
|
||||
- name: Run canary suite
|
||||
run: |
|
||||
FAILED=0
|
||||
@@ -156,6 +238,15 @@ jobs:
|
||||
src/rune/canary_p1_06.py \
|
||||
src/rune/canary_p1_01.py \
|
||||
src/rune/canary_query_memory.py \
|
||||
src/rune/canary_rune_entry.py \
|
||||
src/rune/canary_spine_meta.py \
|
||||
src/rune/canary_audit_ledger.py \
|
||||
src/rust/rune_spine/canary_spine_rust.py \
|
||||
src/rune/canary_rune_serve.py \
|
||||
src/rune/canary_tui_transport.py \
|
||||
src/rust/memory_scoring/canary_memory_scoring.py \
|
||||
src/rust/capability_scoring/canary_capability_scoring.py \
|
||||
src/rust/token_counting/canary_token_counting.py \
|
||||
src/rune/canary_unbounded.py \
|
||||
src/rune/canary_full_system.py \
|
||||
src/rune/canary_native.py; do
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
lint-and-format:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Lint Python files
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
ruff format shared/ src/ || true
|
||||
|
||||
canary-suite:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run all canaries
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
exit $FAILED
|
||||
|
||||
best-practices:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check best practices
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
grep -rn "TODO\|FIXME\|HACK\|XXX" shared/ src/ --include="*.py" | head -10 || echo " Clean"
|
||||
|
||||
documentation-check:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check documentation coverage
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
# push-triggered safety net in auto-version.yml. Detects master/tag drift
|
||||
# and files a deduped issue — it never auto-releases without the PR flow.
|
||||
release-drift-reconciliation:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
@@ -58,3 +58,26 @@ fix, full pipeline re-entry (suite + ratchet + ruff + canaries + henxels;
|
||||
gates for anything beyond a mechanical minor), regression tests frozen forever,
|
||||
discharge recorded finding → test → commit. Symptom patches are rejected at
|
||||
review. To escape: edit `henxels.yaml` or this file — visibly.
|
||||
|
||||
## Issue organisation standard (binding, 2026-09-19)
|
||||
|
||||
One open note per idea. Issues use the template spine (kind, subsystem,
|
||||
version intent) with a plain-English heading that restates the subject —
|
||||
no meta-labels. Before minting a standalone issue, check whether the work
|
||||
belongs inside an existing parent:
|
||||
|
||||
- **Parked ideas under a blocked/future epic** are filed as checklist items
|
||||
in the parent's "Absorbed children" section, NOT as standalone issues.
|
||||
Absorption is reversible — re-mint standalone when the idea becomes live.
|
||||
- **Live, dependency-tracked, or starting work** gets a standalone issue.
|
||||
- **Closures state the reason as data**: `closed_because` in the spine
|
||||
(completed / superseded-by-#N / absorbed-into-#N / wont-do / duplicate),
|
||||
plus a standard closing comment (plain summary first).
|
||||
- The reconciliation checker (`scripts/gitea_reconciliation_check.py`) must
|
||||
stay green after any issue state change; the artifact is updated in the
|
||||
same change that moves issues.
|
||||
|
||||
## Async continuation final flow proofs (R13/M9) — 2026-09-19
|
||||
|
||||
PR-flow live proof marker: harmless documentation addition exercising the
|
||||
canonical single-continuation path on the PC-local forge. No code change.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.86.0**
|
||||
**Current release: v0.90.0**
|
||||
|
||||
**Rune is a persistent personal agent designed to accumulate experience, develop reusable competence, and remain recognisably the same agent even when its underlying foundation model changes.**
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import { existsSync, appendFileSync } from "node:fs"
|
||||
|
||||
// builder-bridge — Rune Core preflight for the MiMo builder (handoff
|
||||
// 2026-09-10 §3–§7, Option C bridge tranche). For each user turn, the hook
|
||||
// extracts the user's message from the outgoing LLM request, runs the Rune
|
||||
// Core builder-bridge preflight (deterministic: route classification +
|
||||
// memory selection + active-Scaffolding projection, ZERO model calls), and
|
||||
// appends a compact capsule to the final user message. Same Rune Core the
|
||||
// TUI/operator path uses — this file is a thin adapter with no cognition.
|
||||
//
|
||||
// CANONICAL SOURCE: config/mimo-hooks/builder-bridge.ts
|
||||
// Installed to ~/.mimocode/hooks/builder-bridge.ts by
|
||||
// `scripts/install_mimo_hook.py` (hash-verified, drift-detecting — the same
|
||||
// contract as event-wait-guard.ts). T22: this file was previously
|
||||
// UNVERSIONED and hardcoded an ephemeral interpreter path (/tmp/ci-venv),
|
||||
// which silently killed the whole edge; both defects are repaired here.
|
||||
//
|
||||
// Overhead contract (§7/§8): capsule ≤ ~300 tokens; a repeat/empty task
|
||||
// costs ~0ms (TTL cache); a fresh task ~150ms (bounded retrieval). The hook
|
||||
// degrades silently to no-injection on any failure — it must never block
|
||||
// the builder.
|
||||
//
|
||||
// Injection policy: only append when the capsule is non-empty. A plain
|
||||
// "hello" gets nothing (§5: if nothing relevant exists, inject nothing).
|
||||
|
||||
const BRIDGE = "/mnt/workdrive/issue161-harness/src/rune/builder_bridge_cli.py"
|
||||
const MARKER_PREFIX = "/tmp/rune-bridge-"
|
||||
|
||||
// Interpreter resolution (T22 repair): the previous hardcoded
|
||||
// "/tmp/ci-venv/bin/python" is an EPHEMERAL path that no longer exists, so
|
||||
// every bridge call failed and the hook silently degraded — the MiMo edge
|
||||
// received no Rune Core preflight at all (zero fire-log lines). Resolve
|
||||
// against durable candidates, first existing wins; env override wins over
|
||||
// all of them. If nothing exists the hook degrades as before.
|
||||
const PY_CANDIDATES = [
|
||||
process.env.MIMO_BRIDGE_PY,
|
||||
"/mnt/workdrive/issue161-harness/.test-venv/bin/python",
|
||||
"/home/nick/.venvs/memory/bin/python3",
|
||||
"/usr/bin/python3",
|
||||
].filter((p) => typeof p === "string" && p.length > 0)
|
||||
|
||||
function resolvePy() {
|
||||
for (const p of PY_CANDIDATES) {
|
||||
try {
|
||||
if (existsSync(p)) return p
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Per-process dedupe: identical task text within the cache TTL does not need
|
||||
// a second bridge call — the CLI has its own TTL cache, but skipping the
|
||||
// process spawn entirely for the same message text within 10 minutes keeps
|
||||
// repeats at ~0ms.
|
||||
const lastText = new Map() // text -> timestamp
|
||||
const TTL = 600_000
|
||||
|
||||
function extractUserText(messages) {
|
||||
// messages: Array<{ info: Message, parts: Part[] }> — newest last.
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]
|
||||
if (m?.info?.role !== "user") continue
|
||||
const texts = []
|
||||
for (const p of m.parts ?? []) {
|
||||
if (p?.type === "text" && typeof p.text === "string") texts.push(p.text)
|
||||
}
|
||||
if (texts.length) return texts.join("\n").slice(0, 4000)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
export default {
|
||||
"experimental.chat.messages.transform": async (input, output) => {
|
||||
try {
|
||||
if (!Array.isArray(output.messages) || output.messages.length === 0) return
|
||||
const userText = extractUserText(output.messages)
|
||||
if (!userText || userText.length < 12) return // trivial/noise guard
|
||||
// Harness-internal steps (checkpoint writer, compaction, system
|
||||
// reminders) are not builder tasks — no capsule (measured: the fire
|
||||
// log showed checkpoint-writer steps receiving capsules 2026-09-10).
|
||||
if (/^(<system-reminder>|EXTRACTION REQUIRED)/.test(userText.trim())) return
|
||||
const now = Date.now()
|
||||
const prev = lastText.get(userText)
|
||||
if (prev !== undefined && now - prev < TTL) return // dedupe repeats
|
||||
lastText.set(userText, now)
|
||||
if (lastText.size > 64) {
|
||||
// evict oldest
|
||||
let oldestKey = null, oldestVal = Infinity
|
||||
for (const [k, v] of lastText) if (v < oldestVal) { oldestVal = v; oldestKey = k }
|
||||
if (oldestKey) lastText.delete(oldestKey)
|
||||
}
|
||||
|
||||
const py = resolvePy()
|
||||
if (!py) return // no durable interpreter — degrade (never block)
|
||||
|
||||
const capsule = await new Promise((resolve) => {
|
||||
execFile(py, [BRIDGE, userText], { timeout: 15_000 }, (err, stdout) => {
|
||||
resolve(err ? "" : (stdout?.toString() ?? ""))
|
||||
})
|
||||
})
|
||||
if (!capsule.trim()) return
|
||||
|
||||
// Durable fire-trace: one line per ACTUAL injection into an outgoing
|
||||
// model request (handoff §1 proof requirement). Not a canary — this is
|
||||
// standing observability for the bridge's consumer edge.
|
||||
try {
|
||||
appendFileSync("/tmp/rune-bridge-fire.log",
|
||||
JSON.stringify({ ts: new Date().toISOString(), task: userText.slice(0, 80), capsule_tokens: Math.round(capsule.length / 4), py }) + "\n")
|
||||
} catch {}
|
||||
|
||||
// Append the capsule to the LAST user message (the one being answered).
|
||||
const last = output.messages[output.messages.length - 1]
|
||||
if (last?.parts && Array.isArray(last.parts)) {
|
||||
last.parts.push({
|
||||
type: "text",
|
||||
text: "\n\n<rune-preflight>\n" + capsule.trim() + "\n</rune-preflight>\n(Injected by Rune Core builder bridge — deterministic preflight, zero model calls. Relevant state above; ignore if not applicable to this task.)",
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// never block the builder on bridge failure
|
||||
}
|
||||
},
|
||||
}
|
||||
+308
-269
@@ -1,272 +1,311 @@
|
||||
{
|
||||
"_comment": "GENERATED by rune.spine \u2014 compatibility view for legacy umbrella consumers. DO NOT EDIT: structural truth lives in store/spine/current.json; edits here are flagged by spine drift.",
|
||||
"_generated_by_spine": true,
|
||||
"generated_at": "2026-09-16T18:23:38Z",
|
||||
"umbrellas": {
|
||||
"capability": {
|
||||
"description": "Tool selection and execution authority",
|
||||
"modules": [
|
||||
"adaptive_router",
|
||||
"builder_bridge",
|
||||
"builder_bridge_cli",
|
||||
"capability_broker",
|
||||
"connection_pool",
|
||||
"evidence_probe",
|
||||
"forge",
|
||||
"forge_client",
|
||||
"intent_continuity",
|
||||
"local_tool_runtime",
|
||||
"message_queue",
|
||||
"openai_compat_transport",
|
||||
"provider_context",
|
||||
"recall_uplift",
|
||||
"route_risk_flags",
|
||||
"tool_executor",
|
||||
"tool_protocol"
|
||||
],
|
||||
"depends_on": [
|
||||
"memory"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_native",
|
||||
"canary_p0_05",
|
||||
"canary_p1_01"
|
||||
]
|
||||
},
|
||||
"governance": {
|
||||
"description": "Quality control, drift detection, promotion, audit",
|
||||
"modules": [
|
||||
"audit_ledger",
|
||||
"build_pipeline_policy",
|
||||
"claim_audit",
|
||||
"contracts",
|
||||
"curator",
|
||||
"drift_core",
|
||||
"drift_ledger",
|
||||
"drift_observability",
|
||||
"failure_auto_filer",
|
||||
"programme_gates"
|
||||
],
|
||||
"depends_on": [],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system",
|
||||
"canary_schema_migration"
|
||||
]
|
||||
},
|
||||
"growth": {
|
||||
"description": "Learning and improvement",
|
||||
"modules": [
|
||||
"autonomy_loop",
|
||||
"cognitive_loop",
|
||||
"dream_cycle",
|
||||
"elastic_cognition",
|
||||
"goal",
|
||||
"growth_delta",
|
||||
"growth_simulator",
|
||||
"self_improve",
|
||||
"subconscious_pass"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance",
|
||||
"memory"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system"
|
||||
]
|
||||
},
|
||||
"identity": {
|
||||
"description": "Who the agent is \u2014 persona, voice, anchors",
|
||||
"modules": [
|
||||
"behavior",
|
||||
"identity_anchors",
|
||||
"persona_definition",
|
||||
"persona_gate",
|
||||
"prompt_sync",
|
||||
"voice_hybrid",
|
||||
"voice_injector",
|
||||
"voice_sync",
|
||||
"voicebox"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system"
|
||||
]
|
||||
},
|
||||
"inference": {
|
||||
"description": "Model routing and outbound requests",
|
||||
"modules": [
|
||||
"inference_trace",
|
||||
"model_connect",
|
||||
"model_profiles",
|
||||
"model_registry",
|
||||
"model_routing",
|
||||
"provider_budget",
|
||||
"provider_registry",
|
||||
"response_adapter",
|
||||
"standardized_providers",
|
||||
"token_optimizer"
|
||||
],
|
||||
"depends_on": [
|
||||
"capability"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_p1_02",
|
||||
"canary_p1_06",
|
||||
"canary_resource_authority"
|
||||
]
|
||||
},
|
||||
"memory": {
|
||||
"description": "Storing, retrieving, and managing knowledge",
|
||||
"modules": [
|
||||
"abstraction",
|
||||
"cache",
|
||||
"drift_cache",
|
||||
"drift_compression",
|
||||
"graph_memory",
|
||||
"graph_shard_bridge",
|
||||
"memory_consolidation",
|
||||
"memory_env",
|
||||
"memory_selector",
|
||||
"memory_store",
|
||||
"query_memory",
|
||||
"recall_router",
|
||||
"retrieval_benchmark",
|
||||
"shard_manager",
|
||||
"tier_manager",
|
||||
"warm_neuron_store"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system",
|
||||
"canary_memory_synthesis",
|
||||
"canary_query_memory"
|
||||
]
|
||||
},
|
||||
"orchestration": {
|
||||
"description": "Task lifecycle, operator, TUI presentation",
|
||||
"modules": [
|
||||
"auto_detect",
|
||||
"build_mode",
|
||||
"build_orchestrator",
|
||||
"chat_bubble",
|
||||
"cron_engine",
|
||||
"custodian",
|
||||
"dependency_tracker",
|
||||
"event_bus",
|
||||
"event_envelope",
|
||||
"gitea_auth",
|
||||
"gitea_pipeline",
|
||||
"gitea_provision",
|
||||
"hook_engine",
|
||||
"message_widgets",
|
||||
"microkernel",
|
||||
"night_mode",
|
||||
"outcome_ledger",
|
||||
"parallel_processor",
|
||||
"pins",
|
||||
"progressive_escalation",
|
||||
"rune_graphic",
|
||||
"rune_operator",
|
||||
"rune_profiler",
|
||||
"rune_spinner",
|
||||
"rune_tui",
|
||||
"safety_override",
|
||||
"sleep_time",
|
||||
"subsystem_health",
|
||||
"system_registry",
|
||||
"task_gate",
|
||||
"task_persist",
|
||||
"tiered_executor",
|
||||
"time_awareness",
|
||||
"tui_validator",
|
||||
"umbrella_update",
|
||||
"verification_layer",
|
||||
"warm_router",
|
||||
"work_manager"
|
||||
],
|
||||
"depends_on": [
|
||||
"capability",
|
||||
"growth",
|
||||
"identity",
|
||||
"inference",
|
||||
"perception"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_cognitive_strata",
|
||||
"canary_conversation_state",
|
||||
"canary_gitea_pipeline",
|
||||
"canary_orchestration_foundation",
|
||||
"canary_p1_07",
|
||||
"canary_surface_parity",
|
||||
"canary_tui_drift"
|
||||
]
|
||||
},
|
||||
"perception": {
|
||||
"description": "Input processing and context assembly",
|
||||
"modules": [
|
||||
"attention_capsule",
|
||||
"auto_context",
|
||||
"auto_tasks",
|
||||
"behaviour_ledger",
|
||||
"build_basis",
|
||||
"capability_fit",
|
||||
"capability_ledger",
|
||||
"cognitive_budgeter",
|
||||
"construction_activation",
|
||||
"construction_commands",
|
||||
"construction_decision",
|
||||
"construction_depth",
|
||||
"construction_intent",
|
||||
"construction_interface",
|
||||
"construction_mode",
|
||||
"construction_pattern",
|
||||
"construction_patterns_builtin",
|
||||
"construction_projection",
|
||||
"context_broker",
|
||||
"context_pressure",
|
||||
"drift_resume",
|
||||
"event_wait",
|
||||
"failure_recovery",
|
||||
"graph_projection",
|
||||
"impact_preview",
|
||||
"iteration",
|
||||
"layout_contract",
|
||||
"programme_continuation",
|
||||
"project_registry",
|
||||
"release_notes",
|
||||
"salience_gate",
|
||||
"scaffold",
|
||||
"spine",
|
||||
"stage_guards",
|
||||
"structural_migration",
|
||||
"structural_model",
|
||||
"structural_store",
|
||||
"testing_mode",
|
||||
"toolchain",
|
||||
"tui_backlog",
|
||||
"wait_guard",
|
||||
"wait_guard_corpus",
|
||||
"witness"
|
||||
],
|
||||
"depends_on": [
|
||||
"memory"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_context_broker",
|
||||
"canary_full_system"
|
||||
]
|
||||
"_comment": "GENERATED by rune.spine \u2014 compatibility view for legacy umbrella consumers. DO NOT EDIT: structural truth lives in store/spine/current.json; edits here are flagged by spine drift.",
|
||||
"_generated_by_spine": true,
|
||||
"generated_at": "2026-09-16T18:23:38Z",
|
||||
"umbrellas": {
|
||||
"capability": {
|
||||
"description": "Tool selection and execution authority",
|
||||
"modules": [
|
||||
"adaptive_router",
|
||||
"builder_bridge",
|
||||
"builder_bridge_cli",
|
||||
"capability_broker",
|
||||
"connection_pool",
|
||||
"core_server",
|
||||
"cs_loader",
|
||||
"evidence_probe",
|
||||
"forge",
|
||||
"forge_client",
|
||||
"intent_continuity",
|
||||
"local_tool_runtime",
|
||||
"message_queue",
|
||||
"native_units",
|
||||
"openai_compat_transport",
|
||||
"provider_context",
|
||||
"recall_uplift",
|
||||
"route_risk_flags",
|
||||
"tool_executor",
|
||||
"tool_protocol"
|
||||
],
|
||||
"depends_on": [
|
||||
"memory"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_native",
|
||||
"canary_p0_05",
|
||||
"canary_p1_01",
|
||||
"canary_rune_entry",
|
||||
"canary_rune_serve"
|
||||
],
|
||||
"go_surfaces": [
|
||||
"rune-entry"
|
||||
],
|
||||
"configs": [],
|
||||
"rust_crates": [
|
||||
"capability_scoring"
|
||||
]
|
||||
},
|
||||
"governance": {
|
||||
"description": "Quality control, drift detection, promotion, audit",
|
||||
"modules": [
|
||||
"audit_ledger",
|
||||
"build_pipeline_policy",
|
||||
"claim_audit",
|
||||
"contracts",
|
||||
"curator",
|
||||
"drift_core",
|
||||
"drift_ledger",
|
||||
"drift_observability",
|
||||
"failure_auto_filer",
|
||||
"programme_gates"
|
||||
],
|
||||
"depends_on": [],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system",
|
||||
"canary_schema_migration",
|
||||
"canary_audit_ledger",
|
||||
"canary_spine_meta"
|
||||
],
|
||||
"rust_crates": [
|
||||
"evidence_provenance"
|
||||
],
|
||||
"configs": [
|
||||
".coveragerc",
|
||||
"go-version.txt",
|
||||
"lint-baseline.json",
|
||||
"pyright-baseline.json"
|
||||
]
|
||||
},
|
||||
"growth": {
|
||||
"description": "Learning and improvement",
|
||||
"modules": [
|
||||
"autonomy_loop",
|
||||
"cognitive_loop",
|
||||
"dream_cycle",
|
||||
"elastic_cognition",
|
||||
"goal",
|
||||
"growth_delta",
|
||||
"growth_simulator",
|
||||
"self_improve",
|
||||
"subconscious_pass"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance",
|
||||
"memory"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system"
|
||||
]
|
||||
},
|
||||
"identity": {
|
||||
"description": "Who the agent is \u2014 persona, voice, anchors",
|
||||
"modules": [
|
||||
"behavior",
|
||||
"identity_anchors",
|
||||
"persona_definition",
|
||||
"persona_gate",
|
||||
"prompt_sync",
|
||||
"voice_hybrid",
|
||||
"voice_injector",
|
||||
"voice_sync",
|
||||
"voicebox"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system"
|
||||
]
|
||||
},
|
||||
"inference": {
|
||||
"description": "Model routing and outbound requests",
|
||||
"modules": [
|
||||
"inference_trace",
|
||||
"model_connect",
|
||||
"model_profiles",
|
||||
"model_registry",
|
||||
"model_routing",
|
||||
"provider_budget",
|
||||
"provider_registry",
|
||||
"response_adapter",
|
||||
"standardized_providers",
|
||||
"tc_loader",
|
||||
"token_optimizer"
|
||||
],
|
||||
"depends_on": [
|
||||
"capability"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_p1_02",
|
||||
"canary_p1_06",
|
||||
"canary_resource_authority"
|
||||
],
|
||||
"rust_crates": [
|
||||
"token_counting"
|
||||
]
|
||||
},
|
||||
"memory": {
|
||||
"description": "Storing, retrieving, and managing knowledge",
|
||||
"modules": [
|
||||
"abstraction",
|
||||
"cache",
|
||||
"drift_cache",
|
||||
"drift_compression",
|
||||
"ffi_query_memory_smoke",
|
||||
"graph_memory",
|
||||
"graph_shard_bridge",
|
||||
"memory_consolidation",
|
||||
"memory_env",
|
||||
"memory_selector",
|
||||
"memory_store",
|
||||
"ms_loader",
|
||||
"query_memory",
|
||||
"recall_router",
|
||||
"retrieval_benchmark",
|
||||
"shard_manager",
|
||||
"tier_manager",
|
||||
"warm_neuron_store"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system",
|
||||
"canary_memory_synthesis",
|
||||
"canary_query_memory"
|
||||
],
|
||||
"rust_crates": [
|
||||
"memory_scoring",
|
||||
"query_memory"
|
||||
]
|
||||
},
|
||||
"orchestration": {
|
||||
"description": "Task lifecycle, operator, TUI presentation",
|
||||
"modules": [
|
||||
"auto_detect",
|
||||
"build_mode",
|
||||
"build_orchestrator",
|
||||
"chat_bubble",
|
||||
"cron_engine",
|
||||
"custodian",
|
||||
"dependency_tracker",
|
||||
"event_bus",
|
||||
"event_envelope",
|
||||
"gitea_auth",
|
||||
"gitea_pipeline",
|
||||
"gitea_provision",
|
||||
"hook_engine",
|
||||
"message_widgets",
|
||||
"microkernel",
|
||||
"night_mode",
|
||||
"outcome_ledger",
|
||||
"parallel_processor",
|
||||
"pins",
|
||||
"progressive_escalation",
|
||||
"rune_graphic",
|
||||
"rune_operator",
|
||||
"rune_profiler",
|
||||
"rune_spinner",
|
||||
"rune_tui",
|
||||
"safety_override",
|
||||
"sleep_time",
|
||||
"subsystem_health",
|
||||
"system_registry",
|
||||
"task_gate",
|
||||
"task_persist",
|
||||
"tiered_executor",
|
||||
"time_awareness",
|
||||
"tui_transport",
|
||||
"tui_validator",
|
||||
"umbrella_update",
|
||||
"verification_layer",
|
||||
"warm_router",
|
||||
"work_manager"
|
||||
],
|
||||
"depends_on": [
|
||||
"capability",
|
||||
"growth",
|
||||
"identity",
|
||||
"inference",
|
||||
"perception"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_cognitive_strata",
|
||||
"canary_conversation_state",
|
||||
"canary_gitea_pipeline",
|
||||
"canary_orchestration_foundation",
|
||||
"canary_p1_07",
|
||||
"canary_surface_parity",
|
||||
"canary_tui_drift",
|
||||
"canary_tui_transport"
|
||||
]
|
||||
},
|
||||
"perception": {
|
||||
"description": "Input processing and context assembly",
|
||||
"modules": [
|
||||
"attention_capsule",
|
||||
"auto_context",
|
||||
"auto_tasks",
|
||||
"behaviour_ledger",
|
||||
"build_basis",
|
||||
"capability_fit",
|
||||
"capability_ledger",
|
||||
"cognitive_budgeter",
|
||||
"construction_activation",
|
||||
"construction_commands",
|
||||
"construction_decision",
|
||||
"construction_depth",
|
||||
"construction_intent",
|
||||
"construction_interface",
|
||||
"construction_mode",
|
||||
"construction_pattern",
|
||||
"construction_patterns_builtin",
|
||||
"construction_projection",
|
||||
"context_broker",
|
||||
"context_pressure",
|
||||
"drift_resume",
|
||||
"event_wait",
|
||||
"failure_recovery",
|
||||
"graph_projection",
|
||||
"impact_preview",
|
||||
"iteration",
|
||||
"layout_contract",
|
||||
"programme_continuation",
|
||||
"project_registry",
|
||||
"release_notes",
|
||||
"salience_gate",
|
||||
"scaffold",
|
||||
"spine",
|
||||
"spine_loader",
|
||||
"stage_guards",
|
||||
"structural_migration",
|
||||
"structural_model",
|
||||
"structural_store",
|
||||
"testing_mode",
|
||||
"toolchain",
|
||||
"tui_backlog",
|
||||
"wait_guard",
|
||||
"wait_guard_corpus",
|
||||
"witness"
|
||||
],
|
||||
"depends_on": [
|
||||
"memory"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_context_broker",
|
||||
"canary_full_system"
|
||||
],
|
||||
"rust_crates": [
|
||||
"rune_spine"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,24 +11,22 @@
|
||||
"NEEDS_REVALIDATION",
|
||||
"DEFERRED_FUTURE"
|
||||
],
|
||||
"live_defects": [
|
||||
324
|
||||
],
|
||||
"live_defects": [],
|
||||
"records": [
|
||||
{
|
||||
"number": 324,
|
||||
"title": "Engine defect: checkpoint writer fails deterministically on aborted worker sessions (MessageAbortedError -> empty checkpoint shells)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "FIXED_VERIFIED",
|
||||
"labels": [
|
||||
"bug",
|
||||
"custodian",
|
||||
"life",
|
||||
"memory"
|
||||
],
|
||||
"live_defect": true,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 defect no longer reproduces: zero MessageAbortedError in current logs (incl. active worker), six most recent session checkpoints full documents (111-396 lines); verified by present-absence 2026-09-19"
|
||||
},
|
||||
{
|
||||
"number": 316,
|
||||
@@ -237,8 +235,8 @@
|
||||
{
|
||||
"number": 183,
|
||||
"title": "Future: typed temporal semantics for facts, relationships, beliefs, and preferences",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"governance",
|
||||
@@ -250,14 +248,14 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #181 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 182,
|
||||
"title": "Future: GitHub code-search prior-art audit for graph and vector memory",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"governance",
|
||||
@@ -266,8 +264,8 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #181 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 181,
|
||||
@@ -287,13 +285,13 @@
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"next_action": "epic holds absorbed children #182 #183 + research watchlist #164 #165 #166; closes when prerequisites land or ideas dropped"
|
||||
},
|
||||
{
|
||||
"number": 179,
|
||||
"title": "Future: succession/privacy semantics and lineage evaluation",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"governance",
|
||||
@@ -305,14 +303,14 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #175 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 178,
|
||||
"title": "Future: evidence-grounded ancestral perspective with explicit uncertainty",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"governance",
|
||||
@@ -322,14 +320,14 @@
|
||||
"perception"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #175 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 177,
|
||||
"title": "Future: temporal autobiographical model with provenance and reinterpretation lineage",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"governance",
|
||||
@@ -340,14 +338,14 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #175 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 176,
|
||||
"title": "Future: lineage-aware situated curiosity for meaningful autobiographical gaps",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"feature",
|
||||
@@ -358,8 +356,8 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #175 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 175,
|
||||
@@ -379,13 +377,13 @@
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"next_action": "epic holds absorbed children #176-#179 as checklist items; closes when prerequisites land or ideas dropped"
|
||||
},
|
||||
{
|
||||
"number": 166,
|
||||
"title": "RESEARCH WATCHLIST: Memory-R1 (RL memory ops), Titans (test-time memory), Vespa/Weaviate/Milvus (retrieval competitors)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"growth",
|
||||
@@ -394,14 +392,14 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #181 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 165,
|
||||
"title": "RESEARCH: SimpleMem semantic compression + A-MEM dynamic linking + Letta sleep-time compute",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"infrastructure",
|
||||
@@ -410,14 +408,14 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #181 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 164,
|
||||
"title": "RESEARCH: HippoRAG 2 \u2014 PPR-style associative propagation as warm-neuron activation candidate",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"growth",
|
||||
@@ -426,27 +424,27 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #181 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 163,
|
||||
"title": "Temporal graph backend spike: Neo4j vs FalkorDB (LadybugDB experimental; Graphiti as reference)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "NEEDS_REVALIDATION",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"labels": [
|
||||
"P1 \u2014 high",
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "run the backend spike; compare on same corpus/query set; record evidence-backed recommendation"
|
||||
},
|
||||
{
|
||||
"number": 162,
|
||||
"title": "Qdrant associative-memory projection spike (dense/sparse/multivector + ColBERT late-interaction reranking)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "NEEDS_REVALIDATION",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "SUPERSEDED",
|
||||
"labels": [
|
||||
"P1 \u2014 high",
|
||||
"custodian",
|
||||
@@ -454,8 +452,8 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 scope carried verbatim by #316 (ADAPT-1 B3-v2); single-tracker rule"
|
||||
},
|
||||
{
|
||||
"number": 159,
|
||||
@@ -478,8 +476,8 @@
|
||||
{
|
||||
"number": 158,
|
||||
"title": "Generated wiring-status / architecture-truth report",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "SUPERSEDED",
|
||||
"labels": [
|
||||
"P2 \u2014 medium",
|
||||
"growth",
|
||||
@@ -489,8 +487,8 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 scope carried verbatim by #315 (ADAPT-1 B2-v2); single-tracker rule"
|
||||
},
|
||||
{
|
||||
"number": 156,
|
||||
@@ -1151,6 +1149,6 @@
|
||||
"next_action": ""
|
||||
}
|
||||
],
|
||||
"last_reconciled": "2026-09-12",
|
||||
"reconciled_by": "PL1 held-out era reconciliation (fresh session 2026-09-12)"
|
||||
}
|
||||
"last_reconciled": "2026-09-19",
|
||||
"reconciled_by": "fresh-session tranche 2026-09-19: template retrofit (spine+plain-English on all open issues), closures #158/#162 (SUPERSEDED) and #324 (FIXED_VERIFIED), research labels applied to #163/#164/#165/#166/#182/#316, stale bug labels removed from #163-#166; 2026-09-19 absorption pass: parked children absorbed into parents (#175<-#176..#179; #181<-#182,#183,#164..#166) per one-open-note-per-idea standard"
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
# Language-Fit Rubric — Wave 1 (Language-Rebasing Tranche R1)
|
||||
|
||||
> STATUS: FINAL — self-verified 2026-09-29 per tranche §12 (all 6 rounds completed,
|
||||
> evidence-sufficiency statements recorded, kill tests frozen; execution proceeds on the
|
||||
> evidence, every RECODE still passes its benchmark gate — no win, no recode).
|
||||
> Per-subsystem sections filled from live research rounds (≥2 live-verified external sources per claim,
|
||||
> per-source lessons, evidence→decision mapping, kill test per candidate). No code changes in Wave 1.
|
||||
> Governing law from §11: **KEEP-PYTHON honesty determines how much of the rebase volume is real.**
|
||||
> Toolchain baseline: Rust 1.98.1 + GCC 16.2.1 present; Go NOT installed — Go earns installation only
|
||||
> through rubric evidence here.
|
||||
|
||||
## Verdict (all rounds, one paragraph)
|
||||
|
||||
**KEEP-PYTHON across the board: 40 clusters scored, 0 recode units awarded.** Six research
|
||||
rounds (W2 leaf utilities, W3 memory, W4 provider-layer, W5 orchestration, W6 runtime-core,
|
||||
W7 TUI) each characterized their subsystem's actual workload inward (file:line-anchored) and
|
||||
tested Python/Rust/Go/C against it with live-verified sources and frozen kill tests. The
|
||||
pattern is consistent: the compute-shaped work already executes in C (hashlib, sqlite3/FTS5/vec0,
|
||||
json, regex), the dominant latency is network or LLM-bound, and no cluster contains a hot loop
|
||||
that pays for a process-boundary recode through the 658 µs–0.63 ms interop crossing. **Go:
|
||||
REJECTED — does not earn installation this tranche** (D5=0 everywhere; nothing measured or
|
||||
fetch-confirmed earns it; orchestration additionally rejects its design fit; K-GO kill tests
|
||||
frozen per round should the case be revived). **Rust: toolchain-ready, justified nowhere this
|
||||
tranche** — keep as the standing option behind the CI-proven std-only adapter for any future
|
||||
measured hot loop. **C: rejected everywhere scored** (duplicates C already inside SQLite/hashlib;
|
||||
adds an unsafe-memory surface). The rebase volume the tranche hypothesized is real only as a
|
||||
small set of named improvements — see "Wave-plan consequence" below.
|
||||
|
||||
## Rubric dimensions (applied to every candidate language per subsystem)
|
||||
|
||||
| Dimension | Question | Evidence required |
|
||||
|---|---|---|
|
||||
| D1 Workload fit | Does the subsystem's dominant workload (I/O-bound, CPU-bound, FFI, protocol, UI) actually favour this language? | Benchmarks or authoritative docs, live-verified |
|
||||
| D2 Measured gain | What wins, concretely — latency, memory, startup, binary distribution? | Named hot paths from the Wave 0 inventory + numbers |
|
||||
| D3 Interop cost | What does the interop contract (§5) cost to carry for this boundary? | Adapter surface size, ABI/build complexity |
|
||||
| D4 Ecosystem | Do the needed libraries exist and are they maintained? | Live-verified crate/package/module listings |
|
||||
| D5 Toolchain reality | Is the toolchain present (or installable and justified) on this machine and the CI runner? | Runner + local `--version` evidence |
|
||||
| D6 Maintenance continuity | Can the Rune ecosystem's single-maintainer loop sustain code in this language here? | Honest judgement, stated plainly |
|
||||
| D7 Kill test | What is the CHEAPEST disproof of this candidate? | One falsifiable early test, frozen before any recode |
|
||||
|
||||
Scoring: each candidate gets D1–D6 scored 0–2 with one-line evidence each, D7 written regardless.
|
||||
Decision per subsystem: **KEEP-PYTHON** / **RECODE→<language>** — KEEP-PYTHON is the expected default;
|
||||
a RECODE that cannot pass its own kill test reverts to KEEP-PYTHON before implementation.
|
||||
|
||||
---
|
||||
|
||||
## W3 — Memory & persistence (units: memory_store 56 fns, curator 43, warm_router 32, memory_consolidation 20, sleep_time 25, + graph_memory, night_mode, identity_anchors)
|
||||
|
||||
- **Research round actor:** general-2 (COMPLETED 2026-09-29 — artifacts at
|
||||
`research/wave1/memory/`: inward.md, outward/ ×6, outward-sources.md, synthesis.md)
|
||||
- **Intention profile:** JSONL-heavy appends, BM25/FTS ranking, consolidation passes, long-running
|
||||
scheduled jobs, file-backed state.
|
||||
- **Decision:** **KEEP-PYTHON (all 7 clusters, zero RECODE).** Cluster verdicts:
|
||||
(1) hot-store JSON persistence — KEEP with upgrade path: orjson in-process wheel
|
||||
(~10× dumps / 2× loads); a bespoke Rust CLI unit is dead on spawn cost (the
|
||||
`shared/native_units.py` crossing ~1–5 ms ≈ orjson's entire 3.4 MB `knowledge.json` decode);
|
||||
(2) SQLite store + FTS5/vec0 recall — KEEP, the compute is already C inside SQLite
|
||||
(`memory_store.py:646` vec0 MATCH, `:693` bm25); (3) neuron scoring/routing — KEEP,
|
||||
network-bound `_embed` (Ollama subprocess curl) dominates; (4) graph BFS — KEEP,
|
||||
hops=1 default, per-node SQL, tiny N; the future fix is a SQL CTE, not a language;
|
||||
(5) batch consolidation/curator — KEEP, LLM/I-O-bound and latency-insensitive,
|
||||
asyncio sweet spot; (6) orchestration/glue — KEEP, pure glue pinned by the Python-TUI
|
||||
interop contract; (7) embedding transport — KEEP, network-bound.
|
||||
- **Candidate matrix summary:** Python 7/7 · Go 0/7 (not installed, nothing measured or
|
||||
fetch-confirmed earns installation; Go's JSON stack mid-rewrite upstream) · hand-written C
|
||||
0/7 (C already battle-tested inside SQLite — hand-writing would duplicate it) · Rust 0/7
|
||||
as CLI units (spawn cost kills the one credible vector; in-process wheels excluded by
|
||||
Wave 0 policy).
|
||||
- **Kill tests (frozen in synthesis.md):** Rust-JSON — orjson microbench on live 3.4 MB
|
||||
`knowledge.json` (if drop-in and ≥2× load / ≥5× dump, the CLI unit is disproven on spawn
|
||||
cost); Rust-general — kill unless a profile shows ≥50% wall time in pure Python compute
|
||||
(none exists; reopen only on materially changed workload); Go — instrument one
|
||||
`consolidate_all` run, kill if Python-compute share <50% (it will be — LLM/I-O bound);
|
||||
C — kill unless a hot kernel absent from the SQLite extension ecosystem is identified
|
||||
(none).
|
||||
- **Sources + lessons:** 6 live-fetched 2026-09-29 (orjson, sled, sqlite-vec, SQLite FTS5
|
||||
docs, Go json/v2 rewrite issue, pydantic-core) — full annotations in
|
||||
`research/wave1/memory/outward-sources.md`.
|
||||
- **Evidence sufficiency (honest):** static analysis only, no runtime profiling;
|
||||
memory.store SQLite DB not materialized locally — kill tests specify *measured*
|
||||
thresholds rather than assertions. Verdicts rest on "Python is glue around C/network,"
|
||||
which profiling would only strengthen.
|
||||
|
||||
## W4 — Provider/model layer (units: provider_registry 28, model_connect 10, model_routing 15, model_profiles 15, provider_budget 21, + standardized_providers, model_registry, connection_pool)
|
||||
|
||||
- **Research round actor:** general-5 (COMPLETED 2026-09-29 — artifacts at
|
||||
`research/wave1/provider-layer/`: inward.md, outward-sources.md, outward/ (8 evidence
|
||||
JSONs), synthesis.md)
|
||||
- **Intention profile:** HTTP/SSE streaming, connection pooling, retries/backoff, protocol parsing,
|
||||
tool-call reliability.
|
||||
- **Intention profile correction (inward-verified):** the layer has **NO retry/backoff and NO
|
||||
SSE stub at all** (grep-verified) — `stream: False` everywhere, ~900 lines, remote-LLM-latency-
|
||||
bound. Rewrite pitches are therefore feature-adds, not ports.
|
||||
- **Candidate matrix:** 5 clusters, all KEEP-PYTHON. Scores (1–5 = fit for the cluster's actual
|
||||
workload): (C1) HTTP wire + transport (provider_registry, openai_compat_transport, model_connect
|
||||
discovery) Python 5 / Go 2 / Rust 1; (C2) connection pool (connection_pool.py, 144 lines —
|
||||
in-process object guards under threading.Lock, NOT a TCP pool) Python 5 / Go 2 / Rust 2;
|
||||
(C3) routing decisions (model_routing.py, 432 lines, pure decision logic, one httpx call)
|
||||
Python 5 / Go 1 / Rust 1; (C4) budget accounting (provider_budget.py, 354 lines, pure
|
||||
arithmetic + lock, no I/O) Python 5 / Go 1 / Rust 1; (C5) config/registry/normalization
|
||||
(model_profiles, model_registry, standardized_providers, model_connect tables — dataclasses +
|
||||
JSON, editable by non-systems programmers) Python 5 / Go 1 / Rust 1.
|
||||
- **Decision:** **KEEP-PYTHON (all 5 clusters).**
|
||||
- **Kill tests:** C1 Go — KILLED: toolchain absent (`which go` → not found); 28 call-site
|
||||
behaviors incl. the tool-call envelope contract (openai_compat_transport.py:67-107, 160-207)
|
||||
would be re-implemented with zero incumbent test port and NO performance signal (remote-LLM-
|
||||
latency-bound, kilobyte payloads). Re-earn path if Go is ever installed: std-only proof via
|
||||
`interop/rebase-proof/` + a benchmark harness on C1 only. C1 Rust — KILLED BY CONSTRUCTION:
|
||||
SSE parsing requires reqwest+tokio+eventsource-stream+nom (outward O3/O4/O5 — even mistral.rs
|
||||
takes the tokio stack), violating the std-only interop contract. C2–C5 Go/Rust — KILLED:
|
||||
burden of proof sits on the rewrite candidates; kill tests need contention/throughput/
|
||||
counter-evidence that does not exist; routing runs once per request at millisecond scale
|
||||
against multi-second LLM calls (5 orders of magnitude below any measurable threshold).
|
||||
- **Sources + lessons:** 8 live-fetched (O1–O8), ≥2 per major claim, coverage map in
|
||||
`outward-sources.md`. Lessons: (1) stdlib-only Rust has no realistic precedent for HTTP/SSE
|
||||
workloads — the interop contract's zero-crate standard effectively decides Rust out of all
|
||||
network-wire clusters; (2) Go's stdlib HTTP+NDJSON streaming is capability-proven
|
||||
(pkg.go.dev/net/http, ollama api/client.go, ollama's Go API server) — Go's losses here are
|
||||
evidence-absence, not capability-absence; (3) the honest reliability upgrade for this layer
|
||||
is a small Python-level httpx retry/timeout wrapper — a feature-add, not a language change.
|
||||
|
||||
## W5 — Orchestration, authority, plugins, skills (units: orchestration/ 44 files 244 fns, contracts, build_mode 34, construction_authority*, verification_construction, spine, + governance surfaces: drift_*, persona_gate, safety_override, witness, behavior, growth_delta, claim_audit)
|
||||
|
||||
- **Research round actor:** general-1 (COMPLETED 2026-09-29 — artifacts at
|
||||
`research/wave1/orchestration/`: inward.md, outward-sources.md, outward/ (4 search
|
||||
JSONs), synthesis.md)
|
||||
- **Intention profile:** contract/lifecycle state machines, authority gating, fail-closed validation,
|
||||
HMAC-chained ledgers, policy dispatch.
|
||||
- **Intention profile correction (inward-verified):** the subsystem is durable-ledger I/O,
|
||||
authority wiring, deterministic policy gates, and TUI-facing coordination — NOT hot loops
|
||||
(2 RLocks, 0 sockets, 0 thread pools across 43 files). **Scope gap: `construction_authority.py`
|
||||
and `verification_construction.py` do not exist in the tree** — the construction cluster is
|
||||
covered by build_mode.py; no evidence invented for the missing files.
|
||||
- **Candidate matrix:** 6 clusters, all KEEP-PYTHON. Scores (1–5 = fit for the cluster's actual
|
||||
workload): (C1) durable state & ledger stores (event_store.py:56-101, atomic.py, evidence_store.py
|
||||
— fsync-bound; hashing already C via hashlib) Python 5; (C2) composition roots & authority wiring
|
||||
(composition.py, authority.py, cognitive.py — pure glue, runtime cost ≈ 0) Python 5; (C3)
|
||||
policy/state machines/gates (lifecycle.py, scheduler.py, broker.py, no_change.py, reflex.py —
|
||||
deterministic dict/enum decisions, sub-ms; testability is the requirement) Python 5; (C4)
|
||||
structural self-model & schema tooling (spine.py:24-25, microkernel.py, contracts.py, records.py
|
||||
— introspection + canonical JSON; only candidate for a future Rust spine-diff if snapshots grow
|
||||
1000×) Python 4; (C5) TUI-facing orchestration & build pipeline (build_mode.py 11-stage pipeline,
|
||||
gateway.py, transport.py — the consumer IS the Python TUI; latency dominated by LLM/subprocess
|
||||
waits) Python 5; (C6) benchmark/migration harnesses (retrieval_benchmark.py, migrate.py,
|
||||
benchmark.py — offline tooling, recode buys nothing, loses iteration speed) Python 4.
|
||||
- **Decision:** **KEEP-PYTHON (all 6 clusters). This subsystem contributes 0 recode units to
|
||||
the wave plan.** Go: REJECTED, do not install — differentiator (goroutine-dense servers)
|
||||
targets a workload absent here; cost is exception→error-value rewrite of ~260 functions.
|
||||
Rust: FEASIBLE (toolchain proven) but UNJUSTIFIED anywhere — no cluster has the hot loop
|
||||
that pays for a process-boundary recode; keep as toolchain-ready for other subsystems.
|
||||
C: REJECTED — the only compute-shaped work (sha256) already executes C via stdlib hashlib.
|
||||
- **Kill tests:** K1 (frozen, ALREADY RUN): 100 round-trips through the rebase-proof adapter —
|
||||
mean 0.63ms, p95 0.74ms, cold 0.87ms, 100% exit-0 + ok:true → adapter overhead does NOT block
|
||||
recodes on cost; recodes die on value grounds. Re-open any cluster if profiling shows a
|
||||
function consuming >10% of a real interactive session. K-GO (frozen, ~30 min): Go prototype
|
||||
of event_store.py's batched append+fsync vs Python at 1000 events; if Python's p95 is within
|
||||
2× of Go's, Go is dead here permanently. K-RUST (frozen, ~20 min): measure spine diff on a
|
||||
10k-entity snapshot; <50ms kills any Rust spine recode by absence of pain.
|
||||
- **Sources + lessons:** 4 live-fetched web sources (pydantic-core, ruff blog, go.dev FAQ,
|
||||
Python 3.14 hashlib docs) + 1 LOCAL EXPERIMENT (n=100 adapter benchmark). Lessons: (1) the
|
||||
rebase-proof adapter round-trips at 0.63ms/op — subprocess-per-op is viable for coarse ops,
|
||||
never per-event; (2) Python's "slowness" argument dissolves where the actual compute already
|
||||
runs in C via stdlib (hashlib, sqlite) — the language question is really "where is the hot
|
||||
loop", and here the answer is nowhere; (3) assumed limits flagged honestly: human-scale event
|
||||
cadence (batch_size=8) and Linux spawn cost — both would need revisit at 10⁴+ events/sec or
|
||||
cross-platform targets.
|
||||
|
||||
## W6 — Runtime core & tooling (units: tool_executor 46, tool_protocol 27, local_tool_runtime 32, capability_broker 19, context_broker 21, work_manager 25, cron_engine 28, + failure_auto_filer 28, hook_engine, elastic_cognition, auto_tasks, goal)
|
||||
|
||||
- **Research round actor:** general-4 (COMPLETED 2026-09-29 on disk despite cancellation
|
||||
notification — artifacts at `research/wave1/runtime-core/`: inward.md, outward/,
|
||||
outward-sources.md, synthesis.md)
|
||||
- **Intention profile:** subprocess/process supervision, native execution boundary (native_units
|
||||
adapter already exists), event loops, cron scheduling, hook dispatch.
|
||||
- **Decision:** **STAY-PYTHON (all 6 clusters).** Round scores (1–5 = fit for the cluster's
|
||||
actual inward-characterized workload): (A) subprocess lifecycle (tool_executor, cron_engine,
|
||||
auto_tasks) 4.7 — `subprocess.run(timeout=, capture_output=)` is a one-call expression of the
|
||||
pattern; Rust std::process has NO built-in child timeout (outward-verified), strictly more
|
||||
code under zero-crate; (B) protocol/JSON parsing (tool_protocol, brokers) 4.5 — inputs are
|
||||
KB-scale model fragments; Python json/regex is already C-backed, so a rewrite swaps C for
|
||||
compiled code at the same asymptotic cost; serde's typed-struct model actively mismatches
|
||||
fence-wrapped schema-flexible output; (C) model→tool control loop + HTTP
|
||||
(local_tool_runtime, elastic_cognition, goal) 4.8 — wall time is LLM HTTP latency in seconds;
|
||||
Python overhead is µs–ms noise; the loop's content is policy (circuit breaker, escalation
|
||||
ladders, routing tables); (D) context assembly (context_broker) 4.7 — correctness lives in
|
||||
admission policy and degrade-not-crash semantics, not speed; compaction is itself an LLM call;
|
||||
(E) reliability/queueing (failure_auto_filer) 4.2 — honest lowest: on paper the best Rust
|
||||
candidate (queue + worker + crash-safe JSONL spool), but perf requirement ≈ nil and the
|
||||
correctness burden is threading semantics a rewrite would re-risk for no win ("nothing
|
||||
breaks today"); (F) declarative config/brokers (work_manager, hook_engine, capability_broker)
|
||||
4.9 — literal data, no-op runtime, portable to anything valuable in nothing.
|
||||
- **Candidate matrix summary:** Python 6/6 · Go 0/6 (paper verdicts only — evidence-starved,
|
||||
not installed, kill tests unrunnable locally) · Rust 0/6 (kill tests runnable via the
|
||||
existing std-only interop harness) · C unscored (no FFI-adjacent cluster surfaced).
|
||||
Best-language moment found anywhere: robfig/cron replacing ~37 hand-rolled cron-parsing
|
||||
lines (cron_engine.py:199-236) — ergonomics nicety, not a fit verdict.
|
||||
- **Kill tests (frozen in synthesis.md):** Rust-A — re-express `_run_command`
|
||||
(tool_executor.py:1064) with std::process + manual timeout; kill if >2× the ~40-line
|
||||
Python wrapper (Q4 predicts it is); Go-A — port `cron_engine._run_loop` with os/exec,
|
||||
measure task-fire latency delta (prediction: zero — 60 s sleep and child runtime dominate);
|
||||
B — parse the largest observed tool-protocol stream 1000× in Python; kill if total <1 s
|
||||
(no rewrite yields an observable win); C — instrument `_run_loop`
|
||||
(local_tool_runtime.py:417), log non-HTTP CPU per iteration (prediction <1% of wall);
|
||||
D — time `assemble`+`_admit` on the largest stored context, kill if <10 ms; E — fault-inject
|
||||
kill -9 during spool append; if recovery semantics already hold, the rewrite has no
|
||||
correctness win to buy; F — none needed: absence of any win condition IS the disproof.
|
||||
- **Sources + lessons:** live-fetched 2026-09-29 — annotations in
|
||||
`research/wave1/runtime-core/outward-sources.md`; headline lessons: Rust std::process lacks
|
||||
child timeout (Q4); Go os/exec reaches parity but changes only syntax (Q1); Python
|
||||
json/regex C-backed (Q5b); serde typed structs mismatch dynamic model output (Q5a);
|
||||
concurrency features irrelevant at ≤2 threads (Q6).
|
||||
- **Evidence sufficiency:** Python verdicts fully evidenced inward+outward; Go execution-side
|
||||
evidence honestly starved (paper kill tests carried forward); E's JSONL-spool crash-safety
|
||||
claim rests on inward semantics only — flagged in synthesis.md.
|
||||
|
||||
## W7 — Operator + TUI (units: rune_operator 61 fns, rune_tui 161 fns — largest module, tui_validator)
|
||||
|
||||
- **Research round actor:** general-3 (COMPLETED 2026-09-29 — artifacts at
|
||||
`research/wave1/tui/`: inward.md, outward/ ×8, outward-sources.md, synthesis.md)
|
||||
- **Intention profile:** terminal rendering, input handling, rich layout, hot store state, supervisory
|
||||
command surface. Design decision 4 holds: TUI consumes the interop contract, never rewritten first.
|
||||
- **Decision:** **KEEP-PYTHON (TUI shell; all 4 clusters, zero RECODE, no carve-out found).**
|
||||
Cluster scores (1 = recode, 5 = keep): (A) rendering/widgets 4 — no hot loop exists; render is
|
||||
plain Rich `Text` concatenation (rune_tui.py:1331, chat_bubble.py:157), only animation a 0.12 s
|
||||
spinner (:263), no markdown/Syntax parsing anywhere in src/rune; (B) event glue (~50 `_cmd_*`
|
||||
handlers, reactives, tool-widget maps) 5 — this is Textual's core competency (workers,
|
||||
`exclusive=True`, `call_from_thread`); vendor docs (ratatui) admit "little to no help in organizing
|
||||
large applications"; (C) model send path + envelope 5 — synchronous `send_task` in async worker;
|
||||
the envelope shape (`success/response/thinking/tool_events/degraded`, rune_tui.py:3624ff) is
|
||||
ALREADY the de-facto interop contract; (D) subprocess/streaming 5 — JSONL streaming precedent
|
||||
already in-tree (`_cmd_pull` :3443-3517): the pattern for consuming recoded units, not a recode
|
||||
target. Whole-TUI native recode 1/5 REJECTED — 80+ first-party module coupling, framework-provided
|
||||
concurrency re-implemented by hand; Go additionally toolchain-blocked. Countervailing evidence
|
||||
(Bubble Tea/tview/Codex TUI maturity) recorded honestly in outward-sources.md — it does not
|
||||
outweigh the coupling.
|
||||
- **⚠ CONTRACT GAP FLAGGED (binds Waves 2–7):** `shared/native_units.py` parses only the LAST
|
||||
stdout line — it must gain a JSONL streaming reader (per-line `json.loads` + terminal-envelope
|
||||
validation) before any recoded long-running unit can stream progress to the TUI. Small, testable,
|
||||
non-breaking; candidate for the first Wave 2 implementation issue.
|
||||
- **Adapter surface (TUI as consumer — adopted as the rubric's interop-contract restatement):**
|
||||
(1) single-invocation stateless CLI (`native_unit <op> [args]`, no daemon); (2) success = final
|
||||
stdout line `{"ok":true, …}` generalizing the model-send envelope — recoded units emitting
|
||||
`response`/`tool_events`/`degraded` plug in with zero TUI changes; (3) failure = `ok:false` +
|
||||
nonzero exit, stderr human-only, success = exit 0 AND not timed out; (4) mandatory `--version`
|
||||
on every unit (status bar + `canary_tui_drift.py` detect live units vs Python fallback);
|
||||
(5) JSONL streaming mode for long ops with terminal sentinel envelope; (6) per-call timeout
|
||||
budget, `timed_out` surfaced in `NativeResult` for graceful degradation; (7) streamed events
|
||||
referencing UI objects carry the TUI's stable keys — `(channel, call_id)` for tool widgets
|
||||
(:3676), WorkPane `_entries` object ids (:1239) — preserved verbatim; (8) std-only for
|
||||
proof-phase units.
|
||||
- **Candidate matrix summary:** Python 4/4 clusters · Rust/Go whole-TUI 1/5 rejected · carve-out
|
||||
candidates: none found (future markdown parsing would carve as a Rust native unit behind the
|
||||
adapter, not now).
|
||||
- **Kill tests (frozen in synthesis.md):** stay-Python — killed if >16 ms UI-thread stalls under
|
||||
heavy event bursts OR IPC latency dominates message round-trip (Textual pilot measurement);
|
||||
Rust recode — killed if the adapter must expose >~15 first-party modules (count today ≈80:
|
||||
killed NOW); Go recode — additionally killed by toolchain absence (`command -v go` empty).
|
||||
- **Sources + lessons:** 8 live-fetched 2026-09-29 (Textual workers guide, ratatui vendor docs,
|
||||
Bubble Tea/tview/Codex countervailing evidence, JSON Lines spec, push-granular-work guidance) —
|
||||
annotations in `research/wave1/tui/outward-sources.md`.
|
||||
- **Evidence sufficiency (honest):** full inward read of all 5 TUI files with line refs; ≥2 sources
|
||||
per major claim; gap = no runtime profiling (stall kill test unmeasured — but no mechanism exists
|
||||
for the stall to occur; the 120 ms 21-step pipeline was already removed, `_run_warm_router`
|
||||
:3575 is a no-op).
|
||||
|
||||
## W2 — Leaf utilities & canaries (~40 modules, low coupling: pins, time_awareness, message_queue, event_envelope, attention_capsule, complexity_scorer, token_optimizer, cache 501/17, prompt_sync, chat_bubble, ~15 canary_* modules)
|
||||
|
||||
- **Research round actor:** general-6 (COMPLETED 2026-09-29 — full report in actor notification)
|
||||
- **Intention profile:** small pure helpers, envelopes, scoring, cache; smallest blast radius; the
|
||||
factory + interop proving ground at real scale.
|
||||
- **Ground-truth measurements (local, this session):** Python fn call ~46 ns (100k timed
|
||||
iterations in-process) · native CLI roundtrip ~658 µs (subprocess to
|
||||
`interop/rebase-proof/target/release/rebase-proof --version`) · Python startup floor ~18 ms ·
|
||||
overhead ratio ≈ **14,000×**. The only CI-proven interop contract is the subprocess CLI boundary
|
||||
(`shared/native_units.py`: argv in → JSON stdout, `--version`, timeout, typed `NativeResult`).
|
||||
PyO3/in-process bindings excluded by Wave 0 policy. Therefore every per-call crossing costs
|
||||
≥658 µs — a per-turn helper would need to run >14,000× faster in Rust to break even.
|
||||
- **Candidate matrix (representative — full per-cluster 0–2 scoring in round report):**
|
||||
Python 12/12 in every cluster · Rust 5–6/12 · Go 1–3/12 (D5=0, not installed) · C 1–2/12.
|
||||
No module contains a hot loop (heaviest: 2 `re.sub` per turn in token_optimizer).
|
||||
- **Decision:** **KEEP-PYTHON (all ~40, zero RECODE).** Cluster verdicts: (a) pure CRUD helpers
|
||||
pins/time_awareness/token_optimizer — KEEP, CPython 3.11+ already 10–60% faster on this shape
|
||||
(PEP 659); (b) message/envelope/capsule structures — KEEP, recode would insert a boundary
|
||||
between the chattiest neighbors; (c) cache.py (501 L, largest in wave) — KEEP, the fast part is
|
||||
already C (sqlite3 DB-API 2.0 over SQLite's C library), Python is thin orchestration, batching
|
||||
(threshold 50 / 30 s) already amortizes writes; (d) chat_bubble.py — KEEP, **hard-pinned** to
|
||||
Python by the Textual framework (recode = full framework abandonment, bottleneck is human
|
||||
typing speed); (e) prompt_sync.py glue — KEEP, glue into Python siblings (memory_env,
|
||||
voice_sync, build_pipeline_policy); (f) ~19 canary_* — KEEP **categorically**, a canary that
|
||||
verifies Python importability cannot exist in another language.
|
||||
- **Kill tests (reopening criteria, all fail today on first cheap measurement):** Rust — microbench
|
||||
one per-turn helper behind the native_units CLI, must beat the Python in-process call >10×
|
||||
end-to-end including the 658 µs crossing (whole call <~46 µs; a trivial `--version` already
|
||||
costs 658 µs); Go — identical benchmark post-install, uninstall if roundtrip >100 µs with no
|
||||
named hot path; C — identical benchmark + required fuzz-clean JSON roundtrip, plus a
|
||||
demonstrated GC-free allocation-hot loop first; cache.py — recode only if Python-side
|
||||
orchestration (excl. sqlite3 waits) exceeds 5% of end-to-end latency; capsule/envelope/queue —
|
||||
recode only if one turn's Python total exceeds ~5 ms AND a candidate's CLI path is >10× cheaper.
|
||||
- **Sources + lessons:** (1) https://docs.python.org/3/whatsnew/3.11.html — 10–60% faster,
|
||||
PEP 659 specializing interpreter: CPython already optimized for helper-shaped code, the D2
|
||||
baseline is a moving improving target; (2) https://docs.python.org/3/whatsnew/3.13.html —
|
||||
free-threading/JIT experimental, KEEP-PYTHON ≠ frozen but no bets on experimental paths;
|
||||
(3) https://www.maturin.rs/ — in-process Rust↔Python needs a whole binding/packaging toolchain
|
||||
atop the CLI boundary, D3 worse than measured 658 µs (Wave 0 policy excludes it anyway);
|
||||
(4) https://go.dev/doc/install — Go is a pre-install prerequisite, D5=0 everywhere, never earned
|
||||
installation; (5) https://textual.textualize.io/ — Textual is a Python TUI framework,
|
||||
chat_bubble.py framework-pinned; (6) https://docs.python.org/3/library/sqlite3.html — sqlite3 is
|
||||
DB-API 2.0 over C, cache.py's perf layer already native. All fetched live 2026-09-29.
|
||||
- **⚠ Inventory drift (VERIFIED 2026-09-29):** complexity_scorer.py listed in
|
||||
rebase-inventory.json (2 mentions) but absent on disk — deleted in commit 867321e
|
||||
("governed retirement — complexity_scorer deleted (602 lines)"). Wave 0 inventory needs a
|
||||
correction pass; the module was scored nowhere (no source to characterize).
|
||||
|
||||
---
|
||||
|
||||
## Wave-plan consequence (T5 scope, from the verdict above)
|
||||
|
||||
With **0 recode units awarded**, the tranche's hypothesized rebase volume is real only as the
|
||||
work below. Waves 2–9 are **disposition + named improvements, NOT mass recoding**:
|
||||
|
||||
**Waves 2–7 — per-issue disposition (the 1,852 `[rebase w{wave}]` issues):**
|
||||
Each issue receives its tranche §7 disposition — improve / fix / missing-connectors answered
|
||||
with evidence — plus a language verdict of **KEEP-PYTHON with its rubric evidence line**
|
||||
(the round section for that subsystem names the claim). Disposition runs leaf-first
|
||||
(Wave 2 → 7), the canonical suite stays green throughout, and no issue closes without its
|
||||
evidence line. A recode is off the table unless a frozen kill test is reopened — and then
|
||||
tranche §6 still governs: **no benchmark win, no recode.** Go is not installed and earned no
|
||||
installation; Rust stays toolchain-ready but unjustified (standing option behind the
|
||||
CI-proven adapter).
|
||||
|
||||
**Named improvements — the actual rebase volume (ship alongside disposition):**
|
||||
1. **JSONL streaming reader for `shared/native_units.py`** (W7 contract gap): the adapter
|
||||
parses only the LAST stdout line; it must gain per-line `json.loads` + terminal-envelope
|
||||
validation before any recoded long-running unit could stream progress. Small, testable,
|
||||
non-breaking — first implementation issue of Wave 2.
|
||||
2. **httpx retry/timeout wrapper for the provider layer** (W4): the honest reliability
|
||||
upgrade is a feature-add, not a language change.
|
||||
3. **orjson upgrade path** (W3 cluster 1): the only credible Python-side JSON win —
|
||||
in-process, no boundary crossing. **DISPOSITION (2026-09-29, kill test EXECUTED and
|
||||
FAILED — KEEP-PYTHON stands, no dependency declared):** microbench on the live
|
||||
`~/.rune/consolidation/knowledge.json` (3,540,959 bytes, n=30 best-of): load 13.15 ms
|
||||
std vs 7.85 ms orjson = **1.68× (bar ≥2×: FAIL)**; dump 9.58 ms std vs 5.61 ms
|
||||
orjson-with-decode = **1.71× (bar ≥5×: FAIL)**; raw-bytes dump 1.39 ms = 6.91× but is
|
||||
not drop-in (every `write_text`/`json.dump`-to-file site would need a bytes→str or
|
||||
binary-write conversion). Roundtrip equality verified. Bench: `/tmp/orjson_bench.py`
|
||||
shape (uvx --with orjson). The bytes trap measured real: decode() eats ~60% of the
|
||||
dump win.
|
||||
4. **complexity_scorer.py inventory correction**: listed in `rebase-inventory.json` but
|
||||
deleted on disk (commit `867321e`, 602 lines) — Wave 0 inventory needs the correction
|
||||
pass. **DISPOSITION (2026-09-29, DONE):** inventory regenerated per the script's own
|
||||
header (never hand-edit) — 177→213 files, 1,852→2,314 functions, 13 dead files
|
||||
dropped; committed + pushed `5bc837c`. The W2 round's "2 mentions" drift claim
|
||||
verified FALSE against the live inventory (zero hits).
|
||||
5. **robfig/cron** (W6): recorded as a best-language footnote only; no action.
|
||||
|
||||
**Wave 8 — TUI integration proof:** demonstrate the TUI reading/utilising the mixed-language
|
||||
surfaces that exist (the `interop/rebase-proof` unit through the upgraded adapter), per the
|
||||
8-point adapter surface adopted above.
|
||||
**Wave 9 — close-out audit:** 100% of the 1,852 issues dispositioned, debt-table revision,
|
||||
version bump.
|
||||
|
||||
## Cross-cutting candidates (evaluated once, referenced by every subsystem)
|
||||
|
||||
- **Rust (present, 1.98.1):** interop proof unit already CI-proven (std-only, zero crates, `interop/rebase-proof/`, adapter in `shared/native_units.py`, ok = exit 0 AND not timed out).
|
||||
- **Go (absent):** must earn installation through D1–D5 evidence here before any recode targets it.
|
||||
- **C (present, GCC 16.2.1):** expected to lose D6 in most subsystems; scored where FFI-adjacent work exists.
|
||||
- **Python (KEEP baseline):** the default. Every subsystem begins here; evidence must move it.
|
||||
|
||||
## Evidence ledger (per-source lessons, all rounds)
|
||||
|
||||
Index of per-source evidence. Full annotations live in each round's
|
||||
`research/wave1/<subsystem>/outward-sources.md`; this ledger records the headline lesson
|
||||
each source contributed and the rubric claims it supports.
|
||||
|
||||
| Round | Sources (live-fetched 2026-09-29) | Headline lessons | Supports |
|
||||
|---|---|---|---|
|
||||
| W2 leaf utilities | 6: python.org 3.11 + 3.13 whatsnew, maturin.rs, go.dev/doc/install, textual.textualize.io, docs.python.org sqlite3 | CPython 3.11+ already 10–60% faster on helper-shaped code (PEP 659) — D2 baseline is a moving target; free-threading/JIT experimental, no bets; in-process bindings need a whole toolchain atop the CLI boundary (excluded by Wave 0 policy anyway); Go is pre-install prerequisite, D5=0; Textual framework-pins chat_bubble.py; sqlite3 perf layer already C | W2 KEEP-PYTHON ×~40; the 14,000× crossing-overhead law |
|
||||
| W3 memory | 6: orjson, sled, sqlite-vec, SQLite FTS5 docs, Go json/v2 rewrite issue, pydantic-core | orjson is the only credible Python-side JSON win (in-process, no boundary); SQLite FTS5/vec0 compute is already C; Go's JSON stack mid-rewrite upstream (versioning risk); pydantic-core shows the Rust-as-wheel pattern, excluded by policy | W3 KEEP-PYTHON 7/7; orjson upgrade path on cluster 1 |
|
||||
| W4 provider-layer | 8: live-fetched O1–O8 (HTTP/SSE ecosystem, tokio-stack precedents incl. mistral.rs, pkg.go.dev/net/http, ollama Go client/server) | stdlib-only Rust has no realistic HTTP/SSE precedent — the zero-crate contract decides Rust out of all network-wire clusters by construction; Go's stdlib HTTP+NDJSON streaming is capability-proven (its losses here are evidence-absence, not capability-absence); the honest reliability upgrade is a Python httpx retry/timeout wrapper — feature-add, not language change | W4 KEEP-PYTHON 5/5; SSE-cluster Rust kill-by-construction |
|
||||
| W5 orchestration | 4: pydantic-core, ruff blog, go.dev FAQ, Python 3.14 hashlib docs + 1 LOCAL EXPERIMENT (n=100 adapter benchmark) | adapter round-trips at 0.63ms/op — subprocess-per-op viable for coarse ops, never per-event; Python "slowness" dissolves where compute already runs in C via stdlib; the language question is really "where is the hot loop" (here: nowhere) | W5 KEEP-PYTHON 6/6; K1 kill test already run |
|
||||
| W6 runtime-core | headline lessons (full set in round outward-sources.md) | Rust std::process lacks built-in child timeout — strictly more code under zero-crate; Go os/exec reaches parity but changes only syntax; Python json/regex C-backed so rewrite swaps C for compiled code at equal asymptotic cost; serde typed structs mismatch schema-flexible model output; concurrency features irrelevant at ≤2 threads | W6 STAY-PYTHON 6/6; robfig/cron as best-language footnote |
|
||||
| W7 TUI | 8: Textual workers guide, ratatui vendor docs, Bubble Tea/tview/Codex TUI (countervailing), JSON Lines spec, push-granular-work guidance | Textual workers/`call_from_thread` are the core competency for event glue; ratatui admits "little to no help in organizing large applications"; countervailing native-TUI frameworks recorded honestly — do not outweigh 80+ module coupling; JSONL per-line parse + terminal sentinel is the streaming contract; vendor guidance: push granular speed-sensitive work to subcomponents only when measured | W7 KEEP-PYTHON 4/4; the 8-point adapter surface (adopted as the rubric's interop-contract restatement) |
|
||||
@@ -0,0 +1,50 @@
|
||||
# Wave 0 close-out report — language-rebasing tranche R1
|
||||
|
||||
Branch: `tranche-r1-wave-0` · Date: 2026-09-29
|
||||
|
||||
## 1. Inventory (docs/rebase/rebase-inventory.json)
|
||||
|
||||
- 177 Python modules, ~52,750 lines, 227 classes
|
||||
- TRUE unit count: **1,852 = 1,030 top-level functions + 822 class methods**
|
||||
- Zero duplicate call edges, zero method leaks (generator double-walk defect
|
||||
root-caused and fixed in scripts/rebase_inventory.py)
|
||||
- Cross-verified against an independent fresh AST walk: zero missing units;
|
||||
six inventory-"extras" are real nested/if-try-block classes the naive walk
|
||||
misses (inventory is MORE complete, not less)
|
||||
|
||||
## 2. Issue factory (scripts/rebase_issue_factory.py)
|
||||
|
||||
- One issue per unit following R1 §7; wave-batched (2–7); dedup-safe via
|
||||
deterministic title `[rebase w{wave}] {relpath}::{unit}` + existing-titles
|
||||
scan per run (retries never duplicate — preserve-first)
|
||||
- `plan` → {2: 239, 3: 251, 4: 129, 5: 544, 6: 415, 7: 274} = 1,852,
|
||||
zero unassigned
|
||||
- `sample --wave 5` → full §7 template holds; `_compute_hash` exactly once as
|
||||
labelled callee with line number
|
||||
- Contract fix during Wave 0: `--limit` default changed 5 → 0 (unlimited) for
|
||||
`create`; `sample` keeps its 5. A full-wave create with the old default
|
||||
would have silently truncated each wave to five issues.
|
||||
|
||||
## 3. Interop proof unit (R1 §5)
|
||||
|
||||
- Rust unit at interop/rebase-proof/ (std-only, zero crates): argv in → JSON
|
||||
on stdout, non-zero exit on failure, mandatory `--version`
|
||||
- Shell-proven: version flag, JSON out, exit 2 on failure, stderr diagnostic
|
||||
- Python thin adapter shared/native_units.py: typed NativeResult, timeout +
|
||||
stderr capture, unit failures as data (never exceptions)
|
||||
- Adapter contract fix during Wave 0: `ok` no longer requires a JSON
|
||||
`ok:true` field — plain-text output (`--version`) succeeds on exit code
|
||||
- tests/test_native_interop_proof.py: **8 passed** — version contract, sum
|
||||
happy path, hash determinism, failure paths, timeout, stderr capture
|
||||
|
||||
## 4. Per-wave issue creation
|
||||
|
||||
<!-- FILLED BY THE COMPLETION LOOP — do not hand-edit -->
|
||||
|
||||
- Wave 4 (129 units): created=127 skipped(dedup)=2 (includes smoke #417–418)
|
||||
- Wave 2 (239 units): created=239 skipped(dedup)=0
|
||||
- Wave 3 (251 units): created=251 skipped(dedup)=0
|
||||
- Wave 5 (544 units): created=544 skipped(dedup)=0
|
||||
- Wave 6 (415 units): created=415 skipped(dedup)=0
|
||||
- Wave 7 (274 units): created=274 skipped(dedup)=0
|
||||
- TOTAL: 1,850 created + 2 dedup = 1,852 / 1,852 ✅ full coverage
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"units_per_wave": {
|
||||
"2": 239,
|
||||
"3": 251,
|
||||
"4": 129,
|
||||
"5": 544,
|
||||
"6": 415,
|
||||
"7": 274
|
||||
},
|
||||
"unassigned_modules": {},
|
||||
"unassigned_module_count": 0
|
||||
}
|
||||
@@ -10,6 +10,7 @@ new surface._
|
||||
| `rune_tui.py` | The main Textual application — chat pane, work pane, status bar (version label + refresh), footer, dialogs | The known giant (file-size debt tracked in henxels); composition root for all UI |
|
||||
| `tui_validator.py` | Import-allowlist validation for TUI code | Drift defect tracked in #139 (allowlist vs `__future__`/relative imports) |
|
||||
| `tui_backlog.py` | Not a surface — the durable TUI Upgrade Backlog ledger (outstanding projection/control work per backend feature) | SCAFFOLD-TRANCHE-007; ledger only, never structural authority |
|
||||
| `tui_transport.py` | Not a surface — the transport seam for the TUI's operator calls (in-process default / /v1 wire opt-in) | H4 slice 1 (contract BUILDER-2026-10-04T10:32:00Z-cli); RUNE_TUI_TRANSPORT selects; coverage: canary_tui_transport.py |
|
||||
|
||||
## Adding a TUI surface
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1.27.1
|
||||
+26
-2
@@ -1,7 +1,31 @@
|
||||
settings:
|
||||
warn_about_similar_files:
|
||||
above: 0.85
|
||||
ignore: ["docs/review-lessons.yaml", "docs/v1/**", "docs/wiki/**", "store/spine/**"]
|
||||
ignore: ["docs/review-lessons.yaml", "docs/v1/**", "docs/wiki/**", "store/spine/**",
|
||||
# shared/ is the DEPLOYED-VINTAGE mirror tree (strangler era):
|
||||
# every src/rune <-> shared similarity here is intentional
|
||||
# (the shim delegates to the crate; the mirror is the deployed
|
||||
# vintage it strangles). Registering the deliberate pairs.
|
||||
"shared/**",
|
||||
"src/rune/drift_core.py", "src/rune/drift_compression.py",
|
||||
"src/rune/drift_observability.py", "src/rune/shard_manager.py",
|
||||
"src/rune/subconscious_pass.py",
|
||||
# deliberate mirror pairs outside the strangler set:
|
||||
# issue templates are mirrored into shared/issue_templates;
|
||||
# completion_file.py is the bridge/daemon mirror of scripts/;
|
||||
# interop/rebase-proof is a committed proof fixture.
|
||||
".gitea/ISSUE_TEMPLATE/**", "shared/issue_templates/**",
|
||||
"bridge/daemon/completion_file.py", "scripts/completion_file.py",
|
||||
"interop/rebase-proof/**",
|
||||
# per-crate copies by zero-dep design: each crate is
|
||||
# self-contained (no shared crate dependency), so the pyjson
|
||||
# JSON module is copied verbatim per crate and the tiny
|
||||
# zero-dep lockfiles are near-identical by construction.
|
||||
"src/rust/*/Cargo.lock", "src/rust/*/src/pyjson.rs",
|
||||
"src/rust/query_memory/src/json_min.rs",
|
||||
# store/scaffold is data (proof fixtures, gate objects),
|
||||
# same class as the already-ignored store/spine.
|
||||
"store/scaffold/**"]
|
||||
|
||||
henxels:
|
||||
- henxel: "Canaries live at the repo's canary slot and are registered in CI"
|
||||
@@ -20,7 +44,7 @@ henxels:
|
||||
- henxel: "Top-level docs are markdown or yaml references"
|
||||
in: ./docs/*
|
||||
allowed_filetypes: [.md, .yaml]
|
||||
except: ["./docs/v1/**", "./docs/wiki/**", "./docs/gitea_issue_reconciliation.json"]
|
||||
except: ["./docs/v1/**", "./docs/wiki/**", "./docs/gitea_issue_reconciliation.json", "./docs/rebase/**"]
|
||||
|
||||
- henxel: "The known giant stays visible — file-size debt is tracked, not silent"
|
||||
in: ./src/rune/*
|
||||
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "rebase-proof"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "rebase-proof"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# Wave 0 interop proof unit (R1 §5). Std-only by design: no crates, no network,
|
||||
# deterministic build. The pattern under proof is the CLI boundary itself, not the
|
||||
# computation inside it.
|
||||
|
||||
[[bin]]
|
||||
name = "rebase-proof"
|
||||
path = "src/main.rs"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Wave 0 interop proof unit (R1 §5 of the language-rebasing tranche).
|
||||
//!
|
||||
//! Contract under proof:
|
||||
//! - standalone binary; argv in, JSON on stdout
|
||||
//! - non-zero exit on failure (JSON error object still printed to stdout)
|
||||
//! - `--version` flag mandatory
|
||||
//! - Python wraps it in a thin adapter (`shared/native_units.py`); tests call
|
||||
//! it the same way.
|
||||
//!
|
||||
//! Ops: sum / echo / hash (fnv1a64) / sleep / fail. All std-only.
|
||||
|
||||
use std::env;
|
||||
use std::process::exit;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// FNV-1a 64-bit — dependency-free deterministic hash for the proof unit.
|
||||
fn fnv1a64(data: &[u8]) -> u64 {
|
||||
let mut hash: u64 = 0xcbf29ce484222325;
|
||||
for byte in data {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn print_json(line: &str) {
|
||||
println!("{line}");
|
||||
}
|
||||
|
||||
fn emit_ok(body: &str) {
|
||||
print_json(&format!("{{\"ok\":true,{body}}}"));
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fn emit_err(message: &str) -> ! {
|
||||
// Escape the minimal set so the JSON stays parseable for arbitrary op names.
|
||||
let escaped: String = message
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'"' => "\\\"".to_string(),
|
||||
'\\' => "\\\\".to_string(),
|
||||
'\n' => "\\n".to_string(),
|
||||
'\r' => "\\r".to_string(),
|
||||
'\t' => "\\t".to_string(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect();
|
||||
print_json(&format!("{{\"ok\":false,\"error\":\"{escaped}\"}}"));
|
||||
exit(2);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
|
||||
if args.as_slice() == ["--version"] || args.as_slice() == ["-V"] {
|
||||
println!("rebase-proof {VERSION}");
|
||||
exit(0);
|
||||
}
|
||||
if args.is_empty() {
|
||||
eprintln!("usage: rebase-proof <op> [args...] | --version");
|
||||
emit_err("no op given");
|
||||
}
|
||||
|
||||
let op = args[0].as_str();
|
||||
let rest = &args[1..];
|
||||
|
||||
match op {
|
||||
"sum" => {
|
||||
let mut total: i128 = 0;
|
||||
for a in rest {
|
||||
match a.parse::<i128>() {
|
||||
Ok(v) => total += v,
|
||||
Err(_) => emit_err(&format!("sum: not an integer: {a}")),
|
||||
}
|
||||
}
|
||||
emit_ok(&format!("\"op\":\"sum\",\"result\":{total}"));
|
||||
}
|
||||
"echo" => {
|
||||
let joined = rest.join(" ");
|
||||
let escaped: String = joined
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'"' => "\\\"".to_string(),
|
||||
'\\' => "\\\\".to_string(),
|
||||
'\n' => "\\n".to_string(),
|
||||
'\r' => "\\r".to_string(),
|
||||
'\t' => "\\t".to_string(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect();
|
||||
emit_ok(&format!("\"op\":\"echo\",\"text\":\"{escaped}\""));
|
||||
}
|
||||
"hash" => {
|
||||
let joined = rest.join(" ");
|
||||
let h = fnv1a64(joined.as_bytes());
|
||||
emit_ok(&format!("\"op\":\"hash\",\"fnv1a64\":\"{h:016x}\""));
|
||||
}
|
||||
"sleep" => {
|
||||
let ms: u64 = match rest.first().map(|s| s.parse::<u64>()) {
|
||||
Some(Ok(v)) => v,
|
||||
_ => emit_err("sleep: expected milliseconds"),
|
||||
};
|
||||
thread::sleep(Duration::from_millis(ms));
|
||||
emit_ok(&format!("\"op\":\"sleep\",\"slept_ms\":{ms}"));
|
||||
}
|
||||
"fail" => {
|
||||
// Deliberate failure: stderr diagnostic + JSON error + exit 2.
|
||||
// Proves stderr capture and non-zero exit in the adapter.
|
||||
eprintln!("rebase-proof: deliberate failure for adapter proof");
|
||||
emit_err("deliberate failure");
|
||||
}
|
||||
other => emit_err(&format!("unknown op: {other}")),
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"BLE001": 347,
|
||||
"BLE001": 345,
|
||||
"S110": 75
|
||||
}
|
||||
@@ -1 +1,401 @@
|
||||
[]
|
||||
[
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read a file with optional windowed viewing (offset/limit). Returns numbered lines. Use offset/limit for large files (100 lines at a time).",
|
||||
"capabilities": [
|
||||
"read_file"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"path exists and is a readable file"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "read_file read a file with optional windowed viewing (offset/limit). returns numbered lines. use offset/limit for large files (100 lines at a time). native tool"
|
||||
},
|
||||
{
|
||||
"name": "edit_file",
|
||||
"description": "Surgical search/replace edit. More reliable than full file rewrites. The search string must match exactly.",
|
||||
"capabilities": [
|
||||
"edit_file"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "write",
|
||||
"preconditions": [
|
||||
"path is inside an allowed write directory",
|
||||
"search string matches exactly once"
|
||||
],
|
||||
"side_effects": [
|
||||
"rewrites the target file atomically after approval",
|
||||
"creates a .bak backup under ~/.rune/undo"
|
||||
],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "edit_file surgical search/replace edit. more reliable than full file rewrites. the search string must match exactly. native tool"
|
||||
},
|
||||
{
|
||||
"name": "write_file",
|
||||
"description": "Write content to a file. Creates parent directories if needed. Shows diff before writing. Use for new files or full rewrites.",
|
||||
"capabilities": [
|
||||
"write_file"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "write",
|
||||
"preconditions": [
|
||||
"path is inside an allowed write directory"
|
||||
],
|
||||
"side_effects": [
|
||||
"writes the full file content atomically after approval",
|
||||
"creates a .bak backup under ~/.rune/undo"
|
||||
],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "write_file write content to a file. creates parent directories if needed. shows diff before writing. use for new files or full rewrites. native tool"
|
||||
},
|
||||
{
|
||||
"name": "glob_files",
|
||||
"description": "Find files matching a glob pattern. Returns file paths sorted by modification time.",
|
||||
"capabilities": [
|
||||
"glob_files"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"search directory is readable"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "glob_files find files matching a glob pattern. returns file paths sorted by modification time. native tool"
|
||||
},
|
||||
{
|
||||
"name": "grep_files",
|
||||
"description": "Search for a pattern in files. Returns file paths and line numbers. Use include to filter by file type.",
|
||||
"capabilities": [
|
||||
"grep_files"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"search directory is readable"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "grep_files search for a pattern in files. returns file paths and line numbers. use include to filter by file type. native tool"
|
||||
},
|
||||
{
|
||||
"name": "list_directory",
|
||||
"description": "List files and directories at a path. Returns names with / suffix for directories.",
|
||||
"capabilities": [
|
||||
"list_directory"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"path is a readable directory"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "list_directory list files and directories at a path. returns names with / suffix for directories. native tool"
|
||||
},
|
||||
{
|
||||
"name": "lint_file",
|
||||
"description": "Lint a file for syntax errors and style issues. Blocks bad edits. Supports Python (ruff), JS/TS (eslint), Go (golangci-lint), and more.",
|
||||
"capabilities": [
|
||||
"lint_file"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"file exists",
|
||||
"a linter is configured for the language"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "lint_file lint a file for syntax errors and style issues. blocks bad edits. supports python (ruff), js/ts (eslint), go (golangci-lint), and more. native tool"
|
||||
},
|
||||
{
|
||||
"name": "run_tests",
|
||||
"description": "Run tests for a file or project. Auto-detects test runner from language.",
|
||||
"capabilities": [
|
||||
"run_tests"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "write",
|
||||
"preconditions": [
|
||||
"a test runner is configured for the project"
|
||||
],
|
||||
"side_effects": [
|
||||
"runs project test commands which may create or delete files"
|
||||
],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "run_tests run tests for a file or project. auto-detects test runner from language. native tool"
|
||||
},
|
||||
{
|
||||
"name": "format_code",
|
||||
"description": "Auto-format code files. Supports Python (black/ruff), JS/TS (prettier), Go (gofmt), and more.",
|
||||
"capabilities": [
|
||||
"format_code"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "write",
|
||||
"preconditions": [
|
||||
"path is inside an allowed write directory",
|
||||
"a formatter is configured for the language"
|
||||
],
|
||||
"side_effects": [
|
||||
"rewrites files in place to the formatter's style"
|
||||
],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "format_code auto-format code files. supports python (black/ruff), js/ts (prettier), go (gofmt), and more. native tool"
|
||||
},
|
||||
{
|
||||
"name": "git_diff",
|
||||
"description": "Show git diff for staged or unstaged changes.",
|
||||
"capabilities": [
|
||||
"git_diff"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"working directory is a git repository"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "git_diff show git diff for staged or unstaged changes. native tool"
|
||||
},
|
||||
{
|
||||
"name": "git_log",
|
||||
"description": "Show recent git commits.",
|
||||
"capabilities": [
|
||||
"git_log"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"working directory is a git repository"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "git_log show recent git commits. native tool"
|
||||
},
|
||||
{
|
||||
"name": "git_commit",
|
||||
"description": "Commit changes with a message. Auto-stages modified files.",
|
||||
"capabilities": [
|
||||
"git_commit"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "irreversible",
|
||||
"preconditions": [
|
||||
"working directory is a git repository",
|
||||
"there are changes to commit"
|
||||
],
|
||||
"side_effects": [
|
||||
"creates a commit on the current branch"
|
||||
],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "git_commit commit changes with a message. auto-stages modified files. native tool"
|
||||
},
|
||||
{
|
||||
"name": "repo_map",
|
||||
"description": "Generate an AST-based codebase map showing key symbols (classes, functions, imports). Uses tree-sitter for 100+ languages.",
|
||||
"capabilities": [
|
||||
"repo_map"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"target directory is readable"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "repo_map generate an ast-based codebase map showing key symbols (classes, functions, imports). uses tree-sitter for 100+ languages. native tool"
|
||||
},
|
||||
{
|
||||
"name": "run_command",
|
||||
"description": "Run an approved command. Prefer argv (shell=False); shell syntax requires shell=true and explicit user approval.",
|
||||
"capabilities": [
|
||||
"run_command"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "irreversible",
|
||||
"preconditions": [
|
||||
"operator approves the command",
|
||||
"required binaries are on PATH"
|
||||
],
|
||||
"side_effects": [
|
||||
"executes arbitrary commands with their full side effects"
|
||||
],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "run_command run an approved command. prefer argv (shell=false); shell syntax requires shell=true and explicit user approval. native tool"
|
||||
},
|
||||
{
|
||||
"name": "read_persona",
|
||||
"description": "Read Rune's persona definition. Returns the full persona block with voice, identity, and rules.",
|
||||
"capabilities": [
|
||||
"read_persona"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"persona MEMORY.md exists"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "read_persona read rune's persona definition. returns the full persona block with voice, identity, and rules. native tool"
|
||||
},
|
||||
{
|
||||
"name": "query_memory",
|
||||
"description": "Query Rune's memory store. Returns relevant memories for a query.",
|
||||
"capabilities": [
|
||||
"query_memory"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"memory store database is available"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "query_memory query rune's memory store. returns relevant memories for a query. native tool"
|
||||
},
|
||||
{
|
||||
"name": "check_growth",
|
||||
"description": "Check Rune's growth log. Returns recent growth entries and patterns.",
|
||||
"capabilities": [
|
||||
"check_growth"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"growth log file exists"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "check_growth check rune's growth log. returns recent growth entries and patterns. native tool"
|
||||
},
|
||||
{
|
||||
"name": "diagnose_behavior",
|
||||
"description": "Diagnose Rune's current behavior. Returns analysis of persona adherence, voice consistency, and drift.",
|
||||
"capabilities": [
|
||||
"diagnose_behavior"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"recent response text provided"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "diagnose_behavior diagnose rune's current behavior. returns analysis of persona adherence, voice consistency, and drift. native tool"
|
||||
},
|
||||
{
|
||||
"name": "find_capability",
|
||||
"description": "Search all installed capabilities for what you need. Returns matching tool names with descriptions and argument schemas. Use this when a capability you need is not in your current tool set; the matched tool becomes callable in the same session.",
|
||||
"capabilities": [
|
||||
"find_capability"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"a capability query describing the needed outcome"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "find_capability search all installed capabilities for what you need. returns matching tool names with descriptions and argument schemas. use this when a capability you need is not in your current tool set; the matched tool becomes callable in the same session. native tool"
|
||||
}
|
||||
]
|
||||
+38287
-15
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,48 @@
|
||||
# Phase G slice 1 — retrieval-index authority supersession ledger
|
||||
|
||||
**Date:** 2026-10-02 · **Branch:** `phase-g-strangler-retrieval-index` · **Unit issue:** conductor/rune#2448
|
||||
|
||||
## What was superseded
|
||||
|
||||
The pure-Python `query_memory` implementation (formerly `src/rune/query_memory.py`,
|
||||
deployed at `~/.config/opencode/scripts/query_memory.py`) is RETIRED as a production
|
||||
implementation. Production authority now rests with:
|
||||
|
||||
1. **Implementation:** Rust crate `src/rust/query_memory/` (lib.rs core + cdylib, ten
|
||||
`extern "C"` FFI symbols, zero external crates).
|
||||
2. **Production service surface:** `shared/query_memory.py` — a ctypes-backed shim
|
||||
(179 lines) over `qm_loader.py`, byte-deployed to `~/.config/opencode/scripts/query_memory.py`,
|
||||
serving the FULL prior Python class API including the `self.history` snapshot.
|
||||
|
||||
## Strangler doctrine (master doc lines 685–770) — step discharge
|
||||
|
||||
| Step | Requirement | Evidence |
|
||||
|------|-------------|----------|
|
||||
| 1–4 | candidate built, contract frozen, parity proven | commits `9b76585`+`54d2db9`; `/tmp/phaseG_retrieval_index_contract.md` frozen; parity harness ALL GREEN |
|
||||
| 5 | qualification | `/tmp/phaseG_qual_bench.out` (Rust CLI ~45× faster end-to-end); canaries both sides |
|
||||
| 6 | canaries conformance | Python canary 12/12; Rust canary 27/27 |
|
||||
| 7 | shadow comparison vs live store | `/tmp/phaseG_shadow.py` 46/46 exact-equal vs 77-record production store |
|
||||
| 8 | authority switch + observed consumer evidence | commit `6f44a87`; both consumers verified live — `memory_store.py` (lines 820–821, live 76-record store, nested stats shape) + `growth_simulator.py` (record_query through Rust core); #2448 comment id 33795 |
|
||||
| 9 | runtime evidence post-switch | post-switch verification wall ALL GREEN: Python canary 12/12, FFI smoke 41/41, Rust canary 27/27, parity harness ALL GREEN |
|
||||
| 10 | old production path unreachable | ALL import paths (`import query_memory`) now resolve to the deployed shim; `shared/graph_shard_bridge.py` tolerant import serves the shim; the orphan `src/rune/query_memory.py` had ZERO importers (verified by sweep incl. path-string references) |
|
||||
| 11 | old implementation removed or reference-only | `src/rune/query_memory.py` DELETED (this commit); the only stale reference (`scripts/structural_coverage_audit.py` registry mapping shared/ as a mirror of src/rune) corrected — query_memory removed from `SRC_A`/`SHARED_D`, added to `SHARED_B` (canonical, ops-harness owned) |
|
||||
| 12 | supersession ledger | THIS FILE |
|
||||
|
||||
Deletion discipline (language-governance rule ⑥) satisfied: old code removed only after
|
||||
the new implementation was PROVEN — wall green, shadow 46/46, both production consumers
|
||||
observed live on the Rust core before deletion.
|
||||
|
||||
## Consumer map at supersession
|
||||
|
||||
- `~/.config/opencode/scripts/memory_store.py` — in-process, `adaptive_exploration_rate` (line 820).
|
||||
- `~/.config/opencode/scripts/growth_simulator.py` — in-process, `record_query`.
|
||||
- `shared/graph_shard_bridge.py` — tolerant import (try/except), follows the switch.
|
||||
- `src/rune/tool_executor.py` — NOT a consumer (nominal grep hit only; goes through `memory_store.cached_recall`).
|
||||
|
||||
## Verification commands (reproducible)
|
||||
|
||||
- `/home/nick/.venvs/memory/bin/python3 src/rune/canary_query_memory.py` → 12/12
|
||||
- `/home/nick/.venvs/memory/bin/python3 src/rust/query_memory/canary_rust.py` → 27/27
|
||||
- `/home/nick/.venvs/memory/bin/python3 src/rune/ffi_query_memory_smoke.py` → 41/41
|
||||
- `/home/nick/.venvs/memory/bin/python3 /tmp/phaseG_parity.py` → ALL GREEN
|
||||
- `/home/nick/.venvs/memory/bin/python3 scripts/structural_coverage_audit.py` → exit 0, coverage 100%
|
||||
@@ -0,0 +1,66 @@
|
||||
# Phase G Slice 2 — FROZEN CONTRACT: evidence-provenance (T7.3)
|
||||
|
||||
Frozen: 2026-10-03, session ses_ffe5f08a7892affeGzZWhhm0wR. Branch `phase-g-strangler-evidence-provenance` (cut from slice-1 head `2ac2d0a`). Gitea issue #2449. Nothing below may change without a recorded amendment in this file.
|
||||
|
||||
## 1. Scope (observed, not inferred)
|
||||
|
||||
Six modules in `src/rune/`, 1796 lines total, one test file each as marked:
|
||||
|
||||
| Module | Lines | Surface (grep-verified 2026-10-03) |
|
||||
|---|---|---|
|
||||
| `audit_ledger.py` | 231 | `_compute_hash(prev_hash, entry)`, `append_entry(entry_type, entry_id, details)`, `verify_chain() -> {valid, entries, reason}`, `get_entries(limit=50)`, `log_promotion(entry_id, old_status, new_status, wash_count, reason)`, `log_gate_result(entry_id, verdict, critics)`, `log_drift_alert(alert_type, message, severity)`, `main()` |
|
||||
| `behavior.py` | 411 | `connect()`, `add_correction(original, corrected, scope_tags=None, domain="", pattern="")`, `add_habit(trigger, action, name="", context_tags=None, min_activations=3)`, `match_context(context, match_type=None)`, `reinforce(item_id, outcome, item_type="habit")`, `list_items(item_type="all", limit=20)`, `behavior_stats()`, `main()`, `PLUGIN` dict |
|
||||
| `behaviour_ledger.py` | 251 | 9 state constants (UNVALIDATED…NOT_APPLICABLE) + `STATES` + `CONFIRMED_STATES` tuples, `BehaviourLedger` class |
|
||||
| `claim_audit.py` | 430 | `PATTERNS` list, `EVIDENCE_TAG` regex, `LEDGER_MARKERS`, `reframe(text)`, `scan(text)`, `verify(claim)`, `degrade(claim, verdict)`, `journal(entry)`, `report()`, `audit(draft)`, `handle_hook(event, data=None)`, `main()`, `PLUGIN` |
|
||||
| `evidence_probe.py` | 214 | `PROBE_STATUSES`, `PROBE_FIELDS`, `MAX_PROBES_PER_TASK=2`, `ProbeBudgetExceeded`, `ProbeResult`, `build_probe_prompt(question, artifact_path)`, `validate_probe(artifact)`, `count_probes(artifact_dir)`, `check_budget(artifact_dir)`, `record_probe(result, artifact_dir)`, `run_probe(question, artifact_dir, runner=None, model="UNKNOWN")` |
|
||||
| `verification_layer.py` | 259 | `VerificationResult`, seven `verify_*` functions (exit_code, unit_test, schema, file_exists, file_changed, tool_receipt, constraint_check), `verify(result, verifier_type)`, `handle_hook`, `PLUGIN` |
|
||||
|
||||
Plus `shared/audit_ledger.py` (210 ln) — OLDER vintage of audit_ledger.
|
||||
|
||||
## 2. Authority decisions (per module)
|
||||
|
||||
- **src/rune/ is the authority vintage for all six modules** — hardened newer code (memory_env-pathed storage, defensive handling). The deployed `~/.config/opencode/scripts/{audit_ledger,behavior,claim_audit}.py` copies are OLDER vintages (hardcoded expanduser paths). The authority switch is a VINTAGE UPGRADE plus a language swap — not a mirror replacement.
|
||||
- `shared/audit_ledger.py` is superseded alongside the deployed copy (same older vintage, same defects).
|
||||
|
||||
## 3. Live-data contract (the linchpin — observed)
|
||||
|
||||
- **Live ledger:** `/home/nick/.config/opencode/memory/promotion_ledger.jsonl` (21 entries). `verify_chain()` under the deployed module's own defaults returns `{'valid': True, 'entries': 21, 'reason': 'chain intact'}` — OBSERVED 2026-10-03.
|
||||
- **Live signing key: `rune-drift-integrity-2024`** (the deployed/shared default). The src/rune default `rune-governed-adaptation-2024` FAILS the live chain — it is a latent break, recorded as a defect this slice fixes.
|
||||
- **Hash scheme (both vintages identical):** `HMAC-SHA256(key, (prev_hash + json.dumps(entry_without_hash, sort_keys=True)).encode()).hexdigest()`; genesis prev_hash = `"genesis"`; entry lines written with `json.dumps(entry)` in insertion order (type, entry_id, details, timestamp, prev_hash, hash); timestamp = `datetime.now(timezone.utc).isoformat()`.
|
||||
- **Key rule for the Rust candidate:** read `AUDIT_LEDGER_KEY` env; DEFAULT = `rune-drift-integrity-2024` (the key that verifies the live chain). Never default to the governed key — it verifies nothing that exists.
|
||||
- **Live `behavior.db`:** SQLite at memory_env memory dir, touched Sep 24 — must remain readable by the Rust candidate's storage layer.
|
||||
|
||||
## 4. Rust candidate design
|
||||
|
||||
Crate `/mnt/workdrive/rune/src/rust/evidence_provenance/` following the proven slice-1 shape: zero-dep core in `lib.rs`, `cdylib` FFI exports, hand-rolled JSON serialization where byte-parity demands it. **Port order within the slice** (dependency + risk order):
|
||||
|
||||
1. **audit_ledger** — linchpin: live data, external consumers, key contract.
|
||||
2. **verification_layer** — pure logic, no storage, one test file; fastest port.
|
||||
3. **evidence_probe** — file-based budget/records; `rune_operator.py:581` consumer.
|
||||
4. **behaviour_ledger** — state machine, src/rune-only.
|
||||
5. **behavior** — SQLite habits; live DB; deployed vintage upgrade + `rune_tui.py` consumer.
|
||||
6. **claim_audit** — heaviest (warm-neuron store + graph DB + regex engine); last.
|
||||
|
||||
Python-side ctypes loaders (`*_loader.py`) + deployed shims per slice-1 pattern, so every consumer keeps its import surface unchanged.
|
||||
|
||||
## 5. Parity + qualification criteria
|
||||
|
||||
- **Byte-parity:** Rust `verify_chain` output byte-identical to Python's on the live ledger AND on a mutation battery (tampered entry, broken chain, invalid JSON, empty ledger). Parity harness both sides, slice-1 pattern.
|
||||
- **Canaries:** Python-side canary module per ported module + Rust canary; both green before any switch.
|
||||
- **Shadow:** shadow-compare Rust vs Python on live-shaped data before authority switch.
|
||||
- **Performance:** like-for-like benchmark on the real consumption shape; no regression (Nick's law: combination does not decrease speed).
|
||||
|
||||
## 6. Consumers + adoption plan (Nick's directive: adoption during the refactor)
|
||||
|
||||
| Consumer | Imports | Switch |
|
||||
|---|---|---|
|
||||
| `~/.config/opencode/scripts/curator.py` | audit_ledger | at audit_ledger switch |
|
||||
| `canary_tui_drift.py`, `canary_full_system.py` | audit_ledger | at audit_ledger switch |
|
||||
| `rune_tui.py` (L62, L83) | claim_audit, behavior | at each module's switch |
|
||||
| `rune_operator.py:581` | evidence_probe (relative) | at evidence_probe switch |
|
||||
|
||||
Deployed copies become ctypes shims at each module's authority switch (vintage upgrade included — the shim serves the src/rune contract, not the old vintage's). Old deployed Python deletions follow language-governance ⑥: only after the new is proven.
|
||||
|
||||
## 7. Exit criteria
|
||||
|
||||
All twelve doctrine steps discharged per module-port with observed evidence on #2449; coverage-audit registry remapped; supersession ledger written; audit regression gate green.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Phase G slice 2 — evidence-provenance authority supersession ledger
|
||||
|
||||
**Date:** 2026-10-03 · **Branch:** `phase-g-strangler-evidence-provenance` · **Unit issue:** conductor/rune#2449
|
||||
|
||||
## What was superseded
|
||||
|
||||
The pure-Python evidence-provenance implementations (six modules in `src/rune/`, 1796 lines; plus the two OLD deployed vintages and `shared/audit_ledger.py`) are RETIRED as production implementations for their ported surfaces. Production authority now rests with:
|
||||
|
||||
1. **Rust crate `src/rust/evidence_provenance/`** — six cores: `lib.rs` (audit_ledger chain + FFI), `verification.rs` (7 verifiers), `probe.rs` (schema gate + budget), `behaviour.rs` (state machine), `habitmath.rs` (reinforce math), `claims.rs` (claim-audit decision skeleton); cdylib FFI (`ep_*` symbols, 21 total), zero external crates.
|
||||
2. **Python shims** at `src/rune/{audit_ledger,verification_layer,evidence_probe,behaviour_ledger,behavior,claim_audit}.py` — serving the src/rune authority contract through `ep_loader.py` (in-process, no interpreter startup for the ported logic).
|
||||
3. **Deployed shims** at `~/.config/opencode/scripts/{audit_ledger,behavior,claim_audit}.py` — same shape; audit_ledger + claim_audit + behavior carry the LIVE data-path rules (drift-key ledger, live behavior.db, real journal); claim_audit additionally carries the preserved SYMMETRY GUARD (deployed-vintage feature) and the repaired `scan_text()` TUI surface.
|
||||
|
||||
## Port ledger (all six, in contract §4 order)
|
||||
|
||||
| Port | Module | Rust core | Parity | Commit | Consumer proof |
|
||||
|------|--------|-----------|--------|--------|----------------|
|
||||
| 1 | audit_ledger | lib.rs chain ops | shadow 18/18; canaries 28/28+23/23 | `114c394`+`4b0d1d2` | live ledger entries 4242 (post-switch write), verify 23-intact |
|
||||
| 2 | verification_layer | verification.rs | 33/33 (7 verifiers + CLI render) | `9cb93e6` | contracts.py, canary_native, rune_operator:323 dataclass |
|
||||
| 3 | evidence_probe | probe.rs | 24/24 + consumer tests 23/23 | `3450a1c` | rune_operator:581/603, research_stage_executor:312 |
|
||||
| 4 | behaviour_ledger | behaviour.rs | 13/13 full-scenario | `8973caa` | testing_mode:19, iteration:23, rune_tui:3152 |
|
||||
| 5 | behavior | habitmath.rs | 16/16 + full-stack live-db | `2732677` | rune_tui:83; deployed vintage upgraded (live db read-verified) |
|
||||
| 6 | claim_audit | claims.rs | 17/17 + real-journal aggregate (1127 rows) | `969e39f` | rune_tui:62/2468/3752, contracts.py, hook_engine:270 |
|
||||
|
||||
Errors: byte-equal (duplicate/unknown-behaviour/unknown-state; probe schema-gate lists incl. Python repr rendering; CLI argparse shapes + exit codes).
|
||||
|
||||
## Strangler doctrine (master doc lines 685–770) — step discharge (slice level)
|
||||
|
||||
| Step | Requirement | Evidence |
|
||||
|------|-------------|----------|
|
||||
| 1–4 | candidate built, contract frozen, parity proven | commits `114c394` → `969e39f`; frozen contract `plans/2026-10-03-phase-g-evidence-provenance-contract.md` (commit `7c68e41`); per-port parity harnesses ALL GREEN |
|
||||
| 5 | qualification | per-port parity + decision-math batteries; canary pair per port (Python 74/74, Rust 66/66 — all six port sections accumulated); performance follows the slice-1 in-process shape (no interpreter startup; same cdylib) |
|
||||
| 6 | canaries conformance | canary_audit_ledger.py 74/74; canary_evidence_provenance_rust.py 66/66 (per-port sections for ALL six ports; dual-mode LIVE/ISOLATED for live-ledger pins — run 18141 repair); embedded tests through shims (verification test, 23 consumer tests) |
|
||||
| 7 | shadow comparison vs live-shaped data | port-1 shadow 18/18 vs `939883d:shared/audit_ledger.py` (banked at `114c394`); ports 2–6 use direct byte-parity batteries vs the live originals (same discipline, stronger: full-document byte-quality) |
|
||||
| 8 | authority switch + observed consumer evidence | shims deployed + live-verified per port: entry 4242 (first post-switch ledger write), live behavior.db read through the port-5 shim, real 1127-row journal through the port-6 aggregation, TUI backend trio imports, TUI scan_text FIRST-EVER live call |
|
||||
| 9 | runtime evidence post-switch | post-switch walls per port: parity round-trips, embedded tests, consumer chains (rune.behaviour_ledger / rune.evidence_probe / rune.claim_audit + deployed trio) — all GREEN |
|
||||
| 10 | old production path unreachable | `import audit_ledger` (deployed consumers) → shim; `rune.behaviour_ledger` / `rune.evidence_probe` / `rune.claim_audit` / `rune.verification_layer` → shims; `shared/audit_ledger.py` had ZERO importers (verified by sweep incl. path-string forms) — DELETED |
|
||||
| 11 | old implementation removed or reference-only | `shared/audit_ledger.py` DELETED (this commit, after the proven wall + zero-importer sweep); six src/rune modules remain as shims (the API contract); original Python lives in git history. Coverage registry remapped: `audit_ledger` removed from SHARED_D; gate exit 0 post-deletion |
|
||||
| 12 | supersession ledger | THIS FILE |
|
||||
|
||||
Deletion discipline (language-governance rule ⑥): `shared/audit_ledger.py` removed only after (a) the deployed shim proved live against the real ledger, (b) the shadow wall was green, (c) a zero-importer sweep across src/ shared/ scripts/ tests/. The remaining five src/rune originals were rewritten AS shims in their own commits (git history holds each original); they are the API contract and stay.
|
||||
|
||||
## Vintage-merge disclosures
|
||||
|
||||
- **SYMMETRY GUARD preserved:** the deployed `claim_audit` vintage carried `research_lens.symmetry_check` integration (2026-08-15) absent from src/rune — preserved in both shims (feature-preserving merge; load-bearing behavior).
|
||||
- **DEAD CONSUMER REPAIRED:** `rune_tui.py:3752` `claim_audit.scan_text()` had NO implementation in either vintage (silent try/except death) — now implemented, first-class, live-verified.
|
||||
- **Sequence break (port 1):** the audit_ledger shim was deployed BEFORE its canary pair existed; both canaries subsequently written and green — disclosed on #2449 comment 33817, not retrofitted.
|
||||
|
||||
## Consumer map at supersession
|
||||
|
||||
- `~/.config/opencode/scripts/rune_tui.py` — claim_audit (report L2468, scan_text L3752), behaviour (L3152), behavior (L83).
|
||||
- `src/rune/rune_operator.py` — evidence_probe (L581/603), VerificationResult dataclass (L323).
|
||||
- `src/rune/contracts.py` — dynamic `__import__` surfaces (verification_layer, claim_audit).
|
||||
- `src/rune/testing_mode.py:19` / `src/rune/iteration.py:23` — behaviour_ledger constants + class.
|
||||
- `scripts/research_stage_executor.py:312` — evidence_probe orchestration.
|
||||
- `~/.config/opencode/scripts/curator.py` / `canary_full_system.py` — audit_ledger (deployed shim).
|
||||
- `src/rune/dependency_tracker.py` — map-strings only (NOT an import).
|
||||
|
||||
## Live-data rules (contract §3) — honored in the shims
|
||||
|
||||
- Live promotion ledger: drift key verified 23-intact through the shim + FFI; explicit `AUDIT_LEDGER_KEY` override honored.
|
||||
- Live behavior.db: read/written through the deployed shim at `~/.config/opencode/memory/behavior.db`; parity scenarios ran on temp dirs only.
|
||||
- Live claim_audit journal (1127 rows): read-only aggregate through the Rust core; writes stay Python.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Phase H plan — Go front end / Rust back end (Nick's architecture directive)
|
||||
|
||||
Status: PLAN (awaiting Nick's sign-off before any code)
|
||||
Directive: USER-LOCKED (2026-10-04). Supersedes the KEEP-PYTHON verdicts in
|
||||
docs/rebase-language-rubric.md wherever they conflict.
|
||||
|
||||
## 1. The directive
|
||||
|
||||
Go owns the front end: runtime entry points, the first-call surface, the
|
||||
user-facing interface and its calls. Rust owns the back end: every core
|
||||
logic module. Python survives only as transitional glue inside a module
|
||||
until its own Go/Rust surface lands.
|
||||
|
||||
## 2. What "front end" means concretely here
|
||||
|
||||
From the runtime-audit memory (2026-09-10): the live entry chain is
|
||||
rune_tui -> send_task -> ContextBroker -> ModelRouter -> local/Cloud
|
||||
transports. The user-facing calls are: the TUI process itself, the CLI
|
||||
entry (python -m rune / rune binary), the operator message path, and the
|
||||
OpenAI-compatible /v1 endpoints MiMoCode serves.
|
||||
|
||||
Candidates for the Go first-call layer, in dependency order:
|
||||
- rune binary (process entry, arg parsing, process lifecycle) — replaces
|
||||
the Python module-loading bootstrap.
|
||||
- CLI/IPC command dispatch — the first-call surface that routes a user
|
||||
command to the right back-end service.
|
||||
- The OpenAI-compatible HTTP endpoint surface (/v1 chat completions) —
|
||||
stdlib net/http + NDJSON streaming is exactly the area the rubric called
|
||||
Go's only capability-proven fit (W4 evidence, unchanged by the directive).
|
||||
- TUI: NOT in the first Go surface. Textual coupling is deep (80+ modules
|
||||
per the rubric); the front-end Go layer first exposes the API the TUI
|
||||
consumes, and the TUI itself migrates later as its own wave.
|
||||
|
||||
## 3. What "back end" means concretely here
|
||||
|
||||
All pure-logic modules move into the two existing zero-dep crates
|
||||
(evidence_provenance, query_memory) or a third crate for the remaining
|
||||
domains. The zero-external-crate discipline stands (the rubric's W4 Rust
|
||||
kill-rule for network code stays valid INSIDE Rust crates: no network code
|
||||
in crates, that belongs to the Go layer).
|
||||
|
||||
Back-end candidates not yet ported (pure logic, no external deps):
|
||||
- drift_core, drift_cache, drift_compression (scored math) — memory umbrella
|
||||
- warm_neuron_store/warm_router scoring paths — memory umbrella
|
||||
- memory_store consolidation/scoring helpers — memory umbrella
|
||||
- capability_fit/capability_broker scoring — capability umbrella
|
||||
- tokenizer/counting helpers in token_optimizer — inference umbrella
|
||||
Each port follows the slice-2 pipeline: contract, zero-dep Rust, byte-parity
|
||||
battery vs the Python vintage, dual-mode canary, consumer import chain,
|
||||
CI green, supersession ledger.
|
||||
|
||||
## 4. Sequencing (per wave, each independently mergeable)
|
||||
|
||||
H0 — enablement (no behavior change):
|
||||
- Install Go on the host + the runner user's PATH; pin a version in
|
||||
requirements-ci-equivalent (a go-version file + CI check).
|
||||
- CI: add a Go build+vet+test job (empty module, exits 0) so the pipeline
|
||||
carries Go before any code lands.
|
||||
|
||||
H1 — Go first-call shim:
|
||||
- Go binary `rune` replacing the Python entrypoint's process bootstrap:
|
||||
arg parsing, env resolution, spawn the Python runtime (transitional).
|
||||
- Canary: same first-call contract (exit codes, env handoff, TTY attach).
|
||||
|
||||
H2 — Go command dispatch + /v1 endpoint:
|
||||
- Go serves /v1 chat completions + CLI dispatch, calling the Python core
|
||||
through the same local transport the TUI uses today.
|
||||
- Parity battery: request/response shapes vs the Python server for the
|
||||
same canned conversations (byte-level for metadata, streaming NDJSON).
|
||||
|
||||
H3 — back-end wave (parallel to H1/H2 where independent):
|
||||
- Ports listed in section 3, one or two modules per slice, same slice-2
|
||||
discipline. No port starts without its parity battery written first.
|
||||
|
||||
H4 — TUI migration wave (its own contract, later):
|
||||
- Only after H1-H2 prove the Go front end serves the same surfaces the TUI
|
||||
consumes. The TUI is the largest single module (4k+ lines, Textual) and
|
||||
earns its own contract.
|
||||
|
||||
## 5. Honest constraints (carried from the rubric, not contradicted)
|
||||
|
||||
- Go must be installed first (rubric D5 gate: it failed this before; the
|
||||
directive now outweighs the gate but installation is still step zero).
|
||||
- Network/streaming code lives in the Go layer, never in Rust crates
|
||||
(rubric W4: stdlib-only Rust has no credible HTTP/SSE precedent).
|
||||
- SQLite/regex/file-IO stay at their current language boundaries per the
|
||||
zero-dep split law from slice 2 (math Rust, IO at the edges).
|
||||
- Every surface gets the same proof pipeline as slice 2: observed-not-
|
||||
invented expectations, byte parity, dual-mode canary, CI green.
|
||||
|
||||
## 6. Success measure
|
||||
|
||||
The tranche is complete when: the `rune` process starts as a Go binary;
|
||||
user-facing calls (CLI, /v1, operator path) are served by Go; every core
|
||||
logic module on the port list is byte-parity Rust; Python remains only in
|
||||
surfaces explicitly contracted as keep-Python (TUI internals until H4,
|
||||
test harness, canary frameworks).
|
||||
+2
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.86.0"
|
||||
version = "0.90.0"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"aiohttp>=3.9.0",
|
||||
"websockets>=12.0",
|
||||
"psutil>=5.9.0",
|
||||
"pillow>=10.0",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
|
||||
+5
-1
@@ -29,6 +29,10 @@
|
||||
"**/__pycache__"
|
||||
],
|
||||
"extraPaths": [
|
||||
"scripts"
|
||||
"scripts",
|
||||
"src/rune",
|
||||
"shared",
|
||||
"src/rust/evidence_provenance",
|
||||
"src/rust/query_memory"
|
||||
]
|
||||
}
|
||||
+10
-8
@@ -1,5 +1,5 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# uv pip compile pyproject.toml -o /tmp/req_new.txt --extra dev --extra memory --python-version 3.11
|
||||
# uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11
|
||||
aiohappyeyeballs==2.7.1
|
||||
# via aiohttp
|
||||
aiohttp==3.14.3
|
||||
@@ -18,9 +18,9 @@ certifi==2026.7.22
|
||||
# httpx
|
||||
chardet==7.6.0
|
||||
# via diff-cover
|
||||
coverage==7.16.1
|
||||
coverage==7.16.2
|
||||
# via rune (pyproject.toml)
|
||||
diff-cover==10.5.1
|
||||
diff-cover==10.6.0
|
||||
# via rune (pyproject.toml)
|
||||
frozenlist==1.8.0
|
||||
# via
|
||||
@@ -50,21 +50,23 @@ markdown-it-py==4.2.0
|
||||
# mdit-py-plugins
|
||||
# rich
|
||||
# textual
|
||||
markupsafe==3.0.3
|
||||
markupsafe==3.0.4
|
||||
# via jinja2
|
||||
mdit-py-plugins==0.6.1
|
||||
# via textual
|
||||
mdurl==0.1.2
|
||||
# via markdown-it-py
|
||||
multidict==6.9.0
|
||||
multidict==6.9.1
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
nodeenv==1.10.0
|
||||
nodeenv==1.11.0
|
||||
# via pyright
|
||||
packaging==26.3
|
||||
# via pytest
|
||||
platformdirs==4.11.11
|
||||
pillow==12.3.0
|
||||
# via rune (pyproject.toml)
|
||||
platformdirs==4.12.3
|
||||
# via textual
|
||||
pluggy==1.6.0
|
||||
# via
|
||||
@@ -116,7 +118,7 @@ typing-extensions==4.16.0
|
||||
# pyright
|
||||
# textual
|
||||
# textual-autocomplete
|
||||
websockets==17.1
|
||||
websockets==17.2
|
||||
# via rune (pyproject.toml)
|
||||
yarl==1.25.1
|
||||
# via aiohttp
|
||||
+17
-3
@@ -148,9 +148,18 @@ def sha_branch(sha: str) -> str:
|
||||
|
||||
|
||||
def sha_on_master(sha: str) -> bool:
|
||||
out = _git("branch", "--contains", sha)
|
||||
return any(l.strip().endswith("master") or l.strip() == "master"
|
||||
for l in out.splitlines())
|
||||
"""SHA is an ancestor of master (local or remote-tracking refs).
|
||||
|
||||
M10 correction (2026-09-19): --all so a freshly fetched master
|
||||
(refs/remotes/gitea/master) counts; local branches alone go stale
|
||||
between fetches. derive_op_type refreshes refs before calling this.
|
||||
"""
|
||||
out = _git("branch", "--all", "--contains", sha)
|
||||
for l in out.splitlines():
|
||||
s = l.strip().removeprefix("remotes/").removesuffix(" *")
|
||||
if s == "master" or s.endswith("/master"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def open_pr_for_sha(sha: str) -> int | None:
|
||||
@@ -184,6 +193,11 @@ def derive_op_type(sha: str) -> tuple[str, str]:
|
||||
were created from model-supplied defaults and cannot be trusted.
|
||||
Never trusts model input.
|
||||
"""
|
||||
# 0. M10 correction (2026-09-19): refresh remote-tracking refs before
|
||||
# inference. A just-merged master commit is invisible to
|
||||
# branch --contains until refs are fetched; stale local git state
|
||||
# must never outrank repo truth. Bounded by GIT_TIMEOUT, fail-open.
|
||||
_git("fetch", "--quiet", "gitea", "+refs/heads/*:refs/remotes/gitea/*")
|
||||
for rec in _load_wait_records():
|
||||
if _rec_sha(rec) != sha:
|
||||
continue
|
||||
|
||||
@@ -86,9 +86,12 @@ def main() -> int:
|
||||
if live_state == "open" and rec["tracker_state"] == "closed" and rec["reconciliation_state"] in TERMINAL_STATES:
|
||||
drift.append(f"#{num}: live state open but artifact says closed/{rec['reconciliation_state']}")
|
||||
live_bug = "bug" in [l["name"] for l in li.get("labels", [])]
|
||||
if live_bug and not rec.get("live_defect"):
|
||||
# Same open-only exemption as the artifact-records block above: a
|
||||
# closed issue's labels are historical record; FIXED_VERIFIED issues
|
||||
# legitimately keep the bug label after closure.
|
||||
if live_bug and rec.get("tracker_state") == "open" and not rec.get("live_defect"):
|
||||
drift.append(f"#{num}: live bug label present but artifact records no live defect")
|
||||
if not live_bug and rec.get("live_defect"):
|
||||
if not live_bug and rec.get("live_defect") and rec.get("tracker_state") == "open":
|
||||
drift.append(f"#{num}: artifact records live defect but live bug label absent")
|
||||
|
||||
# Live defects in the artifact must all be OPEN in the tracker
|
||||
|
||||
@@ -1,43 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""install_mimo_hook.py — T21.2 §21.2D: canonical MiMo hook installer.
|
||||
"""install_mimo_hook.py — T21.2 §21.2D (T22: multi-hook): canonical MiMo
|
||||
hook installer.
|
||||
|
||||
Canonical source: config/mimo-hooks/event-wait-guard.ts
|
||||
Target: ~/.mimocode/hooks/event-wait-guard.ts
|
||||
Verifies hash after install. Detects drift in both directions."""
|
||||
Canonical sources: config/mimo-hooks/*.ts
|
||||
Targets: ~/.mimocode/hooks/<same-name>.ts
|
||||
|
||||
Every hook is hash-verified after install; drift is detected in both
|
||||
directions. T22 added builder-bridge.ts to the managed set — it was
|
||||
previously installed by hand, unversioned, which is how it silently rotted
|
||||
(hardcoded ephemeral interpreter). A hook that reaches the builder must be
|
||||
version-controlled and drift-checked like any other authority.
|
||||
|
||||
Usage:
|
||||
python3 scripts/install_mimo_hook.py # sync all managed hooks
|
||||
python3 scripts/install_mimo_hook.py --check # verify only, install nothing
|
||||
"""
|
||||
import hashlib
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CANONICAL = Path(__file__).resolve().parent.parent / "config" / "mimo-hooks" / "event-wait-guard.ts"
|
||||
TARGET = Path.home() / ".mimocode" / "hooks" / "event-wait-guard.ts"
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
CANONICAL_DIR = REPO / "config" / "mimo-hooks"
|
||||
TARGET_DIR = Path.home() / ".mimocode" / "hooks"
|
||||
|
||||
# The managed set. Adding a file here is what makes a hook version-controlled.
|
||||
MANAGED = ["event-wait-guard.ts", "builder-bridge.ts"]
|
||||
|
||||
|
||||
def sha256(p: Path) -> str:
|
||||
return hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CANONICAL.is_file():
|
||||
print(f"canonical source missing: {CANONICAL}")
|
||||
return 1
|
||||
canon_hash = sha256(CANONICAL)
|
||||
if TARGET.is_file():
|
||||
target_hash = sha256(TARGET)
|
||||
if target_hash == canon_hash:
|
||||
print(f"in-sync: {target_hash[:12]}")
|
||||
return 0
|
||||
print(f"DRIFT: canonical={canon_hash[:12]} installed={target_hash[:12]}")
|
||||
def sync_one(name: str, check_only: bool) -> bool:
|
||||
canonical = CANONICAL_DIR / name
|
||||
target = TARGET_DIR / name
|
||||
if not canonical.is_file():
|
||||
print(f"canonical source missing: {canonical}")
|
||||
return False
|
||||
canon_hash = sha256(canonical)
|
||||
if target.is_file() and sha256(target) == canon_hash:
|
||||
print(f"in-sync: {name} {canon_hash[:12]}")
|
||||
return True
|
||||
if check_only:
|
||||
state = "DRIFT" if target.is_file() else "MISSING"
|
||||
inst = sha256(target)[:12] if target.is_file() else "-"
|
||||
print(f"{state}: {name} canonical={canon_hash[:12]} installed={inst}")
|
||||
return False
|
||||
if target.is_file():
|
||||
print(f"DRIFT: {name} canonical={canon_hash[:12]} installed={sha256(target)[:12]}")
|
||||
else:
|
||||
print(f"hook missing at {TARGET}")
|
||||
TARGET.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(CANONICAL, TARGET)
|
||||
installed_hash = sha256(TARGET)
|
||||
if installed_hash != canon_hash:
|
||||
print(f"INSTALL FAILED: hash mismatch after copy")
|
||||
return 1
|
||||
print(f"installed: {installed_hash[:12]}")
|
||||
return 0
|
||||
print(f"hook missing at {target}")
|
||||
TARGET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(canonical, target)
|
||||
if sha256(target) != canon_hash:
|
||||
print(f"INSTALL FAILED: {name} hash mismatch after copy")
|
||||
return False
|
||||
print(f"installed: {name} {canon_hash[:12]}")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
check_only = "--check" in sys.argv
|
||||
ok = True
|
||||
for name in MANAGED:
|
||||
ok = sync_one(name, check_only) and ok
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tranche R1 Wave 0 — whole-project inventory generator.
|
||||
|
||||
Parses every .py file under src/rune/ with the stdlib AST and emits
|
||||
docs/rebase/rebase-inventory.json: files, classes, functions (with signatures and
|
||||
line ranges), imports, and best-effort caller/callee edges (intra-module call
|
||||
graph + cross-module edges resolved through import tables).
|
||||
|
||||
Deterministic and idempotent: same tree in, same JSON out (sorted keys,
|
||||
sorted files). The issue-factory reads this file; nothing else may edit it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
SRC = REPO / "src" / "rune"
|
||||
OUT = REPO / "docs" / "rebase" / "rebase-inventory.json"
|
||||
|
||||
|
||||
def _arg_str(a: ast.arg) -> str:
|
||||
s = a.arg
|
||||
if a.annotation is not None:
|
||||
# Degraded signature (no annotation) is deterministic metadata — never
|
||||
# let one pathological annotation node abort the whole inventory walk.
|
||||
try:
|
||||
s += f": {ast.unparse(a.annotation)}"
|
||||
except (AttributeError, RecursionError, ValueError):
|
||||
pass
|
||||
return s
|
||||
|
||||
|
||||
def _signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
||||
parts = [_arg_str(a) for a in node.args.args]
|
||||
if node.args.vararg:
|
||||
parts.append(f"*{node.args.vararg.arg}")
|
||||
elif node.args.kwonlyargs:
|
||||
parts.append("*")
|
||||
parts += [_arg_str(a) for a in node.args.kwonlyargs]
|
||||
if node.args.kwarg:
|
||||
parts.append(f"**{node.args.kwarg.arg}")
|
||||
ret = ""
|
||||
if node.returns is not None:
|
||||
# Same degraded-metadata contract as _arg_str.
|
||||
try:
|
||||
ret = f" -> {ast.unparse(node.returns)}"
|
||||
except (AttributeError, RecursionError, ValueError):
|
||||
pass
|
||||
return f"({', '.join(parts)}){ret}"
|
||||
|
||||
|
||||
class FileVisitor(ast.NodeVisitor):
|
||||
def __init__(self) -> None:
|
||||
self.functions: list[dict] = []
|
||||
self.classes: list[dict] = []
|
||||
self.imports: list[dict] = []
|
||||
self.calls: list[tuple[int, str]] = [] # (lineno, dotted call name)
|
||||
|
||||
def _fn(self, node, parents: list[str]) -> dict:
|
||||
return {
|
||||
"name": ".".join([*parents, node.name]),
|
||||
"signature": _signature(node),
|
||||
"line_start": node.lineno,
|
||||
"line_end": node.end_lineno or node.lineno,
|
||||
"decorators": [ast.unparse(d) for d in node.decorator_list
|
||||
if isinstance(d, (ast.Name, ast.Attribute, ast.Call))],
|
||||
"async": isinstance(node, ast.AsyncFunctionDef),
|
||||
}
|
||||
|
||||
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
||||
methods = []
|
||||
for child in node.body:
|
||||
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
methods.append(self._fn(child, [node.name]))
|
||||
self.calls.extend(
|
||||
(n.lineno, ast.unparse(n.func))
|
||||
for n in ast.walk(child)
|
||||
if isinstance(n, ast.Call) and not isinstance(n.func, ast.Name if False else type(None))
|
||||
and isinstance(n.func, (ast.Name, ast.Attribute))
|
||||
)
|
||||
self.classes.append({
|
||||
"name": node.name,
|
||||
"bases": [ast.unparse(b) for b in node.bases],
|
||||
"line_start": node.lineno,
|
||||
"line_end": node.end_lineno or node.lineno,
|
||||
"methods": methods,
|
||||
})
|
||||
# visit only non-function children: methods are already captured above,
|
||||
# and a generic_visit would re-fire visit_FunctionDef on them (duplicate
|
||||
# units + duplicate call edges). Nested classes are still visited.
|
||||
for child in node.body:
|
||||
if not isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
self.visit(child)
|
||||
|
||||
def visit_FunctionDef(self, node) -> None:
|
||||
# top-level only reached here (methods captured in ClassDef)
|
||||
self.functions.append(self._fn(node, []))
|
||||
self.calls.extend(
|
||||
(n.lineno, ast.unparse(n.func))
|
||||
for n in ast.walk(node)
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, (ast.Name, ast.Attribute))
|
||||
)
|
||||
# do not descend into nested defs separately; they are folded into parents
|
||||
# (their calls are already captured by the ast.walk above). No generic_visit:
|
||||
# it would re-collect nested defs as top-level functions and duplicate edges.
|
||||
|
||||
visit_AsyncFunctionDef = visit_FunctionDef
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
for a in node.names:
|
||||
self.imports.append({"module": a.name, "as": a.asname, "line": node.lineno})
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
mod = node.module or ""
|
||||
for a in node.names:
|
||||
self.imports.append({"module": f"{mod}.{a.name}" if a.name != "*" else mod,
|
||||
"as": a.asname or a.name, "line": node.lineno,
|
||||
"from": True})
|
||||
|
||||
|
||||
def collect_file(path: Path) -> dict:
|
||||
src_text = path.read_text(encoding="utf-8", errors="replace")
|
||||
tree = ast.parse(src_text)
|
||||
v = FileVisitor()
|
||||
v.visit(tree)
|
||||
|
||||
# module-level calls (outside defs/classes) — walk tree body directly.
|
||||
# SKIP def/class statements: their bodies are walked by the visit_ methods;
|
||||
# walking them here duplicated every call edge inside every top-level function.
|
||||
for stmt in tree.body:
|
||||
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
continue
|
||||
for n in ast.walk(stmt):
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, (ast.Name, ast.Attribute)):
|
||||
v.calls.append((n.lineno, ast.unparse(n.func)))
|
||||
|
||||
defined = {f["name"].split(".")[-1] for f in v.functions}
|
||||
defined |= {m["name"].split(".")[-1] for c in v.classes for m in c["methods"]}
|
||||
defined |= {c["name"] for c in v.classes}
|
||||
|
||||
callees = []
|
||||
for lineno, call in v.calls:
|
||||
root = call.split(".")[0].split("(")[0].strip()
|
||||
callees.append({"line": lineno, "call": call,
|
||||
"internal": root in defined or root == "self"})
|
||||
|
||||
return {
|
||||
"path": str(path.relative_to(REPO)),
|
||||
"lines": len(src_text.splitlines()),
|
||||
"imports": sorted({i["module"] for i in v.imports}),
|
||||
"import_lines": v.imports,
|
||||
"functions": sorted(v.functions, key=lambda f: (f["line_start"], f["name"])),
|
||||
"classes": sorted(v.classes, key=lambda c: c["line_start"]),
|
||||
"calls": callees,
|
||||
}
|
||||
|
||||
|
||||
def cross_module_edges(files: dict[str, dict]) -> dict[str, set[str]]:
|
||||
"""Resolve import tables: module-stem -> set of imported module stems
|
||||
(rune-internal only), so the factory can attribute cross-module edges."""
|
||||
graph = defaultdict(set)
|
||||
for rel, info in files.items():
|
||||
stem = Path(rel).stem
|
||||
for imp in info["imports"]:
|
||||
top = imp.split(".")[0]
|
||||
second = ".".join(imp.split(".")[:2])
|
||||
if top == "rune":
|
||||
graph[stem].add(imp.replace("rune.", "", 1).split(".")[0])
|
||||
elif not imp.startswith("_") and top not in sys.stdlib_module_names:
|
||||
graph[stem].add(f"ext:{top}")
|
||||
return graph
|
||||
|
||||
|
||||
def main() -> None:
|
||||
py_files = sorted(SRC.rglob("*.py"))
|
||||
files: dict[str, dict] = {}
|
||||
for p in py_files:
|
||||
try:
|
||||
info = collect_file(p)
|
||||
except SyntaxError as e: # a file that cannot parse is a finding, not a skip
|
||||
info = {"path": str(p.relative_to(REPO)), "lines": 0, "imports": [],
|
||||
"import_lines": [], "functions": [], "classes": [], "calls": [],
|
||||
"parse_error": f"{type(e).__name__}: {e}"}
|
||||
files[info["path"]] = info
|
||||
|
||||
graph = cross_module_edges(files)
|
||||
total_fns = sum(len(f["functions"]) + sum(len(c["methods"]) for c in f["classes"])
|
||||
for f in files.values())
|
||||
doc = {
|
||||
"_meta": {
|
||||
"generator": "scripts/rebase_inventory.py",
|
||||
"tranche": "R1 language rebase, Wave 0",
|
||||
"note": "Machine-generated. Regenerate with the script; never hand-edit.",
|
||||
"files": len(files),
|
||||
"functions_total": total_fns,
|
||||
"classes_total": sum(len(f["classes"]) for f in files.values()),
|
||||
"lines_total": sum(f["lines"] for f in files.values()),
|
||||
},
|
||||
"import_graph": {k: sorted(v) for k, v in sorted(graph.items())},
|
||||
"files": dict(sorted(files.items())),
|
||||
}
|
||||
OUT.write_text(json.dumps(doc, indent=1, sort_keys=False) + "\n", encoding="utf-8")
|
||||
m = doc["_meta"]
|
||||
print(f"inventory written: {OUT}")
|
||||
print(f"files={m['files']} functions={m['functions_total']} "
|
||||
f"classes={m['classes_total']} lines={m['lines_total']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tranche R1 Wave 0 — per-function issue factory.
|
||||
|
||||
Reads docs/rebase/rebase-inventory.json and emits one issue per unit (top-level
|
||||
function or class method) following tranche R1 §7. Wave-batched, dedup-safe
|
||||
(deterministic title + existing-issue scan before create), rate-limited.
|
||||
|
||||
Modes:
|
||||
plan — coverage report: units per wave, unassigned modules (no silent drops)
|
||||
sample — emit N rendered issues to a file for review (no Gitea call)
|
||||
create — create issues in Gitea for one wave
|
||||
|
||||
Dedup contract: issue titles are deterministic —
|
||||
[rebase w{wave}] {relpath}::{unit}
|
||||
The factory fetches existing issue titles once per run and skips any that
|
||||
already exist, so retries never duplicate (preserve-first Gitea addendum).
|
||||
|
||||
The factory NEVER writes behaviour prose. Current-behaviour paragraphs and
|
||||
the three evidence answers (IMPROVE / FIX / MISSING CONNECTORS) are filled at
|
||||
disposition time by the working agent — the issue carries the mandatory
|
||||
fields and line ranges, not fabricated content.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
INVENTORY = REPO_ROOT / "docs" / "rebase" / "rebase-inventory.json"
|
||||
COVERAGE_OUT = REPO_ROOT / "docs" / "rebase" / "rebase-wave-coverage.json"
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "https://gitea.thingscouldgetdicey.online")
|
||||
REPO = "conductor/rune"
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "shared"))
|
||||
from gitea_auth import load_gitea_token # noqa: E402
|
||||
|
||||
TRANCHE_REF = "tranche R1 language rebase (2026-09-29), ~/.config/opencode/tranches/2026-09-29-language-rebase-tranche-R1.md"
|
||||
|
||||
# Wave membership from R1 §4 (initial, measured). Directory entries match by
|
||||
# path prefix; bare stems match the file stem. Modules matching no entry are
|
||||
# reported UNASSIGNED by `plan` — never silently dropped (R1 §9 risk 7).
|
||||
WAVE_PLAN = {
|
||||
2: [ # leaf utilities & canaries
|
||||
"pins", "time_awareness", "message_queue", "event_envelope",
|
||||
"attention_capsule", "complexity_scorer", "token_optimizer", "cache",
|
||||
"prompt_sync", "chat_bubble", "canary_*",
|
||||
],
|
||||
3: [ # memory & persistence
|
||||
"memory_store", "curator", "warm_router", "memory_consolidation",
|
||||
"graph_memory", "sleep_time", "night_mode", "identity_anchors",
|
||||
"query_memory", "memory_env", "warm_neuron_store", "subconscious_pass",
|
||||
"task_persist",
|
||||
],
|
||||
4: [ # provider/model layer
|
||||
"provider_registry", "model_connect", "model_routing", "model_profiles",
|
||||
"provider_budget", "standardized_providers", "model_registry",
|
||||
"connection_pool", "forge_client",
|
||||
],
|
||||
5: [ # orchestration, authority, plugins, skills, governance
|
||||
"orchestration/", "microkernel", "contracts", "build_mode", "plugins/",
|
||||
"skills/", "construction_authority", "construction_authority_cli",
|
||||
"construction_decision", "construction_mode", "verification_construction",
|
||||
"authority_recovery", "spine", "drift_core", "drift_ledger",
|
||||
"drift_observability", "persona_gate", "safety_override", "witness",
|
||||
"behavior", "growth_delta", "claim_audit", "verification_layer",
|
||||
"drift_compression", "drift_cache", "self_improve", "persona_definition",
|
||||
"outcome_ledger", "audit_ledger", "subsystem_health",
|
||||
],
|
||||
6: [ # runtime core & tooling
|
||||
"tool_executor", "tool_protocol", "local_tool_runtime",
|
||||
"capability_broker", "context_broker", "work_manager", "cron_engine",
|
||||
"auto_tasks", "goal", "failure_auto_filer", "hook_engine",
|
||||
"elastic_cognition", "event_bus", "system_registry", "abstraction",
|
||||
"tiered_executor", "progressive_escalation", "tier_manager",
|
||||
"parallel_processor", "shard_manager", "response_adapter",
|
||||
"context_pressure", "auto_context", "cognitive_budgeter",
|
||||
"recall_router", "inference_trace", "task_gate", "salience_gate",
|
||||
"dependency_tracker", "rune_profiler",
|
||||
],
|
||||
7: [ # operator + TUI, last by design
|
||||
"rune_operator", "rune_tui", "tui_validator", "voice_hybrid",
|
||||
"voice_sync", "voicebox", "voice_injector", "message_widgets",
|
||||
"rune_graphic", "rune_spinner", "__main__",
|
||||
],
|
||||
}
|
||||
|
||||
WAVE_NAMES = {
|
||||
2: "leaf utilities & canaries",
|
||||
3: "memory & persistence",
|
||||
4: "provider/model layer",
|
||||
5: "orchestration, authority, plugins, skills, governance",
|
||||
6: "runtime core & tooling",
|
||||
7: "operator + TUI",
|
||||
}
|
||||
|
||||
|
||||
def match_module(relpath: str):
|
||||
"""Return the wave number for a module path, or None if unassigned."""
|
||||
stem = Path(relpath).stem
|
||||
for wave, patterns in WAVE_PLAN.items():
|
||||
for pat in patterns:
|
||||
if pat.endswith("/"):
|
||||
if pat in relpath:
|
||||
return wave
|
||||
elif pat.endswith("*"):
|
||||
if stem.startswith(pat[:-1]):
|
||||
return wave
|
||||
elif stem == pat:
|
||||
return wave
|
||||
return None
|
||||
|
||||
|
||||
def iter_units(inv):
|
||||
"""Yield (relpath, unit_name, entry) for every function and method."""
|
||||
for relpath, mod in sorted(inv["files"].items()):
|
||||
for fn in mod["functions"]:
|
||||
yield relpath, fn["name"], fn
|
||||
for cls in mod["classes"]:
|
||||
for m in cls.get("methods", []):
|
||||
yield relpath, f"{cls['name']}.{m['name']}", m
|
||||
|
||||
|
||||
def internal_edges(relpath: str, entry: dict):
|
||||
"""Collect internal (in-repo) call names and line numbers for a unit."""
|
||||
lines = (entry["line_start"], entry["line_end"])
|
||||
mod = inv_cache["files"][relpath]
|
||||
calls = [c for c in mod.get("calls", [])
|
||||
if lines[0] <= c["line"] <= lines[1] and c.get("internal")]
|
||||
return calls
|
||||
|
||||
|
||||
inv_cache = {}
|
||||
|
||||
|
||||
def issue_title(wave: int, relpath: str, unit: str) -> str:
|
||||
return f"[rebase w{wave}] {relpath}::{unit}"
|
||||
|
||||
|
||||
def render_issue(wave: int, relpath: str, unit: str, entry: dict) -> dict:
|
||||
calls = internal_edges(relpath, entry)
|
||||
call_lines = "\n".join(
|
||||
f" - `{c['call']}` (line {c['line']})" for c in calls[:20]
|
||||
) or " - (no internal call edges in range)"
|
||||
sig = entry.get("signature", "()")
|
||||
body = f"""\
|
||||
|
||||
## Identity (from the Wave 0 inventory — machine-generated)
|
||||
|
||||
- **File**: `{relpath}`
|
||||
- **Unit**: `{unit}`
|
||||
- **Signature**: `def {unit}{sig}` (async: {entry.get('async', False)})
|
||||
- **Line range**: {entry['line_start']}–{entry['line_end']}
|
||||
- **Decorators**: {entry.get('decorators', []) or 'none'}
|
||||
- **Internal call edges** (callers/callees inside the repo, from inventory):
|
||||
{call_lines}
|
||||
|
||||
## Current behaviour
|
||||
|
||||
One honest paragraph from READING THE CODE at the line range above — what it
|
||||
actually does, not what the docstring promises. Write this at disposition
|
||||
time; the factory does not fabricate it.
|
||||
|
||||
## The three questions — each answered WITH EVIDENCE
|
||||
|
||||
1. **IMPROVE?** Can the unit be improved within its scope? Evidence or "no — because …".
|
||||
(Bounded to unit scope; larger findings spawn NEW issues, never expand this one.)
|
||||
2. **FIX?** Is it broken, buggy, or failing its expected outcome? Evidence: a failing
|
||||
test, a reproduced fault, or a code-level defect citation. A "no" needs evidence too.
|
||||
3. **MISSING CONNECTORS?** Are there expected outcomes this unit should connect to but
|
||||
doesn't (unwired events, dead parameters, promised-but-absent calls)? Evidence required.
|
||||
|
||||
A silent skip on any question is a factory bug — answer all three.
|
||||
|
||||
## Language verdict (per tranche R1 §6 rubric — evidence beats instinct)
|
||||
|
||||
Choose exactly one, with the evidence line:
|
||||
- **KEEP-PYTHON** — one sentence of justification.
|
||||
- **RECODE-<lang>** — rubric scores (all 7 dimensions) + benchmark-gate plan: the compiled
|
||||
replacement must beat the Python original on the MEASURED hot path, or the recode is
|
||||
rejected and recorded. No win, no recode.
|
||||
- **MERGE-into:<module>** — name the target and why.
|
||||
- **DEPRECATE** — name the caller that no longer needs it.
|
||||
|
||||
Go is NOT pre-decided. Python is not pre-decided. The rubric decides.
|
||||
|
||||
## Constraint context
|
||||
|
||||
- henxels structural contract: this unit's module ≤ 1,200 lines (or on the debt table's
|
||||
exception list — check before adding lines).
|
||||
- Spine: any module-surface change requires `store/spine/current.json` regen with the code
|
||||
change (`test_manifest_maps_every_module` is the arbiter for new top-level modules).
|
||||
- Debt table: docs/structural-contract.md.
|
||||
|
||||
## Pipeline plan
|
||||
|
||||
1. Branch `issue-<N>` from current master (existing issue-N convention).
|
||||
2. Scaffolding activation for any construction (construction_mode/decision per unit change).
|
||||
3. Research rounds are MANDATORY for any RECODE verdict (research-stage-headless; ≥2
|
||||
live-verified external sources per claim; per-source lessons; kill test per candidate).
|
||||
4. Implementation behind the interop contract (R1 §5): compiled units are standalone
|
||||
binaries — argv in, JSON on stdout, non-zero exit on failure, `--version` mandatory;
|
||||
Python wraps in a thin adapter; store stays the shared truth.
|
||||
5. Local gates: pytest + ruff + spine-store regen (if surface changed) + henxels.
|
||||
6. PR → pipeline-gate + ci + required checks → merge-robot → auto-version.
|
||||
|
||||
## Verification
|
||||
|
||||
- The specific test proving this unit's change: (write at disposition).
|
||||
- The keep-green set it must not break: full suite must stay green; named
|
||||
keep-green tests for this unit's callers.
|
||||
|
||||
## Provenance
|
||||
|
||||
- Tranche: {TRANCHE_REF}
|
||||
- Wave: {wave} ({WAVE_NAMES.get(wave, 'unassigned')})
|
||||
- Inventory: docs/rebase/rebase-inventory.json (regenerate with scripts/rebase_inventory.py — never hand-edit)
|
||||
- Factory: scripts/rebase_issue_factory.py
|
||||
"""
|
||||
return {
|
||||
"title": issue_title(wave, relpath, unit),
|
||||
"body": body,
|
||||
"labels": ["rebase", f"wave-{wave}"],
|
||||
"meta": {
|
||||
"wave": wave,
|
||||
"file": relpath,
|
||||
"unit": unit,
|
||||
"line_start": entry["line_start"],
|
||||
"line_end": entry["line_end"],
|
||||
"async": entry.get("async", False),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def fetch_existing_titles() -> set:
|
||||
"""Fetch all issue titles in the repo (paginated) for dedup."""
|
||||
token = load_gitea_token()
|
||||
titles = set()
|
||||
page = 1
|
||||
while True:
|
||||
url = (f"{GITEA_URL}/api/v1/repos/{REPO}/issues"
|
||||
f"?state=all&type=issues&limit=50&page={page}")
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
batch = json.loads(resp.read())
|
||||
if not batch:
|
||||
break
|
||||
titles.update(i["title"] for i in batch)
|
||||
if len(batch) < 50:
|
||||
break
|
||||
page += 1
|
||||
return titles
|
||||
|
||||
|
||||
def create_issue(entry: dict) -> dict:
|
||||
token = load_gitea_token()
|
||||
url = f"{GITEA_URL}/api/v1/repos/{REPO}/issues"
|
||||
payload = json.dumps({
|
||||
"title": entry["title"],
|
||||
"body": entry["body"],
|
||||
"labels": [], # label NAMES are set via the labels endpoint by auto-label; keep create minimal
|
||||
}).encode()
|
||||
req = urllib.request.Request(url, data=payload, method="POST",
|
||||
headers={"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def cmd_plan(inv):
|
||||
counts = {}
|
||||
unassigned_modules = {}
|
||||
for relpath, unit, entry in iter_units(inv):
|
||||
wave = match_module(relpath)
|
||||
counts[wave] = counts.get(wave, 0) + 1
|
||||
if wave is None:
|
||||
unassigned_modules.setdefault(relpath, inv["files"][relpath]["lines"])
|
||||
report = {
|
||||
"units_per_wave": {str(k if k is not None else "UNASSIGNED"): v
|
||||
for k, v in sorted(counts.items(), key=lambda kv: str(kv[0]))},
|
||||
"unassigned_modules": dict(sorted(unassigned_modules.items(), key=lambda kv: -kv[1])),
|
||||
"unassigned_module_count": len(unassigned_modules),
|
||||
}
|
||||
print(json.dumps(report, indent=1))
|
||||
COVERAGE_OUT.write_text(json.dumps(report, indent=1) + "\n")
|
||||
print(f"\ncoverage written to {COVERAGE_OUT.relative_to(REPO_ROOT)}", file=sys.stderr)
|
||||
return 0 if not unassigned_modules else 1
|
||||
|
||||
|
||||
def cmd_sample(inv, wave, limit, out):
|
||||
emitted = 0
|
||||
records = []
|
||||
for relpath, unit, entry in iter_units(inv):
|
||||
if match_module(relpath) != wave:
|
||||
continue
|
||||
records.append(render_issue(wave, relpath, unit, entry))
|
||||
emitted += 1
|
||||
if emitted >= limit:
|
||||
break
|
||||
Path(out).write_text("\n".join(json.dumps(r) for r in records) + "\n")
|
||||
print(f"sampled {emitted} issues for wave {wave} -> {out}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_create(inv, wave, limit, dry_gap):
|
||||
existing = fetch_existing_titles()
|
||||
created, skipped = 0, 0
|
||||
for relpath, unit, entry in iter_units(inv):
|
||||
if match_module(relpath) != wave:
|
||||
continue
|
||||
title = issue_title(wave, relpath, unit)
|
||||
if title in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
issue = create_issue(render_issue(wave, relpath, unit, entry))
|
||||
created += 1
|
||||
print(f"created #{issue.get('number')}: {title}", file=sys.stderr)
|
||||
if limit and created >= limit:
|
||||
break
|
||||
time.sleep(dry_gap)
|
||||
print(f"\nwave {wave}: created={created} skipped(dedup)={skipped}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("command", choices=["plan", "sample", "create"])
|
||||
ap.add_argument("--wave", type=int, help="wave number (2-7)")
|
||||
ap.add_argument("--limit", type=int, default=0, help="max issues for sample/create (0 = unlimited; sample defaults to 5)")
|
||||
ap.add_argument("--out", default=None, help="sample output file")
|
||||
ap.add_argument("--gap", type=float, default=0.5, help="seconds between creates")
|
||||
args = ap.parse_args()
|
||||
|
||||
inv_cache.update(json.loads(INVENTORY.read_text()))
|
||||
inv = inv_cache
|
||||
|
||||
if args.command == "plan":
|
||||
return cmd_plan(inv)
|
||||
if args.command == "sample":
|
||||
if not args.wave:
|
||||
ap.error("--wave required")
|
||||
out = args.out or f"docs/rebase-factory-sample-w{args.wave}.json"
|
||||
return cmd_sample(inv, args.wave, args.limit or 5, out)
|
||||
if args.command == "create":
|
||||
if not args.wave:
|
||||
ap.error("--wave required")
|
||||
return cmd_create(inv, args.wave, args.limit, args.gap)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -50,7 +50,7 @@ SRC_A = {
|
||||
"memory_store": "memory", "memory_selector": "memory",
|
||||
"memory_consolidation": "memory", "memory_env": "memory",
|
||||
"shard_manager": "memory", "tier_manager": "memory",
|
||||
"graph_memory": "memory", "query_memory": "memory",
|
||||
"graph_memory": "memory",
|
||||
"curator": "memory", "sleep_time": "memory",
|
||||
"subconscious_pass": "memory", "warm_neuron_store": "memory",
|
||||
"warm_router": "memory", "abstraction": "memory",
|
||||
@@ -166,14 +166,12 @@ SCRIPTS_C = {"fake_worker", "tui_startup_canary"}
|
||||
|
||||
# shared/ — harness-facing layer; mirrors of canonical src modules = D
|
||||
SHARED_D = {
|
||||
"audit_ledger": "src/rune/audit_ledger.py",
|
||||
"dependency_tracker": "src/rune/dependency_tracker.py",
|
||||
"drift_cache": "src/rune/drift_cache.py",
|
||||
"drift_compression": "src/rune/drift_compression.py",
|
||||
"drift_core": "src/rune/drift_core.py",
|
||||
"drift_observability": "src/rune/drift_observability.py",
|
||||
"gitea_auth": "scripts/gitea_auth.py",
|
||||
"query_memory": "src/rune/query_memory.py",
|
||||
"shard_manager": "src/rune/shard_manager.py",
|
||||
"subconscious_pass": "src/rune/subconscious_pass.py",
|
||||
"tier_manager": "src/rune/tier_manager.py",
|
||||
@@ -182,7 +180,8 @@ SHARED_B_OWNER = "ops-harness"
|
||||
SHARED_B = {"auto_detect", "autonomy_loop", "build_orchestrator",
|
||||
"build_pipeline_policy", "cognitive_loop", "custodian",
|
||||
"dream_cycle", "gitea_pipeline", "gitea_provision",
|
||||
"graph_shard_bridge", "growth_simulator", "umbrella_update"}
|
||||
"graph_shard_bridge", "growth_simulator", "query_memory",
|
||||
"umbrella_update"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -47,7 +47,19 @@ def main() -> int:
|
||||
p for p in env.get("PATH", "").split(os.pathsep)
|
||||
if "ci-venv" not in p and ".type-venv" not in p and "venv/bin" not in p)
|
||||
result = subprocess.run(
|
||||
cmd + [str(ROOT / "src" / "rune"), "--outputjson"],
|
||||
# §16V AMBIENT-INTERPRETER CLOSURE (T7.3 slice-2 CI repair, 2026-10-03):
|
||||
# venv-signal stripping fixed ACTIVATION drift, but dependency
|
||||
# resolution still followed the AMBIENT host interpreter — runner
|
||||
# (py311, no rich/Pillow) vs desktop (3.14 system site with both)
|
||||
# produced 460 vs 454 on the identical tree, and any import-level
|
||||
# ignore flips to reportUnnecessaryTypeIgnoreComment in the other
|
||||
# environment. Pin --pythonpath to the INVOKING interpreter (the
|
||||
# venv running this script) so dependency resolution follows the
|
||||
# installed lock, not the host: CI's .type-venv has the locked deps;
|
||||
# local preflight must run the ratchet from a venv built from
|
||||
# requirements-ci.txt (same lock) — that is now the contract.
|
||||
cmd + [str(ROOT / "src" / "rune"), "--outputjson",
|
||||
"--pythonpath", sys.executable],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""audit_ledger.py — HMAC-chained tamper-evident promotion ledger.
|
||||
|
||||
Every promotion decision is recorded in a hash-chained ledger.
|
||||
Tampering with any entry breaks the chain.
|
||||
|
||||
Research basis:
|
||||
- PoEM (arXiv:2608.16032): HMAC-chained tamper-evident execution ledger
|
||||
- Autogenesis (arXiv:2604.15034): versioned interfaces, auditable lineage
|
||||
|
||||
Run under ~/.venvs/memory/bin/python3.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
LEDGER_FILE = os.path.expanduser("~/.config/opencode/memory/promotion_ledger.jsonl")
|
||||
SECRET_KEY = os.environ.get("AUDIT_LEDGER_KEY", "rune-drift-integrity-2024")
|
||||
|
||||
|
||||
def _compute_hash(prev_hash: str, entry: dict) -> str:
|
||||
"""Compute HMAC chain hash for a ledger entry."""
|
||||
payload = json.dumps(entry, sort_keys=True).encode()
|
||||
msg = (prev_hash + payload.decode()).encode()
|
||||
return hmac.new(SECRET_KEY.encode(), msg, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def append_entry(entry_type: str, entry_id: int, details: dict) -> dict:
|
||||
"""Append an entry to the promotion ledger.
|
||||
|
||||
Returns the ledger entry with chain hash.
|
||||
"""
|
||||
# Read last hash
|
||||
prev_hash = "genesis"
|
||||
if os.path.exists(LEDGER_FILE):
|
||||
with open(LEDGER_FILE, "r") as f:
|
||||
lines = f.readlines()
|
||||
if lines:
|
||||
try:
|
||||
last = json.loads(lines[-1])
|
||||
prev_hash = last.get("hash", "genesis")
|
||||
except json.JSONDecodeError:
|
||||
prev_hash = "genesis"
|
||||
|
||||
entry = {
|
||||
"type": entry_type,
|
||||
"entry_id": entry_id,
|
||||
"details": details,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"prev_hash": prev_hash,
|
||||
}
|
||||
|
||||
# Compute chain hash
|
||||
entry["hash"] = _compute_hash(prev_hash, entry)
|
||||
|
||||
# Append to ledger
|
||||
os.makedirs(os.path.dirname(LEDGER_FILE), exist_ok=True)
|
||||
with open(LEDGER_FILE, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def verify_chain() -> dict:
|
||||
"""Verify the integrity of the entire ledger chain.
|
||||
|
||||
Returns dict with verification result.
|
||||
"""
|
||||
if not os.path.exists(LEDGER_FILE):
|
||||
return {"valid": True, "entries": 0, "reason": "empty ledger"}
|
||||
|
||||
with open(LEDGER_FILE, "r") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
prev_hash = "genesis"
|
||||
for i, line in enumerate(lines):
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
return {"valid": False, "entries": i, "reason": f"invalid JSON at line {i+1}"}
|
||||
|
||||
# Verify prev_hash chain
|
||||
if entry.get("prev_hash") != prev_hash:
|
||||
return {
|
||||
"valid": False,
|
||||
"entries": i,
|
||||
"reason": f"chain broken at line {i+1}: expected prev_hash={prev_hash}, got {entry.get('prev_hash')}"
|
||||
}
|
||||
|
||||
# Verify HMAC
|
||||
stored_hash = entry.get("hash")
|
||||
computed = _compute_hash(prev_hash, {k: v for k, v in entry.items() if k != "hash"})
|
||||
if stored_hash != computed:
|
||||
return {
|
||||
"valid": False,
|
||||
"entries": i,
|
||||
"reason": f"HMAC mismatch at line {i+1}: stored={stored_hash[:16]}..., computed={computed[:16]}..."
|
||||
}
|
||||
|
||||
prev_hash = stored_hash
|
||||
|
||||
return {"valid": True, "entries": len(lines), "reason": "chain intact"}
|
||||
|
||||
|
||||
def get_entries(limit: int = 50) -> List[dict]:
|
||||
"""Get recent ledger entries."""
|
||||
if not os.path.exists(LEDGER_FILE):
|
||||
return []
|
||||
|
||||
with open(LEDGER_FILE, "r") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
entries = []
|
||||
for line in lines[-limit:]:
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return entries
|
||||
|
||||
|
||||
def log_promotion(entry_id: int, old_status: str, new_status: str, wash_count: int, reason: str) -> dict:
|
||||
"""Log a promotion decision."""
|
||||
return append_entry("promotion", entry_id, {
|
||||
"old_status": old_status,
|
||||
"new_status": new_status,
|
||||
"wash_count": wash_count,
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
|
||||
def log_gate_result(entry_id: int, verdict: str, critics: dict) -> dict:
|
||||
"""Log a pre-commit gate result."""
|
||||
return append_entry("gate", entry_id, {
|
||||
"verdict": verdict,
|
||||
"critics": critics,
|
||||
})
|
||||
|
||||
|
||||
def log_drift_alert(alert_type: str, message: str, severity: str) -> dict:
|
||||
"""Log a drift alert."""
|
||||
return append_entry("alert", 0, {
|
||||
"alert_type": alert_type,
|
||||
"message": message,
|
||||
"severity": severity,
|
||||
})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# CLI
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Promotion audit ledger")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
|
||||
# verify
|
||||
sub.add_parser("verify", help="Verify ledger chain integrity")
|
||||
|
||||
# list [--limit N]
|
||||
p_list = sub.add_parser("list", help="Show recent ledger entries")
|
||||
p_list.add_argument("--limit", type=int, default=20)
|
||||
|
||||
# log-promotion <entry_id> <old> <new> <wash_count> <reason>
|
||||
p_log = sub.add_parser("log-promotion", help="Log a promotion")
|
||||
p_log.add_argument("entry_id", type=int)
|
||||
p_log.add_argument("old_status")
|
||||
p_log.add_argument("new_status")
|
||||
p_log.add_argument("wash_count", type=int)
|
||||
p_log.add_argument("reason")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.cmd:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
if args.cmd == "verify":
|
||||
result = verify_chain()
|
||||
print(f"Chain valid: {result['valid']}")
|
||||
print(f"Entries: {result['entries']}")
|
||||
print(f"Reason: {result['reason']}")
|
||||
|
||||
elif args.cmd == "list":
|
||||
entries = get_entries(args.limit)
|
||||
if entries:
|
||||
print(f"Recent ledger entries ({len(entries)}):")
|
||||
for e in entries:
|
||||
print(f" [{e.get('type')}] entry_id={e.get('entry_id')} @ {e.get('timestamp', '')[:19]}")
|
||||
details = e.get('details', {})
|
||||
if e.get('type') == 'promotion':
|
||||
print(f" {details.get('old_status')} → {details.get('new_status')} (wash={details.get('wash_count')})")
|
||||
elif e.get('type') == 'gate':
|
||||
print(f" verdict={details.get('verdict')}")
|
||||
else:
|
||||
print("No ledger entries.")
|
||||
|
||||
elif args.cmd == "log-promotion":
|
||||
entry = log_promotion(args.entry_id, args.old_status, args.new_status, args.wash_count, args.reason)
|
||||
print(f"Logged: {entry['hash'][:16]}...")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+88
-12
@@ -33,7 +33,7 @@ SCRIPTS_DIR = os.path.expanduser("~/.config/opencode/scripts")
|
||||
SHARED_DIR = os.path.expanduser("~/.config/opencode/shared")
|
||||
CUSTODIAN_STATE_FILE = os.path.expanduser("~/.config/opencode/memory/custodian_state.json")
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "https://gitea.thingscouldgetdicey.online")
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "http://127.0.0.1:3000") # gitea.internal (PC); external = https://gitea.thingscouldgetdicey.online via Pi Caddy
|
||||
GITEA_TOKEN = load_gitea_token()
|
||||
REPO = "conductor/rune"
|
||||
TUI_DIR = "/mnt/workdrive/rune"
|
||||
@@ -54,6 +54,15 @@ UMBRELLA_TAGS = {
|
||||
}
|
||||
|
||||
|
||||
# Issue template directory — canonical copies live beside this script
|
||||
# (mirrored to .gitea/ISSUE_TEMPLATE/ for the web-UI submission path).
|
||||
ISSUE_TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "issue_templates")
|
||||
|
||||
# kind -> Gitea label that MUST be present on the issue
|
||||
KIND_LABEL = {"feature": "feature", "bug": "bug", "research": "research"}
|
||||
VERSION_INTENTS = ("major", "minor", "patch", "none")
|
||||
|
||||
|
||||
class Custodian:
|
||||
"""The Custodian agent for Gitea-based self-improvement."""
|
||||
|
||||
@@ -105,23 +114,90 @@ class Custodian:
|
||||
except Exception as e:
|
||||
return {"error": str(e)[:200]}
|
||||
|
||||
def create_issue(self, title: str, body: str, labels: List[str] = None) -> dict:
|
||||
"""Create a Gitea issue with plain-language description.
|
||||
def _ensure_label(self, name: str, description: str = "", color: str = "#6f42c1") -> bool:
|
||||
"""Make sure a label exists on the repo; return its id or None."""
|
||||
labels = self._gitea_api("labels")
|
||||
if isinstance(labels, list):
|
||||
for l in labels:
|
||||
if l["name"] == name:
|
||||
return l["id"]
|
||||
data = {"name": name, "description": description or name, "color": color}
|
||||
created = self._gitea_api("labels", method="POST", data=data)
|
||||
return created.get("id") if isinstance(created, dict) else None
|
||||
|
||||
Labels should be umbrella names (memory, governance, etc).
|
||||
They get converted to IDs automatically.
|
||||
def _load_issue_template(self, kind: str) -> str:
|
||||
"""Load the canonical issue template for a kind, WITHOUT the web-UI
|
||||
frontmatter (name/about/title/labels — that metadata belongs to the
|
||||
submission form, not the issue body). Empty string if absent."""
|
||||
path = os.path.join(ISSUE_TEMPLATE_DIR, f"{kind}.md")
|
||||
try:
|
||||
with open(path) as f:
|
||||
text = f.read()
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
if text.startswith("---"):
|
||||
end = text.find("\n---", 3)
|
||||
if end != -1:
|
||||
text = text[end + 4:]
|
||||
return text.lstrip("\n")
|
||||
|
||||
def create_issue(
|
||||
self,
|
||||
title: str,
|
||||
body: str,
|
||||
labels: List[str] = None,
|
||||
kind: str = "feature",
|
||||
version_intent: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Create a Gitea issue assembled from the canonical template.
|
||||
|
||||
kind: feature | bug | research — selects the template and forces the
|
||||
matching label (the research label is auto-created if missing).
|
||||
version_intent: major | minor | patch | none — pre-ticks the matching
|
||||
box in the template's Version impact section. The pipeline
|
||||
verifies the real bump at merge and flags mismatches.
|
||||
labels: umbrella names (memory, governance, ...); converted to IDs.
|
||||
|
||||
The caller's body replaces the SUBMITTED_BODY marker inside the
|
||||
template's Plain English section — plain terms first, always.
|
||||
|
||||
ORGANISATION STANDARD (one open note per idea, 2026-09-19): before
|
||||
minting a standalone issue, check whether the work belongs inside an
|
||||
existing parent. Ideas parked under a blocked/future epic are filed
|
||||
as checklist items in the parent's "Absorbed children" section, NOT
|
||||
as standalone issues — the board stays uncluttered and every idea
|
||||
has exactly one home. Mint a standalone issue only when the work is
|
||||
live, dependency-tracked, or about to start. Absorbed ideas are
|
||||
reversible: re-mint standalone when they become live.
|
||||
"""
|
||||
# Convert label names to IDs
|
||||
# 1. assemble body from template (graceful: unformatted if template missing)
|
||||
template = self._load_issue_template(kind)
|
||||
text = body.strip()
|
||||
if template and "<!-- SUBMITTED_BODY -->" in template:
|
||||
text = template.replace("<!-- SUBMITTED_BODY -->", text, 1)
|
||||
if version_intent in VERSION_INTENTS:
|
||||
text = text.replace(f"- [ ] {version_intent} ", f"- [x] {version_intent} ", 1)
|
||||
|
||||
# 2. labels: umbrella names + mandatory kind label
|
||||
labels = list(labels or [])
|
||||
kind_label = KIND_LABEL.get(kind)
|
||||
if kind_label and kind_label not in labels:
|
||||
labels.append(kind_label)
|
||||
|
||||
all_labels = self._gitea_api("labels")
|
||||
label_map = {l["name"]: l["id"] for l in all_labels} if isinstance(all_labels, list) else {}
|
||||
label_ids = []
|
||||
if labels:
|
||||
all_labels = self._gitea_api("labels")
|
||||
if isinstance(all_labels, list):
|
||||
label_map = {l["name"]: l["id"] for l in all_labels}
|
||||
label_ids = [label_map[l] for l in labels if l in label_map]
|
||||
for l in labels:
|
||||
if l in label_map:
|
||||
label_ids.append(label_map[l])
|
||||
else:
|
||||
lid = self._ensure_label(l)
|
||||
if lid:
|
||||
label_ids.append(lid)
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"body": text,
|
||||
"labels": label_ids,
|
||||
}
|
||||
result = self._gitea_api("issues", method="POST", data=data)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: Bug Fix
|
||||
about: Demonstrate currently incorrect behaviour — plain English first, proof required
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
<restate the issue title here in everyday words — this heading IS the
|
||||
plain-English summary; no meta-labels like "plain English" in it>
|
||||
<!-- The heading above carries the summary itself — a scan of issue
|
||||
headings alone should tell the whole story of the board. TOP PRIORITY:
|
||||
if a technical detail below contradicts this section, the issue is
|
||||
buggy — fix the issue. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
What is broken: <what a person observes going wrong>
|
||||
|
||||
What should happen instead: <the correct behaviour>
|
||||
|
||||
How often / when: <always | after X | since change Y>
|
||||
|
||||
## Proof it is broken (required — no proof, no bug)
|
||||
<!-- A bug is DEMONSTRATED currently incorrect behaviour. Nothing else.
|
||||
If this section cannot be filled, file it as a question, not a bug. -->
|
||||
- [ ] Reproduction command or steps (deterministic where possible)
|
||||
- [ ] The exact wrong output observed: <paste/log excerpt>
|
||||
- [ ] Where the evidence lives: <log path, fixture, session id>
|
||||
|
||||
## Steps to reproduce
|
||||
<!-- How to trigger the bug -->
|
||||
|
||||
## What would let us close this
|
||||
- [ ] The reproduction command now shows correct behaviour
|
||||
- [ ] A regression test exists that fails before the fix and passes after
|
||||
- [ ] No other behaviour changed (or changes are listed and justified)
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
<!-- A bug fix is USUALLY a patch. Pick major or minor only with a reason. -->
|
||||
- [ ] patch — same behaviour, done correctly (usual for bugs)
|
||||
- [ ] major — the bug was in a published contract and fixing it breaks consumers (justify)
|
||||
- [ ] minor — fixing revealed a missing capability that had to be added (justify)
|
||||
|
||||
## Technical details
|
||||
<!-- WHAT (the bug) / WHERE (files) / EVIDENCE (how it was confirmed). -->
|
||||
|
||||
**WHAT (bug):**
|
||||
**WHERE:**
|
||||
**EVIDENCE:**
|
||||
|
||||
## Pipeline requirements
|
||||
<!-- Every fix must clear these before the issue closes. -->
|
||||
- [ ] TDD: regression test written first, red → green
|
||||
- [ ] `ruff check` and `ruff format --check` clean
|
||||
- [ ] Spec-compliance review gate: PASS
|
||||
- [ ] Code-quality review gate: Approved
|
||||
- [ ] Full orchestration suite regression green (suite total)
|
||||
- [ ] Canary passes (name)
|
||||
- [ ] Closed with a properly formatted closing comment + commit references
|
||||
|
||||
## Cognitive invariants (memory/cognition issues — both handoffs)
|
||||
<!-- Complete ONLY if this issue touches memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before creating modules)
|
||||
- [ ] **Trigger** — what event activates it
|
||||
- [ ] **Read/write authority** — which canonical state it reads/changes
|
||||
- [ ] **Provenance** — derived state traceable to evidence
|
||||
- [ ] **Verification** — how we know the change helped
|
||||
- [ ] **Failure visibility** — how failure surfaces
|
||||
- [ ] **Reversibility** — bad learned state can be superseded/replayed/rolled back
|
||||
- [ ] **Dormant cost** — what it costs when irrelevant
|
||||
- [ ] **Benchmark + ablation** — what measurable behaviour improves; what happens when disabled
|
||||
- [ ] **No duplicate authority** — why extend vs create
|
||||
|
||||
Hard invariants: derived cognition preserves evidentiary ancestry · epistemic confidence ≠ attentional utility ≠ current applicability · retrieval never increases utility · self-model-induced behaviour is not independent evidence · historical truth survives present-state change · raw evidence is never destroyed by promotion.
|
||||
|
||||
## Related umbrella
|
||||
- [ ] Memory
|
||||
- [ ] Governance
|
||||
- [ ] Identity
|
||||
- [ ] Growth
|
||||
- [ ] Perception
|
||||
- [ ] Infrastructure
|
||||
- [ ] Orchestration
|
||||
|
||||
## Priority (REQUIRED — the priority gate)
|
||||
<!-- Pick exactly one. An issue without a priority is not filed. -->
|
||||
- [ ] **P0 — critical** — breaks the spine / core correctness, or blocks other work
|
||||
- [ ] **P1 — high** — material user value or a real defect (money, correctness bugs, next-stage dependency)
|
||||
- [ ] **P2 — medium** — quality/consolidation improvement (check the fold-into mapping first)
|
||||
- [ ] **P3 — low** — measurement / benchmark / deferred policy
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: New feature or improvement — plain English first, verifiable close conditions, version intent
|
||||
title: "[FEATURE] "
|
||||
labels: feature
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
<restate the issue title here in everyday words — this heading IS the
|
||||
plain-English summary; no meta-labels like "plain English" in it>
|
||||
<!-- The heading above carries the summary itself — a scan of issue
|
||||
headings alone should tell the whole story of the board. TOP PRIORITY:
|
||||
if a technical detail below contradicts this section, the issue is
|
||||
buggy — fix the issue. A non-technical reader must be able to retell this
|
||||
section. 3-6 sentences, no jargon. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
What is this about: <what>
|
||||
|
||||
Why do we need it: <the itch — what happens today that shouldn't, or what becomes possible>
|
||||
|
||||
Why is it still open: <"not started yet" | "part done, X remains" | "deliberately parked as a future idea">
|
||||
|
||||
## Example
|
||||
<!-- A concrete before/after in plain terms: "If X happens, the system will Y instead of Z." -->
|
||||
|
||||
## What would let us close this
|
||||
<!-- Checkable statements, verifiable by looking at something that EXISTS —
|
||||
a file, a log line, a passing test. Not "when appropriate". -->
|
||||
- [ ] <condition 1 — observable>
|
||||
- [ ] <condition 2>
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
<!-- The pipeline derives the real bump from merged conventional commits and
|
||||
compares against this declaration. A mismatch is flagged, not fatal:
|
||||
either the declaration was wrong or the work grew. Both facts matter. -->
|
||||
- [ ] major — breaking: changes a contract, schema, or interface others depend on
|
||||
- [ ] minor — new capability: something becomes possible that wasn't
|
||||
- [ ] patch — repair: same behaviour, done correctly
|
||||
- [ ] none — touches no released artifact (docs, memory, process notes)
|
||||
|
||||
## Technical details
|
||||
<!-- WHAT — one-line objective.
|
||||
WHY — design-spec reference (S#) or reason.
|
||||
FILES — affected components.
|
||||
ACCEPTANCE — frozen-test file + test count + canary name.
|
||||
DEPENDENCIES — parent epic + preceding stage/plan refs. -->
|
||||
|
||||
**WHAT:**
|
||||
**WHY:**
|
||||
**FILES:**
|
||||
**ACCEPTANCE:**
|
||||
**DEPENDENCIES:**
|
||||
|
||||
## Pipeline requirements
|
||||
<!-- Every stage must clear these before the issue closes. -->
|
||||
- [ ] TDD: frozen tests written first, red → green (N passed)
|
||||
- [ ] `ruff check` and `ruff format --check` clean
|
||||
- [ ] Spec-compliance review gate: PASS
|
||||
- [ ] Code-quality review gate: Approved
|
||||
- [ ] Full orchestration suite regression green (suite total)
|
||||
- [ ] Canary passes (name)
|
||||
- [ ] Closed with a properly formatted closing comment + commit/PR references
|
||||
|
||||
## Cognitive invariants (memory/cognition issues — both handoffs)
|
||||
<!-- Complete ONLY if this issue touches memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before creating modules)
|
||||
- [ ] **Trigger** — what event activates it
|
||||
- [ ] **Read/write authority** — which canonical state it reads/changes
|
||||
- [ ] **Provenance** — derived state traceable to evidence
|
||||
- [ ] **Verification** — how we know the change helped
|
||||
- [ ] **Failure visibility** — how failure surfaces
|
||||
- [ ] **Reversibility** — bad learned state can be superseded/replayed/rolled back
|
||||
- [ ] **Dormant cost** — what it costs when irrelevant
|
||||
- [ ] **Benchmark + ablation** — what measurable behaviour improves; what happens when disabled
|
||||
- [ ] **No duplicate authority** — why extend vs create
|
||||
|
||||
Hard invariants: derived cognition preserves evidentiary ancestry · epistemic confidence ≠ attentional utility ≠ current applicability · retrieval never increases utility · self-model-induced behaviour is not independent evidence · historical truth survives present-state change · raw evidence is never destroyed by promotion.
|
||||
|
||||
## Related umbrella
|
||||
- [ ] Memory
|
||||
- [ ] Governance
|
||||
- [ ] Identity
|
||||
- [ ] Growth
|
||||
- [ ] Perception
|
||||
- [ ] Infrastructure
|
||||
- [ ] Life (Nick's)
|
||||
- [ ] Custodian
|
||||
- [ ] Orchestration
|
||||
|
||||
## Priority (REQUIRED — the priority gate)
|
||||
<!-- Pick exactly one. An issue without a priority is not filed. -->
|
||||
- [ ] **P0 — critical** — breaks the spine / core correctness, or blocks other work
|
||||
- [ ] **P1 — high** — material user value or a real defect (money, correctness bugs, next-stage dependency)
|
||||
- [ ] **P2 — medium** — quality/consolidation improvement (check the fold-into mapping first)
|
||||
- [ ] **P3 — low** — measurement / benchmark / deferred policy
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: Research Submission
|
||||
about: Buy down uncertainty before building — question, falsifier, budget, durable findings
|
||||
title: "[RESEARCH] "
|
||||
labels: research
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
<restate the issue title here in everyday words — this heading IS the
|
||||
plain-English summary; no meta-labels like "plain English" in it>
|
||||
<!-- The heading above carries the summary itself — a scan of issue
|
||||
headings alone should tell the whole story of the board. TOP PRIORITY:
|
||||
if a technical detail below contradicts this section, the issue is
|
||||
buggy — fix the issue. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
The question: <what we're trying to learn, one sentence>
|
||||
|
||||
Why we're asking: <what decision this research will inform — a name, an issue
|
||||
number, or "exploratory: none yet">
|
||||
|
||||
What we believe today: <the current assumption, stated so it can be proven wrong>
|
||||
|
||||
## The shape of an answer
|
||||
- [ ] What evidence would settle the question: <observation, benchmark, or spike>
|
||||
- [ ] What evidence would KILL the current assumption (the falsifier): <be specific —
|
||||
this is stated BEFORE starting so the research cannot become a search for confirmation>
|
||||
- [ ] Budget: <time/effort cap — when research must stop and report back>
|
||||
|
||||
## What would let us close this
|
||||
- [ ] Findings written to a durable document: <path, e.g. research/issue-NNN/> —
|
||||
a comment thread is not a durable finding
|
||||
- [ ] The decision the research informs is recorded: made / deferred / changed
|
||||
- [ ] If a follow-up build issue was minted: <#N or "none needed">
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
- [ ] none — research changes no released artifact (usual)
|
||||
- [ ] minor/major — only if a code spike is being merged as part of this (justify)
|
||||
|
||||
## Technical detail
|
||||
<!-- Candidate approaches, prior art in the repo, related issues, constraints,
|
||||
rebuildable-projection or authority requirements if findings touch canonical state. -->
|
||||
|
||||
## Pipeline requirements
|
||||
- [ ] Findings committed under the stated durable path
|
||||
- [ ] Every citation verified against a real source (no fabricated references)
|
||||
- [ ] Closed with a properly formatted closing comment: findings summary + decision
|
||||
|
||||
## Cognitive invariants (memory/cognition research — both handoffs)
|
||||
<!-- Complete ONLY if findings touch memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before proposing modules)
|
||||
- [ ] **Read/write authority** — which canonical state is affected
|
||||
- [ ] **Provenance** — findings traceable to evidence
|
||||
- [ ] **Reversibility** — projections remain rebuildable; no unique truth moves into a derived index
|
||||
- [ ] **No duplicate authority** — why extend vs create
|
||||
|
||||
Hard invariants: derived cognition preserves evidentiary ancestry · retrieval never increases utility · raw evidence is never destroyed by promotion.
|
||||
|
||||
## Related umbrella
|
||||
- [ ] Memory
|
||||
- [ ] Governance
|
||||
- [ ] Identity
|
||||
- [ ] Growth
|
||||
- [ ] Perception
|
||||
- [ ] Infrastructure
|
||||
- [ ] Life (Nick's)
|
||||
- [ ] Custodian
|
||||
- [ ] Orchestration
|
||||
|
||||
## Priority (REQUIRED — the priority gate)
|
||||
<!-- Pick exactly one. An issue without a priority is not filed. -->
|
||||
- [ ] **P0 — critical** — blocks a decision that blocks other work
|
||||
- [ ] **P1 — high** — informs a next-stage dependency
|
||||
- [ ] **P2 — medium** — quality/consolidation understanding
|
||||
- [ ] **P3 — low** — watchlist / deferred / exploratory
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Thin adapter for native (compiled) units — R1 §5 interop contract, Wave 0.
|
||||
|
||||
The contract under proof: compiled units are standalone binaries exposing a
|
||||
CLI boundary — argv in, JSON on stdout, non-zero exit on failure, a mandatory
|
||||
``--version`` flag. This module is the Python side: run a unit, enforce a
|
||||
timeout, capture stderr, and return a TYPED result. It never raises for unit
|
||||
failures (those are data in the result); it raises only for adapter misuse
|
||||
(missing binary, bad timeout).
|
||||
|
||||
No IPC daemon, no PyO3 — the file-based store stays the shared truth. If
|
||||
research later surfaces a materially better pattern it competes through the
|
||||
research pipeline; the CLI boundary is the default, not a cage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
PROOF_UNIT = REPO_ROOT / "interop" / "rebase-proof" / "target" / "release" / "rebase-proof"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NativeResult:
|
||||
"""Typed result of one native-unit invocation.
|
||||
|
||||
ok — the invocation succeeded (exit 0 AND not timed out); when
|
||||
the unit emitted JSON with an ``ok`` field, that field must
|
||||
not be False (plain-text output like ``--version`` has none)
|
||||
exit_code — raw process exit code
|
||||
data — parsed JSON object from stdout, or None if unparseable
|
||||
stderr — captured stderr text (may be non-empty even on success)
|
||||
duration_ms — wall-clock cost of the invocation
|
||||
timed_out — True when the unit exceeded the timeout and was killed
|
||||
events — in streaming mode: every parsed JSON object line, in
|
||||
arrival order (empty for non-streaming :func:`run_native`)
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
exit_code: int
|
||||
data: dict | None
|
||||
stderr: str
|
||||
duration_ms: int
|
||||
timed_out: bool = False
|
||||
raw_stdout: str = field(default="", repr=False)
|
||||
events: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def run_native(binary: Path, args: list[str], timeout_s: float = 10.0) -> NativeResult:
|
||||
"""Invoke a native unit and return a typed result.
|
||||
|
||||
Raises FileNotFoundError only when *binary* itself is absent (adapter
|
||||
misuse) — every unit-level failure is reported via the result, never an
|
||||
exception, so callers can branch on ``result.ok`` without try/except.
|
||||
"""
|
||||
if not binary.is_file():
|
||||
raise FileNotFoundError(f"native unit not built: {binary}")
|
||||
|
||||
started = time.monotonic()
|
||||
timed_out = False
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[str(binary), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s,
|
||||
check=False, # exit codes are data, never exceptions
|
||||
)
|
||||
exit_code, stdout, stderr = proc.returncode, proc.stdout, proc.stderr
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
timed_out = True
|
||||
exit_code = -1
|
||||
stdout = exc.stdout or "" if isinstance(exc.stdout, str) else (exc.stdout or b"").decode(errors="replace")
|
||||
stderr = exc.stderr or "" if isinstance(exc.stderr, str) else (exc.stderr or b"").decode(errors="replace")
|
||||
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
|
||||
data: dict | None = None
|
||||
stripped = stdout.strip()
|
||||
if stripped:
|
||||
try:
|
||||
parsed = json.loads(stripped.splitlines()[-1])
|
||||
if isinstance(parsed, dict):
|
||||
data = parsed
|
||||
except json.JSONDecodeError:
|
||||
data = None
|
||||
|
||||
ok = (not timed_out) and exit_code == 0 and (data is None or data.get("ok") is not False)
|
||||
return NativeResult(
|
||||
ok=ok,
|
||||
exit_code=exit_code,
|
||||
data=data,
|
||||
stderr=stderr,
|
||||
duration_ms=duration_ms,
|
||||
timed_out=timed_out,
|
||||
raw_stdout=stdout,
|
||||
)
|
||||
|
||||
|
||||
def run_proof(args: list[str], timeout_s: float = 10.0) -> NativeResult:
|
||||
"""Run the Wave 0 proof unit with the standard contract defaults."""
|
||||
return run_native(PROOF_UNIT, args, timeout_s=timeout_s)
|
||||
|
||||
|
||||
def stream_native(
|
||||
binary: Path,
|
||||
args: list[str],
|
||||
timeout_s: float = 10.0,
|
||||
on_event=None,
|
||||
):
|
||||
"""Run a native unit in JSONL streaming mode, yielding events as they arrive.
|
||||
|
||||
The JSONL streaming contract (rubric §W7): one JSON object per stdout
|
||||
line — ``{"ok": true, "event": ...}`` progress objects along the way, and
|
||||
a terminal envelope as the FINAL line. This generator yields each parsed
|
||||
event dict the moment it arrives (so a long op surfaces partial progress
|
||||
instead of blocking until exit), then yields exactly one terminal
|
||||
:class:`NativeResult`. *on_event*, when given, is called with each parsed
|
||||
event as it arrives (the TUI wraps this in ``call_from_thread``).
|
||||
|
||||
Stream-mode ok-semantics are stricter than :func:`run_native`: ``ok``
|
||||
requires exit 0, no timeout, AND a valid terminal envelope (the final
|
||||
stdout line parsed as a JSON object whose ``ok`` is not False). Malformed
|
||||
interior lines are skipped and counted in ``malformed_lines``; malformed
|
||||
FINAL line means no valid terminal envelope, so ``ok`` is False — partial
|
||||
progress stays available in ``events``. That is the point of streaming.
|
||||
|
||||
Same failure discipline as :func:`run_native`: raises FileNotFoundError
|
||||
when *binary* is absent; every unit-level failure (bad envelope, timeout,
|
||||
non-zero exit) is data on the terminal result, never an exception.
|
||||
"""
|
||||
if on_event is not None and not callable(on_event):
|
||||
raise TypeError("on_event must be callable")
|
||||
if not binary.is_file():
|
||||
raise FileNotFoundError(f"native unit not built: {binary}")
|
||||
|
||||
started = time.monotonic()
|
||||
proc = subprocess.Popen(
|
||||
[str(binary), *args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# stderr must drain concurrently — reading it after the stdout loop
|
||||
# deadlocks when a unit floods stderr past the pipe buffer mid-stream.
|
||||
stderr_parts: list[str] = []
|
||||
stderr_drain = threading.Thread(
|
||||
target=lambda: stderr_parts.append(proc.stderr.read()), daemon=True
|
||||
)
|
||||
stderr_drain.start()
|
||||
|
||||
killed = threading.Event()
|
||||
# The watchdog's kill and the flag are one step so an adapter-side
|
||||
# timeout is never mistaken for a unit dying by its own signal.
|
||||
watchdog = threading.Timer(timeout_s, lambda: (killed.set(), proc.kill()))
|
||||
watchdog.start()
|
||||
|
||||
events: list[dict] = []
|
||||
malformed_lines = 0
|
||||
# True only while the most recent non-empty stdout line parsed as a JSON
|
||||
# object — a malformed final line invalidates the terminal envelope.
|
||||
final_line_is_json = False
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
malformed_lines += 1
|
||||
final_line_is_json = False
|
||||
continue
|
||||
if not isinstance(parsed, dict):
|
||||
malformed_lines += 1
|
||||
final_line_is_json = False
|
||||
continue
|
||||
events.append(parsed)
|
||||
final_line_is_json = True
|
||||
if on_event is not None:
|
||||
on_event(parsed)
|
||||
yield parsed
|
||||
proc.wait()
|
||||
finally:
|
||||
watchdog.cancel()
|
||||
stderr_drain.join(timeout=5.0)
|
||||
|
||||
timed_out = killed.is_set()
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
exit_code = proc.returncode if proc.returncode is not None else -1
|
||||
if timed_out:
|
||||
exit_code = -1 # adapter-side kill, never a unit's own exit
|
||||
|
||||
terminal = events[-1] if (events and final_line_is_json) else None
|
||||
ok = (
|
||||
not timed_out
|
||||
and exit_code == 0
|
||||
and terminal is not None
|
||||
and terminal.get("ok") is not False
|
||||
)
|
||||
yield NativeResult(
|
||||
ok=ok,
|
||||
exit_code=exit_code,
|
||||
data=terminal,
|
||||
stderr=stderr_parts[0] if stderr_parts else "",
|
||||
duration_ms=duration_ms,
|
||||
timed_out=timed_out,
|
||||
events=events,
|
||||
)
|
||||
|
||||
+60
-134
@@ -1,17 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""query_memory.py — Query memory system for adaptive retrieval.
|
||||
|
||||
The system remembers what kinds of queries it gets, what retrieval
|
||||
strategy worked, and adapts its gate logic accordingly.
|
||||
AUTHORITY-SWITCHED (Phase G strangler slice 1, doctrine step 8): the
|
||||
classification core now lives in Rust (`src/rust/query_memory/src/lib.rs`,
|
||||
cdylib `libquery_memory.so`). This module is a thin ctypes-backed shim over
|
||||
`qm_loader.py` serving the FULL historical Python class API — the deployed
|
||||
in-process consumers (`memory_store.py`, `growth_simulator.py`) call
|
||||
`QueryMemory()` exactly as before and get the Rust core.
|
||||
|
||||
This prevents dead weight — memories get tested based on usage
|
||||
patterns, not just random exploration.
|
||||
Parity evidence: FFI-gap parity EXACT-EQUAL on `get_topic_stats` /
|
||||
`get_dead_weight_topics`; verification wall green (FFI smoke 41/41, parity
|
||||
harness ALL GREEN, Python canary 12/12, Rust canary 27/27); shadow comparison
|
||||
46/46 exact-equal vs the live production store.
|
||||
|
||||
Research basis:
|
||||
- IGMiRAG (arXiv:2602.07525): Intuition-guided retrieval
|
||||
- Task-Adaptive Retrieval (arXiv:2604.07863): Learning from interaction history
|
||||
- MemCon (arXiv:2607.13591): Learned policy for consolidation/forgetting
|
||||
- Agent-Native Memory (arXiv:2606.24775): Workload-adaptive routing
|
||||
The Python implementation this replaces is preserved in git history on branch
|
||||
`phase-g-strangler-retrieval-index` (pre-shim commit); per deletion discipline
|
||||
it is not kept as a silent fallback — a missing cdylib fails loudly.
|
||||
|
||||
Store resolution is unchanged: `$HOME/.config/opencode/memory/query_memory`
|
||||
(the Rust core resolves `$HOME` natively; history.jsonl / topic_stats.json
|
||||
formats are byte-parity-proven).
|
||||
|
||||
Run under ~/.venvs/memory/bin/python3.
|
||||
"""
|
||||
@@ -19,15 +27,14 @@ Run under ~/.venvs/memory/bin/python3.
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
QUERY_MEMORY_DIR = os.path.expanduser("~/.config/opencode/memory/query_memory")
|
||||
QUERY_HISTORY_FILE = os.path.join(QUERY_MEMORY_DIR, "history.jsonl")
|
||||
TOPIC_STATS_FILE = os.path.join(QUERY_MEMORY_DIR, "topic_stats.json")
|
||||
|
||||
# Topic keywords for classification
|
||||
# Topic keywords (classification table — tie-break law: insertion order is
|
||||
# part of the contract; the Rust core carries the identical table).
|
||||
TOPIC_KEYWORDS = {
|
||||
"guitar": ["guitar", "music", "teaching", "course", "lesson", "chord", "scale"],
|
||||
"coding": ["code", "python", "script", "function", "debug", "build", "deploy"],
|
||||
@@ -41,17 +48,47 @@ TOPIC_KEYWORDS = {
|
||||
"creative": ["write", "design", "create", "build", "make", "art", "music"],
|
||||
}
|
||||
|
||||
# Where the Rust core (qm_loader.py + target/release/libquery_memory.so) lives.
|
||||
_QM_CORE_DIR = os.environ.get(
|
||||
"QM_CORE_DIR", "/mnt/workdrive/rune/src/rust/query_memory"
|
||||
)
|
||||
|
||||
class QueryMemory:
|
||||
"""Query memory system for adaptive retrieval."""
|
||||
if _QM_CORE_DIR not in sys.path:
|
||||
sys.path.insert(0, _QM_CORE_DIR)
|
||||
|
||||
try:
|
||||
from qm_loader import QueryMemory as _RustQueryMemory
|
||||
except ImportError as _e: # pragma: no cover - deployment integrity failure
|
||||
raise ImportError(
|
||||
f"query_memory shim: cannot load the Rust core from {_QM_CORE_DIR!r} "
|
||||
f"({_e}). The cdylib must be built: `cargo build --release` in "
|
||||
f"{_QM_CORE_DIR}. No Python fallback exists by design."
|
||||
) from _e
|
||||
|
||||
|
||||
class QueryMemory(_RustQueryMemory):
|
||||
"""Query memory system for adaptive retrieval (Rust core, Python API).
|
||||
|
||||
API (identical to the historical Python implementation):
|
||||
* classify_query(query) -> str
|
||||
* adaptive_exploration_rate(query) -> float
|
||||
* record_query(query, strategy, success) -> None
|
||||
* get_topic_stats() -> dict (topic -> stats, insertion order)
|
||||
* get_dead_weight_topics() -> list[str]
|
||||
* get_health_report() -> dict
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Match the historical constructor: ensure the store dir exists,
|
||||
# resolve the store from $HOME (qm_new's native behaviour).
|
||||
os.makedirs(QUERY_MEMORY_DIR, exist_ok=True)
|
||||
self.history: List[dict] = self._load_history()
|
||||
self.topic_stats: Dict[str, dict] = self._load_topic_stats()
|
||||
super().__init__()
|
||||
# Historical in-memory attribute: query history loaded at construction,
|
||||
# appended by record_query. (canary check 2 reads len(qm.history).)
|
||||
self.history = self._load_history()
|
||||
|
||||
def _load_history(self) -> List[dict]:
|
||||
"""Load query history from disk."""
|
||||
def _load_history(self):
|
||||
"""Load query history from disk (historical shape: list of dicts)."""
|
||||
if not os.path.exists(QUERY_HISTORY_FILE):
|
||||
return []
|
||||
entries = []
|
||||
@@ -63,127 +100,16 @@ class QueryMemory:
|
||||
continue
|
||||
return entries
|
||||
|
||||
def _save_history(self, entry: dict):
|
||||
"""Append a query entry to history."""
|
||||
with open(QUERY_HISTORY_FILE, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
def _load_topic_stats(self) -> Dict[str, dict]:
|
||||
"""Load topic statistics from disk."""
|
||||
if os.path.exists(TOPIC_STATS_FILE):
|
||||
try:
|
||||
with open(TOPIC_STATS_FILE) as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def _save_topic_stats(self):
|
||||
"""Save topic statistics to disk."""
|
||||
with open(TOPIC_STATS_FILE, "w") as f:
|
||||
json.dump(self.topic_stats, f, indent=2)
|
||||
|
||||
def classify_query(self, query: str) -> str:
|
||||
"""Classify a query into a topic.
|
||||
|
||||
Returns the topic with the most keyword matches.
|
||||
Falls back to "general" if no strong match.
|
||||
"""
|
||||
query_lower = query.lower()
|
||||
scores = {}
|
||||
for topic, keywords in TOPIC_KEYWORDS.items():
|
||||
score = sum(1 for kw in keywords if kw in query_lower)
|
||||
if score > 0:
|
||||
scores[topic] = score
|
||||
|
||||
if not scores:
|
||||
return "general"
|
||||
|
||||
return max(scores, key=scores.get)
|
||||
|
||||
def record_query(self, query: str, strategy: str, success: bool):
|
||||
"""Record a query and its outcome.
|
||||
|
||||
Args:
|
||||
query: The query text
|
||||
strategy: "consolidated" or "full_retrieval"
|
||||
success: Whether the retrieval produced useful results
|
||||
"""
|
||||
topic = self.classify_query(query)
|
||||
entry = {
|
||||
"""Record through the Rust core, then mirror the historical in-memory append."""
|
||||
super().record_query(query, strategy, success)
|
||||
self.history.append({
|
||||
"query": query[:100],
|
||||
"topic": topic,
|
||||
"topic": self.classify_query(query),
|
||||
"strategy": strategy,
|
||||
"success": success,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
self.history.append(entry)
|
||||
self._save_history(entry)
|
||||
|
||||
# Update topic stats
|
||||
if topic not in self.topic_stats:
|
||||
self.topic_stats[topic] = {
|
||||
"total_queries": 0,
|
||||
"successful_queries": 0,
|
||||
"consolidated_hits": 0,
|
||||
"full_retrieval_hits": 0,
|
||||
"exploration_rate": 0.30, # default
|
||||
}
|
||||
|
||||
stats = self.topic_stats[topic]
|
||||
stats["total_queries"] += 1
|
||||
if success:
|
||||
stats["successful_queries"] += 1
|
||||
if strategy == "consolidated":
|
||||
stats["consolidated_hits"] += 1
|
||||
else:
|
||||
stats["full_retrieval_hits"] += 1
|
||||
|
||||
# Adaptive exploration rate: decrease for well-tested topics
|
||||
if stats["total_queries"] >= 10:
|
||||
success_rate = stats["successful_queries"] / stats["total_queries"]
|
||||
if success_rate > 0.8:
|
||||
stats["exploration_rate"] = max(0.10, stats["exploration_rate"] - 0.05)
|
||||
elif success_rate < 0.5:
|
||||
stats["exploration_rate"] = min(0.50, stats["exploration_rate"] + 0.05)
|
||||
|
||||
self._save_topic_stats()
|
||||
|
||||
def get_topic_stats(self) -> Dict[str, dict]:
|
||||
"""Get topic statistics."""
|
||||
return self.topic_stats
|
||||
|
||||
def adaptive_exploration_rate(self, query: str) -> float:
|
||||
"""Get the exploration rate for a query based on its topic.
|
||||
|
||||
Well-tested topics have lower exploration rates.
|
||||
Untested topics have higher exploration rates.
|
||||
"""
|
||||
topic = self.classify_query(query)
|
||||
if topic in self.topic_stats:
|
||||
return self.topic_stats[topic].get("exploration_rate", 0.30)
|
||||
return 0.50 # untested topics get high exploration
|
||||
|
||||
def get_dead_weight_topics(self) -> List[str]:
|
||||
"""Get topics with low proven uses (dead weight)."""
|
||||
dead = []
|
||||
for topic, stats in self.topic_stats.items():
|
||||
if stats["total_queries"] < 5:
|
||||
dead.append(topic)
|
||||
return dead
|
||||
|
||||
def get_health_report(self) -> dict:
|
||||
"""Get a health report of the query memory system."""
|
||||
total_queries = len(self.history)
|
||||
topics = len(self.topic_stats)
|
||||
dead_topics = len(self.get_dead_weight_topics())
|
||||
|
||||
return {
|
||||
"total_queries": total_queries,
|
||||
"topics_tracked": topics,
|
||||
"dead_weight_topics": dead_topics,
|
||||
"topic_stats": self.topic_stats,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module rune-entry
|
||||
|
||||
go 1.27.1
|
||||
@@ -0,0 +1,167 @@
|
||||
// Command rune is the Go first-call entry for the Rune runtime (Phase H1).
|
||||
//
|
||||
// Directive (USER-LOCKED 2026-10-04): Go owns the front end — runtime entry
|
||||
// points, the first-call surface, user-facing calls. This binary is the
|
||||
// transitional first-call shim: it replicates the EXACT contract of the
|
||||
// deployed bash launcher (~/.local/bin/rune) — same cwd, same env, same
|
||||
// handoff — and exec-replaces itself with the Python runtime so exit codes
|
||||
// and signals pass through 1:1. The Python core shrinks behind this surface
|
||||
// as the back-end ports land.
|
||||
//
|
||||
// Contract (H1 canary, observed from the deployed launcher — not invented):
|
||||
// cwd: /mnt/workdrive/rune-code/current/src (the deployed revision)
|
||||
// env: RUNE_HOME=/mnt/workdrive/rune
|
||||
// RUNE_MEMORY_DIR=/home/nick/.config/opencode/memory
|
||||
// MIMO_PROBE_ENABLED=1
|
||||
// handoff: ~/.venvs/memory/bin/python3 -m rune [args...]
|
||||
//
|
||||
// Flags consumed by the Go layer (everything else passes through):
|
||||
// --version print "rune <version>" (deployed pyproject), exit 0
|
||||
// --python-only print the resolved handoff command, exit 0
|
||||
// --help usage, exit 0
|
||||
// unknown flag usage on stderr, exit 2
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const usage = `rune — Go first-call entry (Phase H1 transitional shim)
|
||||
|
||||
usage:
|
||||
rune [args...] hand off to the Python runtime (deployed revision)
|
||||
rune serve H2 front door: /v1 surface proxying the Python core
|
||||
rune --version print the runtime version
|
||||
rune --python-only print the resolved Python handoff command and exit
|
||||
rune --help this text
|
||||
|
||||
Environment contract (same as the deployed bash launcher):
|
||||
cwd /mnt/workdrive/rune-code/current/src
|
||||
RUNE_HOME data home (stable across code deploys)
|
||||
RUNE_MEMORY_DIR memory dir
|
||||
MIMO_PROBE_ENABLED=1
|
||||
Explicitly-set RUNE_HOME / RUNE_MEMORY_DIR in the ambient environment win.
|
||||
`
|
||||
|
||||
const (
|
||||
deployRoot = "/mnt/workdrive/rune-code"
|
||||
dataHome = "/mnt/workdrive/rune"
|
||||
defaultMemDir = "/home/nick/.config/opencode/memory"
|
||||
)
|
||||
|
||||
// deployedSrc resolves the runtime source dir: the deployed current
|
||||
// revision when it exists (the launcher's authority), else the binary's own
|
||||
// repo checkout (development runs), else the desktop default.
|
||||
func deployedSrc() string {
|
||||
current := filepath.Join(deployRoot, "current", "src")
|
||||
if st, err := os.Stat(filepath.Join(current, "rune", "__main__.py")); err == nil && !st.IsDir() {
|
||||
return current
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
for i := 0; i < 8; i++ {
|
||||
cand := filepath.Join(dir, "src")
|
||||
if st, err := os.Stat(filepath.Join(cand, "rune", "__main__.py")); err == nil && !st.IsDir() {
|
||||
return cand
|
||||
}
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
}
|
||||
return filepath.Join(dataHome, "src")
|
||||
}
|
||||
|
||||
func version(srcDir string) string {
|
||||
data, err := os.ReadFile(filepath.Join(filepath.Dir(srcDir), "pyproject.toml"))
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
const prefix = `version = "`
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if rest, ok := strings.CutPrefix(line, prefix); ok {
|
||||
if end := strings.IndexByte(rest, '"'); end > 0 {
|
||||
return rest[:end]
|
||||
}
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func pythonBin() string {
|
||||
cand := filepath.Join(os.Getenv("HOME"), ".venvs", "memory", "bin", "python3")
|
||||
if _, err := os.Stat(cand); err == nil {
|
||||
return cand
|
||||
}
|
||||
return "python3"
|
||||
}
|
||||
|
||||
func upsertEnv(env []string, key, val string) []string {
|
||||
prefix := key + "="
|
||||
for i, e := range env {
|
||||
if strings.HasPrefix(e, prefix) {
|
||||
env[i] = prefix + val
|
||||
return env
|
||||
}
|
||||
}
|
||||
return append(env, prefix+val)
|
||||
}
|
||||
|
||||
func launchEnv(base []string) []string {
|
||||
env := base
|
||||
if os.Getenv("RUNE_HOME") == "" {
|
||||
env = upsertEnv(env, "RUNE_HOME", dataHome)
|
||||
}
|
||||
if os.Getenv("RUNE_MEMORY_DIR") == "" {
|
||||
env = upsertEnv(env, "RUNE_MEMORY_DIR", defaultMemDir)
|
||||
}
|
||||
env = upsertEnv(env, "MIMO_PROBE_ENABLED", "1")
|
||||
return env
|
||||
}
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
// H2: the front-door subcommand owns the /v1 surface (no Python handoff).
|
||||
if len(args) > 0 && args[0] == "serve" {
|
||||
os.Exit(runServe(args[1:]))
|
||||
}
|
||||
srcDir := deployedSrc()
|
||||
for _, a := range args {
|
||||
switch a {
|
||||
case "--help", "-h":
|
||||
fmt.Print(usage)
|
||||
return
|
||||
case "--version":
|
||||
fmt.Printf("rune %s\n", version(srcDir))
|
||||
return
|
||||
case "--python-only":
|
||||
fmt.Printf("(cd %s && %s -m rune %s)\n", srcDir, pythonBin(), strings.Join(args[1:], " "))
|
||||
return
|
||||
default:
|
||||
if strings.HasPrefix(a, "-") {
|
||||
fmt.Fprintf(os.Stderr, "rune: unknown flag %q\n\n%s", a, usage)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
argv := append([]string{pythonBin(), "-m", "rune"}, args...)
|
||||
bin, err := exec.LookPath(argv[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rune: cannot find python runtime %q: %v\n", argv[0], err)
|
||||
os.Exit(127)
|
||||
}
|
||||
env := launchEnv(os.Environ())
|
||||
// chdir before exec: the launcher's cwd contract (the deployed src dir
|
||||
// makes the rune package importable for `-m rune`).
|
||||
if err := syscall.Chdir(srcDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rune: cannot enter %s: %v\n", srcDir, err)
|
||||
os.Exit(126)
|
||||
}
|
||||
if err := syscall.Exec(bin, argv, env); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rune: handoff failed: %v\n", err)
|
||||
os.Exit(126)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVersionReadsPyprojectBesideSrc(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "src", "rune"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "pyproject.toml"),
|
||||
[]byte("[project]\nversion = \"0.99.1\"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := version(filepath.Join(root, "src")); got != "0.99.1" {
|
||||
t.Fatalf("want 0.99.1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionUnknownWhenMissing(t *testing.T) {
|
||||
if got := version("/nonexistent-src"); got != "unknown" {
|
||||
t.Fatalf("missing root -> unknown, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertEnv(t *testing.T) {
|
||||
env := []string{"A=1", "RUNE_HOME=/old"}
|
||||
got := upsertEnv(env, "RUNE_HOME", "/new")
|
||||
if got[1] != "RUNE_HOME=/new" {
|
||||
t.Fatalf("existing key replaced, got %v", got)
|
||||
}
|
||||
got = upsertEnv([]string{"A=1"}, "B", "2")
|
||||
if got[len(got)-1] != "B=2" || len(got) != 2 {
|
||||
t.Fatalf("new key appended, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchEnvContract(t *testing.T) {
|
||||
t.Setenv("RUNE_HOME", "")
|
||||
t.Setenv("RUNE_MEMORY_DIR", "")
|
||||
env := launchEnv(nil)
|
||||
joined := strings.Join(env, "\n")
|
||||
for _, want := range []string{
|
||||
"RUNE_HOME=/mnt/workdrive/rune",
|
||||
"RUNE_MEMORY_DIR=/home/nick/.config/opencode/memory",
|
||||
"MIMO_PROBE_ENABLED=1",
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("launch env missing %q\ngot:\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchEnvHonorsExplicitOverrides(t *testing.T) {
|
||||
t.Setenv("RUNE_HOME", "/tmp/custom-home")
|
||||
env := launchEnv([]string{"RUNE_HOME=/tmp/custom-home"})
|
||||
joined := strings.Join(env, "\n")
|
||||
if !strings.Contains(joined, "RUNE_HOME=/tmp/custom-home") {
|
||||
t.Fatalf("explicit RUNE_HOME must win, got:\n%s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "RUNE_HOME=/mnt/workdrive/rune") {
|
||||
t.Fatalf("default must NOT override explicit env, got:\n%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPythonBinFallsBack(t *testing.T) {
|
||||
t.Setenv("HOME", "/nonexistent-home")
|
||||
if got := pythonBin(); got != "python3" {
|
||||
t.Fatalf("missing venv -> python3 on PATH, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployedSrcPrefersLiveCurrent(t *testing.T) {
|
||||
// The deployed current revision exists on this host (the launcher
|
||||
// contract) — the shim must resolve to it, not to a dev checkout.
|
||||
got := deployedSrc()
|
||||
if !strings.HasPrefix(got, "/mnt/workdrive/rune-code/current") &&
|
||||
!strings.Contains(got, "/src") {
|
||||
t.Fatalf("deployed current must win, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── H2 front door (serve.go) ────────────────────────────────────────────────
|
||||
|
||||
func TestServeHelpExitsZero(t *testing.T) {
|
||||
if rc := runServe([]string{"--help"}); rc != 0 {
|
||||
t.Fatalf("serve --help must exit 0, got %d", rc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeUnknownFlagExitsTwo(t *testing.T) {
|
||||
if rc := runServe([]string{"--bogus"}); rc != 2 {
|
||||
t.Fatalf("unknown serve flag must exit 2, got %d", rc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrontDoorNotFoundEnvelope(t *testing.T) {
|
||||
f := &frontDoor{core: "http://127.0.0.1:1"}
|
||||
req := httptest.NewRequest("GET", "/v1/nope", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
f.mux().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown route must 404, got %d", rec.Code)
|
||||
}
|
||||
var body map[string]map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("404 body must be the error envelope: %v", err)
|
||||
}
|
||||
if body["error"]["code"] != "not_found" {
|
||||
t.Fatalf("404 code must be not_found, got %q", body["error"]["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrontDoorCoreDownEnvelope(t *testing.T) {
|
||||
// Nothing listening on port 1 — the proxy must answer 502
|
||||
// core_unavailable, not hang and not 500.
|
||||
f := &frontDoor{core: "http://127.0.0.1:1",
|
||||
client: &http.Client{Timeout: 2 * time.Second}}
|
||||
req := httptest.NewRequest("POST", "/v1/chat/completions",
|
||||
strings.NewReader(`{"messages":[{"role":"user","content":"x"}]}`))
|
||||
rec := httptest.NewRecorder()
|
||||
f.mux().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("core down must 502, got %d", rec.Code)
|
||||
}
|
||||
var body map[string]map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("502 body must be the error envelope: %v", err)
|
||||
}
|
||||
if body["error"]["code"] != "core_unavailable" {
|
||||
t.Fatalf("502 code must be core_unavailable, got %q", body["error"]["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrontDoorProxiesByteForByte(t *testing.T) {
|
||||
// A canned upstream: the front door must pass status + body through
|
||||
// verbatim (the H2 parity law in its strongest form).
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"object":"list","data":[]}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
f := &frontDoor{core: upstream.URL, client: upstream.Client()}
|
||||
req := httptest.NewRequest("GET", "/v1/models", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
f.mux().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("proxy must pass status through, got %d", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != `{"object":"list","data":[]}` {
|
||||
t.Fatalf("proxy must pass the body byte-for-byte, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// serve.go — H2 front door (T4.3, contract BUILDER-2026-10-04T08:32:43Z-cli).
|
||||
//
|
||||
// Go owns the front end (USER-LOCKED directive): this subcommand serves the
|
||||
// OpenAI-compatible /v1 surface on 127.0.0.1 and proxies to the Python core
|
||||
// sidecar (core_server.py) during the transition. Parity law: response
|
||||
// bodies pass through byte-for-byte (the canary compares against the Python
|
||||
// server modulo volatile id/created), and locally-generated errors use the
|
||||
// same envelope the core emits: {"error": {"code", "message"}}.
|
||||
//
|
||||
// Stdlib only (net/http, io) — the plan's "stdlib net/http + NDJSON" line.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const serveUsage = `rune serve — H2 front door (OpenAI-compatible /v1 surface)
|
||||
|
||||
usage:
|
||||
rune serve [--port N] [--core URL]
|
||||
|
||||
flags:
|
||||
--port N listen port on 127.0.0.1 (default 8377)
|
||||
--core URL Python core sidecar base URL (default http://127.0.0.1:8376)
|
||||
|
||||
Endpoints (identical shapes to the core — the parity battery enforces it):
|
||||
GET /v1/models
|
||||
GET /v1/status
|
||||
POST /v1/chat/completions stream=true → NDJSON chunk lines
|
||||
`
|
||||
|
||||
type frontDoor struct {
|
||||
core string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// errorEnvelope mirrors core_server._envelope_error exactly.
|
||||
func errorEnvelope(w http.ResponseWriter, status int, code, message string) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"error": map[string]string{"code": code, "message": message},
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// proxy forwards one request to the core and streams the response back
|
||||
// verbatim — byte-parity with the Python server is the H2 contract, and a
|
||||
// pure pass-through is the strongest form of it.
|
||||
func (f *frontDoor) proxy(w http.ResponseWriter, r *http.Request) {
|
||||
var body io.Reader
|
||||
if r.Body != nil {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
errorEnvelope(w, http.StatusBadRequest, "bad_request", "unreadable body")
|
||||
return
|
||||
}
|
||||
body = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequest(r.Method, f.core+r.URL.Path, body)
|
||||
if err != nil {
|
||||
errorEnvelope(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", r.Header.Get("Content-Type"))
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
errorEnvelope(w, http.StatusBadGateway, "core_unavailable",
|
||||
"core sidecar unreachable: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for k, vs := range resp.Header {
|
||||
if strings.EqualFold(k, "Content-Type") {
|
||||
w.Header().Set(k, vs[0])
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
// NDJSON streaming: flush per write so chunk lines reach the caller as
|
||||
// the core emits them (the framing is the contract).
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := w.Write(buf[:n]); werr != nil {
|
||||
return
|
||||
}
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *frontDoor) mux() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
handle := func(path string) {
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != path {
|
||||
errorEnvelope(w, http.StatusNotFound, "not_found",
|
||||
"no route "+r.URL.Path)
|
||||
return
|
||||
}
|
||||
f.proxy(w, r)
|
||||
})
|
||||
}
|
||||
handle("/v1/models")
|
||||
handle("/v1/status")
|
||||
handle("/v1/chat/completions")
|
||||
// H4 slice 1: the TUI endpoints (same pass-through law).
|
||||
handle("/v1/conversation")
|
||||
handle("/v1/approvals/resolve")
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
errorEnvelope(w, http.StatusNotFound, "not_found", "no route "+r.URL.Path)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// runServe implements `rune serve`. Returns the process exit code.
|
||||
func runServe(args []string) int {
|
||||
port := 8377
|
||||
core := "http://127.0.0.1:8376"
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--help", "-h":
|
||||
fmt.Print(serveUsage)
|
||||
return 0
|
||||
case "--port":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(os.Stderr, "rune serve: --port needs a value")
|
||||
return 2
|
||||
}
|
||||
i++
|
||||
if _, err := fmt.Sscanf(args[i], "%d", &port); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rune serve: bad --port %q\n", args[i])
|
||||
return 2
|
||||
}
|
||||
case "--core":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(os.Stderr, "rune serve: --core needs a value")
|
||||
return 2
|
||||
}
|
||||
i++
|
||||
core = strings.TrimSuffix(args[i], "/")
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "rune serve: unknown flag %q\n\n%s", args[i], serveUsage)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
f := &frontDoor{
|
||||
core: core,
|
||||
client: &http.Client{Timeout: 130 * time.Second},
|
||||
}
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
fmt.Printf("rune front door on %s (core %s)\n", addr, core)
|
||||
if err := http.ListenAndServe(addr, f.mux()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rune serve: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -6,7 +6,7 @@ This is the main entry point for the Rune program.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.86.0"
|
||||
__version__ = "0.90.0"
|
||||
|
||||
# One build identity (handoff 2026-09-10 §4): the semantic version above is
|
||||
# the repo-level truth (auto-bumped at merge); the DEPLOYED revision (exact
|
||||
|
||||
+29
-33
@@ -15,6 +15,17 @@ Usage:
|
||||
behavior.py reinforce <id> [--outcome success|failure]
|
||||
behavior.py list [--type correction|habit|all]
|
||||
behavior.py stats
|
||||
|
||||
Phase G strangler slice 2 (port 5): the decision math (reinforce
|
||||
counting/confidence/promotion/state formulas) is served IN-PROCESS by the
|
||||
Rust `evidence_provenance` cdylib through ep_loader.py. STORAGE STAYS
|
||||
PYTHON — SQLite I/O and the regex matching layer remain here (the crate is
|
||||
zero-dep by design; hand-rolling SQLite/regex would be hubris, not
|
||||
hardening). Vintage upgrade per the frozen contract §2: this module is the
|
||||
src/rune authority vintage; the deployed ~/.config/opencode/scripts copy
|
||||
is superseded by the same shim shape pointing at the live db path.
|
||||
Timestamps are pinned by the shim (`now`) — the core is deterministic.
|
||||
API, CLI, and PLUGIN shapes unchanged (git history holds the original).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -24,6 +35,12 @@ import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# ── Rust core wiring (slice-2 port-5 shim pattern) ──────────────────────────
|
||||
_CRATE_DIR = "/mnt/workdrive/rune/src/rust/evidence_provenance"
|
||||
if _CRATE_DIR not in sys.path:
|
||||
sys.path.insert(0, _CRATE_DIR)
|
||||
import ep_loader # noqa: E402 (loads target/release cdylib)
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import memory_env
|
||||
|
||||
@@ -194,7 +211,10 @@ def match_context(context, match_type=None):
|
||||
|
||||
|
||||
def reinforce(item_id, outcome, item_type="habit"):
|
||||
"""Reinforce a correction or habit based on outcome."""
|
||||
"""Reinforce a correction or habit based on outcome.
|
||||
|
||||
Decision math (counting, confidence formula, promotion, state) served
|
||||
by the Rust core; SQL stays here."""
|
||||
db = connect()
|
||||
try:
|
||||
ts = _now()
|
||||
@@ -204,28 +224,14 @@ def reinforce(item_id, outcome, item_type="habit"):
|
||||
if not habit:
|
||||
return {"error": f"Habit {item_id} not found"}
|
||||
|
||||
new_count = habit["activation_count"] + 1
|
||||
new_success = habit["success_count"] + (1 if outcome == "success" else 0)
|
||||
new_failure = habit["failure_count"] + (1 if outcome == "failure" else 0)
|
||||
|
||||
# Update confidence based on success rate
|
||||
if new_count > 0:
|
||||
success_rate = new_success / new_count
|
||||
new_confidence = min(1.0, 0.5 + success_rate * 0.5)
|
||||
else:
|
||||
new_confidence = habit["confidence"]
|
||||
|
||||
# Promote from candidate to active if enough activations
|
||||
new_state = habit["state"]
|
||||
if habit["state"] == "candidate" and new_count >= habit["min_activations"]:
|
||||
new_state = "active"
|
||||
|
||||
upd = ep_loader.habit_reinforce(dict(habit), outcome)
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE habits SET activation_count=?, success_count=?, failure_count=?,
|
||||
confidence=?, state=?, last_activated=? WHERE id=?
|
||||
""",
|
||||
(new_count, new_success, new_failure, new_confidence, new_state, ts, item_id),
|
||||
(upd["activation_count"], upd["success_count"], upd["failure_count"],
|
||||
upd["confidence"], upd["state"], ts, item_id),
|
||||
)
|
||||
|
||||
# Log activation
|
||||
@@ -238,33 +244,23 @@ def reinforce(item_id, outcome, item_type="habit"):
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return {"reinforced": item_id, "confidence": new_confidence, "state": new_state}
|
||||
return {"reinforced": item_id, "confidence": upd["confidence"], "state": upd["state"]}
|
||||
|
||||
else: # correction
|
||||
correction = db.execute("SELECT * FROM corrections WHERE id=?", (item_id,)).fetchone()
|
||||
if not correction:
|
||||
return {"error": f"Correction {item_id} not found"}
|
||||
|
||||
if outcome == "success":
|
||||
new_confidence = min(1.0, correction["confidence"] + 0.1)
|
||||
new_count = correction["evidence_count"] + 1
|
||||
elif outcome == "failure":
|
||||
new_confidence = max(0.0, correction["confidence"] - 0.3)
|
||||
new_count = correction["evidence_count"]
|
||||
else:
|
||||
new_confidence = correction["confidence"]
|
||||
new_count = correction["evidence_count"]
|
||||
|
||||
new_state = "active" if new_confidence > 0.3 else "archived"
|
||||
upd = ep_loader.correction_reinforce(dict(correction), outcome)
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE corrections SET confidence=?, evidence_count=?, state=?, last_reinforced=?
|
||||
WHERE id=?
|
||||
""",
|
||||
(new_confidence, new_count, new_state, ts, item_id),
|
||||
(upd["confidence"], upd["evidence_count"], upd["state"], ts, item_id),
|
||||
)
|
||||
db.commit()
|
||||
return {"reinforced": item_id, "confidence": new_confidence, "state": new_state}
|
||||
return {"reinforced": item_id, "confidence": upd["confidence"], "state": upd["state"]}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -408,4 +404,4 @@ PLUGIN = {
|
||||
"provides": ["behavior"],
|
||||
"priority": 50,
|
||||
"auto_activate": True,
|
||||
}
|
||||
}
|
||||
+48
-126
@@ -16,12 +16,29 @@ dependencies changed are invalidated; every invalidation carries a
|
||||
reason; unrelated hardened confirmations are preserved.
|
||||
|
||||
Storage: <project_store>/behaviour_ledger.json (versioned document).
|
||||
|
||||
Phase G strangler slice 2 (port 4): the state machine (define / get /
|
||||
transitions / selective invalidation / queues / summary) is served
|
||||
IN-PROCESS by the Rust `evidence_provenance` cdylib through ep_loader.py
|
||||
— parity 13/13 proven vs this module's Python vintage (full-scenario
|
||||
doc parity, byte-equal error messages, save render indent=1 sort_keys).
|
||||
File glue (load/save) stays Python; timestamps are pinned by this shim
|
||||
(`now`) so the core stays deterministic. State constants and class API
|
||||
unchanged (git history holds the original).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# ── Rust core wiring (slice-2 port-4 shim pattern) ──────────────────────────
|
||||
_CRATE_DIR = "/mnt/workdrive/rune/src/rust/evidence_provenance"
|
||||
if _CRATE_DIR not in sys.path:
|
||||
sys.path.insert(0, _CRATE_DIR)
|
||||
import ep_loader # noqa: E402 (loads target/release cdylib)
|
||||
|
||||
LEDGER_SCHEMA_VERSION = 1
|
||||
|
||||
# hardening lifecycle (rule 19G semantics)
|
||||
@@ -56,13 +73,11 @@ class BehaviourLedger:
|
||||
|
||||
def _load(self) -> dict:
|
||||
if self.path.is_file():
|
||||
import json
|
||||
return json.loads(self.path.read_text())
|
||||
return {"schema_version": LEDGER_SCHEMA_VERSION,
|
||||
"behaviours": {}, "updated_at": _now()}
|
||||
|
||||
def save(self) -> None:
|
||||
import json
|
||||
self.doc["updated_at"] = _now()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(json.dumps(self.doc, indent=1, sort_keys=True))
|
||||
@@ -77,66 +92,34 @@ class BehaviourLedger:
|
||||
dependencies: list[str] | None = None,
|
||||
automated_ref: str = "",
|
||||
environment: str = "") -> dict:
|
||||
bid = (behaviour_id if behaviour_id.startswith("behaviour:")
|
||||
else f"behaviour:{behaviour_id}")
|
||||
if bid in self.doc["behaviours"]:
|
||||
raise ValueError(f"behaviour exists: {bid}")
|
||||
rec = {
|
||||
"behaviour_id": bid,
|
||||
"owning_project": owning_project,
|
||||
"intended_behaviour": intended_behaviour,
|
||||
"origin_intent": origin_intent,
|
||||
"pass_criteria": pass_criteria,
|
||||
"fail_criteria": fail_criteria,
|
||||
"procedure": procedure,
|
||||
"owner_component": owner_component, # structural entity id
|
||||
"dependencies": list(dependencies or []), # structural ids
|
||||
"automated_ref": automated_ref,
|
||||
"environment": environment,
|
||||
"state": UNVALIDATED,
|
||||
"evidence": [], # [{at, generation, kind, detail}]
|
||||
"history": [], # every state transition, never lost
|
||||
"invalidations": [], # [{at, generation, because}]
|
||||
"limitations": [],
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
self.doc["behaviours"][bid] = rec
|
||||
self.doc, rec = ep_loader.bl_define(
|
||||
self.doc, behaviour_id=behaviour_id,
|
||||
owning_project=owning_project,
|
||||
intended_behaviour=intended_behaviour,
|
||||
origin_intent=origin_intent,
|
||||
pass_criteria=pass_criteria,
|
||||
fail_criteria=fail_criteria,
|
||||
procedure=procedure,
|
||||
owner_component=owner_component,
|
||||
dependencies=dependencies,
|
||||
automated_ref=automated_ref,
|
||||
environment=environment,
|
||||
now=_now())
|
||||
return rec
|
||||
|
||||
def get(self, behaviour_id: str) -> dict | None:
|
||||
return self.doc["behaviours"].get(
|
||||
behaviour_id if behaviour_id.startswith("behaviour:")
|
||||
else f"behaviour:{behaviour_id}")
|
||||
return ep_loader.bl_get(self.doc, behaviour_id)
|
||||
|
||||
# -- state transitions (all recorded in history — rule 19G) -------------
|
||||
|
||||
def _transition(self, rec: dict, new_state: str, *, kind: str,
|
||||
detail: str = "", generation: str = "") -> None:
|
||||
if new_state not in STATES:
|
||||
raise ValueError(f"unknown validation state {new_state!r}")
|
||||
rec["history"].append({
|
||||
"at": _now(), "from": rec["state"], "to": new_state,
|
||||
"kind": kind, "detail": detail[:200],
|
||||
"generation": generation})
|
||||
rec["state"] = new_state
|
||||
rec["updated_at"] = _now()
|
||||
|
||||
def record_auto_verification(self, behaviour_id: str, *,
|
||||
passed: bool, detail: str = "",
|
||||
generation: str = "") -> dict:
|
||||
"""Automated verification. GREEN NEVER IMPLIES USER_CONFIRMED
|
||||
(rule 19W-22): the ceiling here is AUTO_VERIFIED."""
|
||||
rec = self.get(behaviour_id)
|
||||
if rec is None:
|
||||
raise ValueError(f"unknown behaviour {behaviour_id!r}")
|
||||
rec["evidence"].append({"at": _now(), "kind": "automated",
|
||||
"passed": passed,
|
||||
"detail": detail[:200],
|
||||
"generation": generation})
|
||||
self._transition(rec, AUTO_VERIFIED if passed else TEST_FAILED,
|
||||
kind="automated_verification", detail=detail,
|
||||
generation=generation)
|
||||
self.doc, rec = ep_loader.bl_record_auto(
|
||||
self.doc, behaviour_id, passed=passed, detail=detail,
|
||||
generation=generation, now=_now())
|
||||
return rec
|
||||
|
||||
def record_real_world(self, behaviour_id: str, *, passed: bool,
|
||||
@@ -144,32 +127,16 @@ class BehaviourLedger:
|
||||
confirmed_by: str = "user") -> dict:
|
||||
"""Real-world/manual test result (testing mode). USER_CONFIRMED
|
||||
requires an explicit confirmed_by='user' pass (rule 19M)."""
|
||||
rec = self.get(behaviour_id)
|
||||
if rec is None:
|
||||
raise ValueError(f"unknown behaviour {behaviour_id!r}")
|
||||
rec["evidence"].append({"at": _now(), "kind": "real_world",
|
||||
"passed": passed,
|
||||
"detail": detail[:200],
|
||||
"generation": generation,
|
||||
"by": confirmed_by})
|
||||
if passed:
|
||||
state = (USER_CONFIRMED if confirmed_by == "user"
|
||||
else REAL_WORLD_PASSED)
|
||||
else:
|
||||
state = TEST_FAILED
|
||||
self._transition(rec, state, kind="real_world", detail=detail,
|
||||
generation=generation)
|
||||
self.doc, rec = ep_loader.bl_record_real_world(
|
||||
self.doc, behaviour_id, passed=passed, detail=detail,
|
||||
generation=generation, confirmed_by=confirmed_by, now=_now())
|
||||
return rec
|
||||
|
||||
def accept_limitation(self, behaviour_id: str, *, note: str,
|
||||
approved_by: str = "user") -> dict:
|
||||
rec = self.get(behaviour_id)
|
||||
if rec is None:
|
||||
raise ValueError(f"unknown behaviour {behaviour_id!r}")
|
||||
rec["limitations"].append({"at": _now(), "note": note[:200],
|
||||
"approved_by": approved_by})
|
||||
self._transition(rec, ACCEPTED_LIMITATION, kind="accept_limitation",
|
||||
detail=note, )
|
||||
self.doc, rec = ep_loader.bl_accept_limitation(
|
||||
self.doc, behaviour_id, note=note,
|
||||
approved_by=approved_by, now=_now())
|
||||
return rec
|
||||
|
||||
# -- selective invalidation (rule 19I) -----------------------------------
|
||||
@@ -182,44 +149,15 @@ class BehaviourLedger:
|
||||
structural dependencies intersect the changed closure. Every
|
||||
invalidation carries a reason; unrelated confirmations stay.
|
||||
Returns the invalidated records."""
|
||||
reasons = reasons or {}
|
||||
invalidated = []
|
||||
for rec in self.doc["behaviours"].values():
|
||||
if rec["state"] not in CONFIRMED_STATES:
|
||||
continue
|
||||
overlap = set(rec.get("dependencies") or []) & changed_entities
|
||||
if rec.get("owner_component") in changed_entities:
|
||||
overlap.add(rec["owner_component"])
|
||||
if not overlap:
|
||||
continue # unrelated hardened behaviour: PRESERVED (19S-5)
|
||||
because = reasons.get(
|
||||
rec["behaviour_id"],
|
||||
f"structural change touched: {sorted(overlap)}")
|
||||
rec["invalidations"].append({
|
||||
"at": _now(), "generation": generation,
|
||||
"because": because[:200],
|
||||
"was": rec["state"],
|
||||
"changed_entities": sorted(overlap)})
|
||||
# HISTORY IS PRESERVED — prior confirmation stays queryable
|
||||
self._transition(rec, RETEST_REQUIRED,
|
||||
kind="selective_invalidation",
|
||||
detail=f"was {rec['state']}; {because}",
|
||||
generation=generation)
|
||||
invalidated.append(rec)
|
||||
return invalidated
|
||||
self.doc, recs = ep_loader.bl_invalidate(
|
||||
self.doc, changed_entities=sorted(changed_entities),
|
||||
generation=generation, reasons=reasons, now=_now())
|
||||
return recs
|
||||
|
||||
def retest_history(self, behaviour_id: str) -> list[dict]:
|
||||
"""Generation-aware history: all confirmations + invalidations
|
||||
for one behaviour, oldest first (rule 19S-12)."""
|
||||
rec = self.get(behaviour_id)
|
||||
if rec is None:
|
||||
return []
|
||||
events = []
|
||||
for h in rec["history"]:
|
||||
events.append({"at": h["at"], "generation": h.get("generation"),
|
||||
"event": f"{h['from']}→{h['to']}",
|
||||
"kind": h["kind"], "detail": h.get("detail", "")})
|
||||
return events
|
||||
return ep_loader.bl_retest_history(self.doc, behaviour_id)
|
||||
|
||||
# -- queue (testing mode, 19L/M) ------------------------------------------
|
||||
|
||||
@@ -228,24 +166,8 @@ class BehaviourLedger:
|
||||
"""Eligible testing items: anything not confirmed/accepted/n.a.
|
||||
Dynamic — the queue re-derives from states after every fix
|
||||
(rule 19M)."""
|
||||
out = []
|
||||
for rec in self.doc["behaviours"].values():
|
||||
if rec["state"] in (USER_CONFIRMED, ACCEPTED_LIMITATION,
|
||||
NOT_APPLICABLE):
|
||||
continue
|
||||
if project and rec.get("owning_project") != project:
|
||||
continue
|
||||
if component and rec.get("owner_component") != component:
|
||||
continue
|
||||
out.append(rec)
|
||||
out.sort(key=lambda r: (0 if r["state"] == TEST_FAILED else 1,
|
||||
r["behaviour_id"]))
|
||||
return out
|
||||
return ep_loader.bl_outstanding(
|
||||
self.doc, component=component or "", project=project or "")
|
||||
|
||||
def summary(self) -> dict:
|
||||
counts: dict[str, int] = {}
|
||||
for rec in self.doc["behaviours"].values():
|
||||
counts[rec["state"]] = counts.get(rec["state"], 0) + 1
|
||||
return {"total": len(self.doc["behaviours"]),
|
||||
"states": counts,
|
||||
"outstanding": len(self.outstanding())}
|
||||
return ep_loader.bl_summary(self.doc)
|
||||
@@ -64,6 +64,7 @@ class BuilderCapsule:
|
||||
memory_lines: list = field(default_factory=list)
|
||||
lesson_lines: list = field(default_factory=list)
|
||||
constraints: list = field(default_factory=list)
|
||||
construction_lines: list = field(default_factory=list)
|
||||
generated_at: str = ""
|
||||
core_version: str = "rune-core/operator-path"
|
||||
|
||||
@@ -75,6 +76,7 @@ class BuilderCapsule:
|
||||
"memory_lines": self.memory_lines[:MAX_MEMORY_LINES],
|
||||
"lesson_lines": self.lesson_lines[:MAX_LESSON_LINES],
|
||||
"constraints": self.constraints,
|
||||
"construction_lines": self.construction_lines,
|
||||
"generated_at": self.generated_at,
|
||||
"core_version": self.core_version,
|
||||
}
|
||||
@@ -87,10 +89,14 @@ class BuilderCapsule:
|
||||
and not d["lesson_lines"]
|
||||
and not d["unknowns"]
|
||||
and not d["constraints"]
|
||||
and not d["construction_lines"]
|
||||
and d["task_route"] in ("LOCAL_FIX", "STANDARD_CHANGE")
|
||||
):
|
||||
return "" # nothing useful — inject nothing (handoff §5)
|
||||
lines = [f"### Rune Core preflight ({d['task_route']})"]
|
||||
if d["construction_lines"]:
|
||||
lines.append("**Active Scaffolding construction (read-only projection):**")
|
||||
lines += [f"- {c}" for c in d["construction_lines"]]
|
||||
if d["memory_lines"]:
|
||||
lines.append("**Relevant Rune memory:**")
|
||||
lines += [f"- {m}" for m in d["memory_lines"]]
|
||||
@@ -107,6 +113,56 @@ class BuilderCapsule:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _construction_store() -> str:
|
||||
"""The SAME construction store the operator path uses (env override
|
||||
honoured; default derives from the repo root, never a bare relative
|
||||
path). Kept identical to rune_operator._construction_store."""
|
||||
import os as _os
|
||||
env = _os.environ.get("RUNE_CONSTRUCTION_STORE")
|
||||
if env:
|
||||
return env
|
||||
from pathlib import Path as _P
|
||||
return str(_P(__file__).resolve().parent.parent.parent / "store" / "scaffold")
|
||||
|
||||
|
||||
def _active_construction_lines() -> list:
|
||||
"""Read-only projection of the ACTIVE Scaffolding construction into
|
||||
builder-consumable lines. This is the MiMo edge of the shared
|
||||
Construction Context (§15) — it surfaces canonical state; it is NOT a
|
||||
second construction authority and never mutates. Degrade-safe: any
|
||||
failure returns [] (the bridge must never block the builder)."""
|
||||
try:
|
||||
from .construction_interface import ConstructionInterface
|
||||
env = ConstructionInterface(builder="mimo-bridge",
|
||||
project_store=_construction_store(),
|
||||
project_id="issue161-harness"
|
||||
).construction_context()
|
||||
st = env.get("state") or {}
|
||||
if not st.get("active"):
|
||||
return []
|
||||
out = [f"contract {st.get('contract_id')} · phase {st.get('phase')}"
|
||||
f" · status {st.get('status')}"
|
||||
f" · mode {st.get('construction_mode')}"]
|
||||
if st.get("project"):
|
||||
out.append(f"project: {st['project']}")
|
||||
if st.get("need"):
|
||||
out.append(f"need: {st['need']}")
|
||||
scope = st.get("scope") or []
|
||||
if scope:
|
||||
out.append("scope: " + ", ".join(str(x) for x in scope[:8]))
|
||||
obl = st.get("obligations") or []
|
||||
if obl:
|
||||
out.append(f"outstanding obligations: {len(obl)}")
|
||||
for o in obl[:3]:
|
||||
txt = o.get("obligation") or o.get("kind") or o
|
||||
out.append(f" - {str(txt)[:120]}")
|
||||
if st.get("next_action"):
|
||||
out.append(f"next action: {st['next_action']}")
|
||||
return out
|
||||
except Exception: # noqa: BLE001 — projection only; never block the builder
|
||||
return []
|
||||
|
||||
|
||||
def _default_selector():
|
||||
"""The SAME MemorySelector the operator path uses (lazy, degrade-safe)."""
|
||||
try:
|
||||
@@ -166,6 +222,11 @@ def preflight(task_text: str, memory_selector=None) -> BuilderCapsule:
|
||||
# design (handoff §9: raw SEED records are outcome telemetry, not lessons).
|
||||
capsule.lesson_lines = _validated_lessons_for(task_text or "")
|
||||
|
||||
# Active Scaffolding construction state (read-only projection of the
|
||||
# shared Construction Context). Empty when no contract is active, so
|
||||
# the inject-nothing policy is preserved for ordinary turns.
|
||||
capsule.construction_lines = _active_construction_lines()
|
||||
|
||||
return capsule
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
#!/usr/bin/env python3
|
||||
"""canary_audit_ledger.py — Python canary for slice-2 ports 1-6.
|
||||
|
||||
Slice-1 PASS/FAIL discipline (counter harness, exit 1 on any FAIL), pinned to
|
||||
the FROZEN CONTRACT /mnt/workdrive/rune/plans/2026-10-03-phase-g-evidence-provenance-contract.md:
|
||||
§5 requires the canary pair to carry per-port sections for ALL SIX ports.
|
||||
|
||||
DUAL-MODE LAW (added 2026-10-03 after run 18141 went red): sections 2/3/4/5
|
||||
pin the LIVE promotion ledger when it exists in this process's HOME (desktop
|
||||
runs — the drift-key pin is the §3 linchpin). Under CI's sandboxed HOME the
|
||||
ledger file does not exist, so the same sections run an ISOLATED battery
|
||||
against the sandboxed ledger with count expectations re-derived from the
|
||||
seeded entries — identical test count, identical shapes, honest MODE print.
|
||||
The artifact coupling is disclosed, not hidden: the Rust binary + cdylib
|
||||
resolve to the checkout's own target/release/ (CI's host-executor topology
|
||||
makes the desktop build visible; if they ever go missing, ep_loader raises
|
||||
and this canary FAILS LOUDLY rather than testing nothing).
|
||||
|
||||
Canary expectations are OBSERVED, not invented:
|
||||
- Deployed shim head + verify_chain() on the live 23-entry ledger:
|
||||
{'valid': True, 'entries': 23, 'reason': 'chain intact'} (observed 2026-10-03).
|
||||
- verify_chain() return shape is a 3-key dict, no tuple unpacking.
|
||||
- Deployed SECRET_KEY default: 'rune-drift-integrity-2024' (the live-chain key).
|
||||
- Empty-ledger contract: missing ledger file -> valid-and-empty dict.
|
||||
- Shadow battery (18/18 GREEN, banked): read/write/CLI-error shapes all match
|
||||
the old vintage at the shim.
|
||||
|
||||
Run under ~/.venvs/memory/bin/python3.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/home/nick/.config/opencode/scripts")
|
||||
|
||||
# Repo-derived roots (no /mnt/workdrive hardcodes): this file lives in
|
||||
# src/rune/, so the crate is two parents up plus src/rust/evidence_provenance.
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
|
||||
_CRATE_DIR = os.path.join(_REPO, "src", "rust", "evidence_provenance")
|
||||
if os.environ.get("EP_RUST_LIB"):
|
||||
# CI: the build step emits to a runner-owned /tmp target dir and the job
|
||||
# points EP_RUST_LIB at the cdylib FILE (that's the form ep_loader's
|
||||
# override takes). The binary lives next to it.
|
||||
_BIN = os.path.join(os.path.dirname(os.environ["EP_RUST_LIB"]),
|
||||
"evidence_provenance")
|
||||
else:
|
||||
_BIN = os.path.join(_CRATE_DIR, "target", "release", "evidence_provenance")
|
||||
|
||||
# Dual-mode switch (DUAL-MODE LAW in the docstring): LIVE when this process's
|
||||
# HOME carries the real promotion ledger AND we are not inside the CI sandbox.
|
||||
# The discriminator is the repo's own CI-sandbox convention — the workflow sets
|
||||
# RUNE_HOME=/tmp/rune-ci-<run_id> and its cleanup guard matches /tmp/rune-ci-*
|
||||
# exactly. File EXISTENCE is NOT a valid discriminator: run 18141's shim writes
|
||||
# left a zombie 1-entry ledger in the runner's persistent HOME, which would
|
||||
# flip a naive isfile() gate to LIVE with the wrong data (proven 2026-10-03
|
||||
# on run 18161: "entries >= 23 — got 1").
|
||||
_LIVE_LEDGER = os.path.expanduser("~/.config/opencode/memory/promotion_ledger.jsonl")
|
||||
_IN_CI_SANDBOX = os.environ.get("RUNE_HOME", "").startswith("/tmp/rune-ci-")
|
||||
LIVE = os.path.isfile(_LIVE_LEDGER) and not _IN_CI_SANDBOX
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
|
||||
def test(name, condition, detail=""):
|
||||
global PASS, FAIL
|
||||
if condition:
|
||||
PASS += 1
|
||||
print(f" ✓ {name}")
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f" ✗ {name} — {detail}")
|
||||
|
||||
|
||||
def main():
|
||||
global PASS, FAIL
|
||||
|
||||
print("=== AUDIT LEDGER CANARY (slice-2 ports 1-6) ===")
|
||||
print(f"MODE: {'LIVE (deployed surface + live ledger)' if LIVE else 'ISOLATED (sandboxed ledger — CI mode)'}\n")
|
||||
|
||||
bin_home = None # binary HOME override for §5 in ISOLATED mode
|
||||
|
||||
# --- 1. Deployed module surface ---
|
||||
print("--- 1. Deployed module surface ---")
|
||||
try:
|
||||
import audit_ledger
|
||||
|
||||
test("audit_ledger imports (deployed shim)", True)
|
||||
for name in (
|
||||
"_compute_hash",
|
||||
"append_entry",
|
||||
"verify_chain",
|
||||
"get_entries",
|
||||
"log_promotion",
|
||||
"log_gate_result",
|
||||
"log_drift_alert",
|
||||
"main",
|
||||
):
|
||||
test(f"has {name}", hasattr(audit_ledger, name))
|
||||
test("LEDGER_FILE is the live ledger path",
|
||||
audit_ledger.LEDGER_FILE ==
|
||||
os.path.expanduser("~/.config/opencode/memory/promotion_ledger.jsonl"),
|
||||
f"got {audit_ledger.LEDGER_FILE}")
|
||||
test("SECRET_KEY default is the drift key (live-chain key)",
|
||||
audit_ledger.SECRET_KEY == "rune-drift-integrity-2024",
|
||||
f"got {audit_ledger.SECRET_KEY}")
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
test("audit_ledger module", False, str(e))
|
||||
print(f"CANARY ABORT: import failed — {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- 2. Ledger verification through the shim (live on desktop / isolated in CI) ---
|
||||
print("\n--- 2. Ledger verification ---")
|
||||
if not LIVE:
|
||||
# Seed a PER-RUN isolated ledger (never the runner's persistent HOME —
|
||||
# leftover writes there are what poisoned run 18161's mode detection):
|
||||
# point the shim's module-level LEDGER_FILE at a temp path; all §2/§3/§4
|
||||
# reads/writes then land in the temp file. Binary §5 gets HOME=<temp>.
|
||||
# Placed AFTER §1 so the surface pins observe the shim's DEFAULT
|
||||
# resolution before the override.
|
||||
import tempfile as _tf
|
||||
|
||||
bin_home = _tf.mkdtemp(prefix="alcanary_home_")
|
||||
iso_ledger = os.path.join(bin_home, ".config", "opencode", "memory",
|
||||
"promotion_ledger.jsonl")
|
||||
os.makedirs(os.path.dirname(iso_ledger), exist_ok=True)
|
||||
audit_ledger.LEDGER_FILE = iso_ledger
|
||||
for seed_id in (9001, 9002):
|
||||
audit_ledger.append_entry("promotion", seed_id,
|
||||
{"reason": "canary-ci-seed", "old_status": "a",
|
||||
"new_status": "b", "wash_count": 1})
|
||||
try:
|
||||
result = audit_ledger.verify_chain()
|
||||
test("verify_chain() returns dict", isinstance(result, dict), f"got {type(result)}") # type: ignore[reportUnnecessaryIsInstance]
|
||||
test("valid is True on the chain", result.get("valid") is True,
|
||||
f"got {result.get('valid')}")
|
||||
if LIVE:
|
||||
test("entries >= 23 (ledger grew through prior canaries)",
|
||||
result.get("entries", 0) >= 23, f"got {result.get('entries')}")
|
||||
else:
|
||||
test("entries == 2 (the §2 seeds this process wrote)",
|
||||
result.get("entries") == 2, f"got {result.get('entries')}")
|
||||
test("reason is 'chain intact'", result.get("reason") == "chain intact",
|
||||
f"got {result.get('reason')}")
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
test("live verify_chain()", False, str(e))
|
||||
|
||||
# --- 3. Read path: get_entries ---
|
||||
print("\n--- 3. Read path ---")
|
||||
try:
|
||||
entries = audit_ledger.get_entries(limit=5)
|
||||
test("get_entries(5) returns a list", isinstance(entries, list)) # type: ignore[reportUnnecessaryIsInstance]
|
||||
if LIVE:
|
||||
test("get_entries(5) returns 5 entries on the live ledger", len(entries) == 5,
|
||||
f"got {len(entries)}")
|
||||
else:
|
||||
test("get_entries(5) returns the 2 seeded entries (isolated)", len(entries) == 2,
|
||||
f"got {len(entries)}")
|
||||
first = entries[0] if entries else {}
|
||||
test("entry carries hash chain fields",
|
||||
"hash" in first and "prev_hash" in first,
|
||||
f"keys: {sorted(first.keys())}")
|
||||
# Observed deployed-vintage serving order: ASCENDING (oldest-first
|
||||
# within the limit window) — verified against the live ledger 2026-10-03.
|
||||
test("entries served in ascending timestamp order (deployed-vintage shape)",
|
||||
all(entries[i]["timestamp"] <= entries[i + 1]["timestamp"]
|
||||
for i in range(len(entries) - 1)),
|
||||
"timestamps not monotonic-ascending")
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
test("get_entries", False, str(e))
|
||||
|
||||
# --- 4. Write path: append through the deployed shim (LIVE ledger,
|
||||
# the established canary pattern — entries 89/90 are this shape) ---
|
||||
print("\n--- 4. Write path (canary append through the shim) ---")
|
||||
try:
|
||||
e1 = audit_ledger.append_entry("promotion", 4242, {"old_status": "established",
|
||||
"new_status": "rock",
|
||||
"wash_count": 20,
|
||||
"reason": "canary"})
|
||||
test("append_entry returns dict", isinstance(e1, dict), f"got {type(e1)}") # type: ignore[reportUnnecessaryIsInstance]
|
||||
test("entry has prev_hash + hash", "prev_hash" in e1 and "hash" in e1)
|
||||
test("hash is 64-hex", isinstance(e1.get("hash"), str) and len(e1["hash"]) == 64
|
||||
and all(c in "0123456789abcdef" for c in e1["hash"]))
|
||||
test("append flowed through the shim: live chain still intact",
|
||||
audit_ledger.verify_chain()["valid"] is True,
|
||||
"live chain invalid after append")
|
||||
tail = audit_ledger.get_entries(limit=1)
|
||||
test("newest live entry is the canary append (entry_id 4242)",
|
||||
tail and tail[0].get("entry_id") == 4242,
|
||||
f"got {tail[0].get('entry_id') if tail else 'empty'}")
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
test("append_entry write path", False, str(e))
|
||||
|
||||
# --- 5. Rust binary CLI parity (the compiled candidate) ---
|
||||
print("\n--- 5. Rust binary CLI parity ---")
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
BIN = _BIN
|
||||
tmp2 = tempfile.mkdtemp(prefix="alcanary_cli_")
|
||||
# The binary honors RUNE_MEMORY_DIR/RUNE_HOME first (main.rs ledger_path);
|
||||
# the deployed shim resolves expanduser($HOME). Strip the RUNE_* overrides
|
||||
# from binary envs so both surfaces resolve the SAME ledger — without this,
|
||||
# CI's RUNE_MEMORY_DIR silently pointed the binary at an empty ledger while
|
||||
# the shim wrote the sandboxed-HOME one (run 18141's ghost divergence).
|
||||
clean_env = {k: v for k, v in os.environ.items()
|
||||
if k not in ("RUNE_MEMORY_DIR", "RUNE_HOME")}
|
||||
if not os.path.exists(BIN):
|
||||
# FAIL loud, never crash: run 18164 died on an uncaught Popen
|
||||
# FileNotFoundError when the artifact was absent from the checkout.
|
||||
# CI now builds the crate (canary job); a missing binary here means
|
||||
# the build step regressed and this canary must go red, not skip.
|
||||
test("release binary exists in the checkout (CI builds it)",
|
||||
False, f"missing: {BIN}")
|
||||
else:
|
||||
try:
|
||||
env = dict(clean_env, HOME=tmp2)
|
||||
r = subprocess.run([BIN, "verify"], capture_output=True, text=True, env=env)
|
||||
test("binary verify on missing ledger -> valid-empty",
|
||||
r.returncode == 0 and "Chain valid: True" in r.stdout
|
||||
and "Entries: 0" in r.stdout and "empty ledger" in r.stdout,
|
||||
f"rc={r.returncode} out={r.stdout!r} err={r.stderr!r}")
|
||||
r = subprocess.run([BIN], capture_output=True, text=True, env=env)
|
||||
test("binary no-args -> help + exit 1", r.returncode == 1
|
||||
and "usage:" in r.stdout, f"rc={r.returncode}")
|
||||
r = subprocess.run([BIN, "frobnicate"], capture_output=True, text=True, env=env)
|
||||
test("binary invalid choice -> exit 2", r.returncode == 2
|
||||
and "invalid choice" in r.stderr, f"rc={r.returncode} err={r.stderr!r}")
|
||||
r = subprocess.run([BIN, "list", "--limit", "3"], capture_output=True, text=True,
|
||||
env=dict(clean_env, HOME=bin_home) if bin_home else clean_env)
|
||||
test("binary list --limit 3 on the shim's ledger renders 3 entries",
|
||||
r.returncode == 0 and "Recent ledger entries (3):" in r.stdout,
|
||||
f"rc={r.returncode} out={r.stdout[:120]!r}")
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(tmp2, ignore_errors=True)
|
||||
|
||||
# --- 6. Port 2 (verification_layer) through the same cdylib ---
|
||||
print("\n--- 6. Port 2: verification_layer via ep_loader ---")
|
||||
import json
|
||||
if _CRATE_DIR not in sys.path:
|
||||
sys.path.insert(0, _CRATE_DIR)
|
||||
try:
|
||||
import ep_loader # type: ignore[import-not-found] (ctypes cdylib wrapper — runtime import by design)
|
||||
v = ep_loader.verify('{"exit_code": 0}', "exit_code")
|
||||
out = json.loads(v)
|
||||
test("ep_verify exit_code=0 -> passed True", out.get("passed") is True)
|
||||
v = ep_loader.verify('{"exit_code": false}', "exit_code")
|
||||
out = json.loads(v)
|
||||
test("ep_verify bool-false renders str() shape (False, not 0)",
|
||||
out.get("passed") is True and out.get("evidence_refs") == ["exit_code=False"],
|
||||
f"got {out}")
|
||||
v = ep_loader.verify('{}', "frobnicate")
|
||||
out = json.loads(v)
|
||||
test("ep_verify unknown verifier -> passed False retryable False",
|
||||
out.get("passed") is False and out.get("retryable") is False
|
||||
and out.get("escalation_reason") == "unknown_verifier",
|
||||
f"got {out}")
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
test("port-2 ep_verify FFI", False, str(e))
|
||||
|
||||
# --- 7. src/rune shim module ---
|
||||
print("\n--- 7. src/rune verification_layer shim ---")
|
||||
try:
|
||||
sys.path.insert(0, os.path.join(_REPO, "src", "rune"))
|
||||
import verification_layer as vl
|
||||
r = vl.verify({"exit_code": 1}, "exit_code")
|
||||
test("shim verify returns VerificationResult dataclass",
|
||||
type(r).__name__ == "VerificationResult")
|
||||
test("shim CLI parity: fail case escalation_reason=exit_code_nonzero",
|
||||
r.to_dict()["escalation_reason"] == "exit_code_nonzero")
|
||||
test("shim PLUGIN name preserved", vl.PLUGIN["name"] == "verification_layer")
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
test("src/rune verification_layer shim", False, str(e))
|
||||
|
||||
# --- 8. Port 3 (evidence_probe): schema gate + budget count ---
|
||||
# Expectations probed against the serving shim 2026-10-03 (zero-dep split:
|
||||
# schema gate + count Rust; orchestration Python).
|
||||
print("\n--- 8. Port 3: evidence_probe shim ---")
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
import evidence_probe as epr
|
||||
|
||||
errs = epr.validate_probe({})
|
||||
test("validate_probe({}) -> 5 missing-field errors in field order",
|
||||
errs == ["missing required field: question",
|
||||
"missing required field: source",
|
||||
"missing required field: answer",
|
||||
"missing required field: implication",
|
||||
"missing required field: status"], f"got {errs}")
|
||||
good = {"question": "q", "source": "s", "answer": "a",
|
||||
"implication": "i", "status": "STILL_UNKNOWN"}
|
||||
test("valid STILL_UNKNOWN probe -> no errors",
|
||||
epr.validate_probe(good) == [], f"got {epr.validate_probe(good)}")
|
||||
bad_status = dict(good, status="PASS")
|
||||
test("status 'PASS' -> membership error with Python repr rendering",
|
||||
epr.validate_probe(bad_status) ==
|
||||
["status must be one of ['CONTRADICTED', 'GROUNDED', 'STILL_UNKNOWN'], got 'PASS'"],
|
||||
f"got {epr.validate_probe(bad_status)}")
|
||||
int_status = dict(good, status=7)
|
||||
test("non-string status -> field error AND membership error (Python law)",
|
||||
epr.validate_probe(int_status) ==
|
||||
["field status must be a string",
|
||||
"status must be one of ['CONTRADICTED', 'GROUNDED', 'STILL_UNKNOWN'], got 7"],
|
||||
f"got {epr.validate_probe(int_status)}")
|
||||
extra = dict(good, extra=1)
|
||||
test("extra field -> unexpected-fields error appended last",
|
||||
epr.validate_probe(extra)[-1] == "unexpected extra fields: ['extra']",
|
||||
f"got {epr.validate_probe(extra)}")
|
||||
grounded_blank = dict(good, question="", status="GROUNDED")
|
||||
test("blank question + GROUNDED -> both errors (citation gate)",
|
||||
epr.validate_probe(grounded_blank) ==
|
||||
["field question must be a non-empty string",
|
||||
"GROUNDED probe requires a concrete source citation"],
|
||||
f"got {epr.validate_probe(grounded_blank)}")
|
||||
|
||||
probe_dir = tempfile.mkdtemp(prefix="eprcanary_")
|
||||
try:
|
||||
with open(os.path.join(probe_dir, "probe.jsonl"), "w") as f:
|
||||
f.write('{"q": 1}\n\n{"r": 2}\n\n\n{"s": 3}\n')
|
||||
test("count_probes counts non-empty lines only",
|
||||
epr.count_probes(probe_dir) == 3,
|
||||
f"got {epr.count_probes(probe_dir)}")
|
||||
test("count_probes on missing dir -> 0",
|
||||
epr.count_probes(probe_dir + "/nope") == 0)
|
||||
test("MAX_PROBES_PER_TASK is 2 (the no-recursion budget)",
|
||||
epr.MAX_PROBES_PER_TASK == 2)
|
||||
try:
|
||||
epr.check_budget(probe_dir)
|
||||
test("check_budget raises at the budget", False, "no raise")
|
||||
except epr.ProbeBudgetExceeded as e:
|
||||
test("check_budget raises ProbeBudgetExceeded at 2 probes",
|
||||
str(e).startswith("probe budget exhausted (2)"), f"got {e}")
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(probe_dir, ignore_errors=True)
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
test("port-3 evidence_probe shim", False, str(e))
|
||||
|
||||
# --- 9. Port 4 (behaviour_ledger): doc-envelope state machine ---
|
||||
# Expectations probed against ep_loader 2026-10-03: doc-in/doc-out,
|
||||
# {ok, err} envelope, CONFIRMED_STATES-only invalidation, TEST_FAILED-first
|
||||
# queue ordering.
|
||||
print("\n--- 9. Port 4: behaviour_ledger via ep_loader ---")
|
||||
try:
|
||||
import ep_loader # type: ignore[import-not-found] (already imported in §6; re-import is a cache hit)
|
||||
|
||||
doc = {"behaviours": {}}
|
||||
doc, rec = ep_loader.bl_define(
|
||||
doc, behaviour_id="alpha", owning_project="proj",
|
||||
intended_behaviour="ib", origin_intent="oi", pass_criteria="pc",
|
||||
fail_criteria="fc", procedure="proc", owner_component="comp:alpha",
|
||||
dependencies=["dep:one"], automated_ref="ar", environment="env",
|
||||
now="2026-10-03T23:00:00Z")
|
||||
test("define returns (doc, rec) with normalized id + UNVALIDATED",
|
||||
rec.get("behaviour_id") == "behaviour:alpha"
|
||||
and rec.get("state") == "UNVALIDATED",
|
||||
f"got id={rec.get('behaviour_id')} state={rec.get('state')}")
|
||||
test("define record carries the 18-key shape",
|
||||
set(rec.keys()) == {"automated_ref", "behaviour_id", "created_at",
|
||||
"dependencies", "environment", "evidence",
|
||||
"fail_criteria", "history", "intended_behaviour",
|
||||
"invalidations", "limitations", "origin_intent",
|
||||
"owner_component", "owning_project", "pass_criteria",
|
||||
"procedure", "state", "updated_at"},
|
||||
f"got {sorted(rec.keys())}")
|
||||
|
||||
doc, _ = ep_loader.bl_record_auto(doc, "alpha", passed=True,
|
||||
detail="tests green", generation="g1",
|
||||
now="2026-10-03T23:01:00Z")
|
||||
rec_a = ep_loader.bl_get(doc, "alpha")
|
||||
test("record_auto pass -> AUTO_VERIFIED (a CONFIRMED state)",
|
||||
rec_a is not None and rec_a["state"] == "AUTO_VERIFIED")
|
||||
|
||||
doc, _ = ep_loader.bl_define(doc, behaviour_id="beta", owning_project="proj",
|
||||
intended_behaviour="ib2", now="2026-10-03T23:02:00Z")
|
||||
doc, _ = ep_loader.bl_record_auto(doc, "beta", passed=False, detail="flaky",
|
||||
now="2026-10-03T23:03:00Z")
|
||||
rec_b = ep_loader.bl_get(doc, "beta")
|
||||
test("record_auto fail -> TEST_FAILED",
|
||||
rec_b is not None and rec_b["state"] == "TEST_FAILED")
|
||||
|
||||
# Selective invalidation (rule 19I): only CONFIRMED_STATES with
|
||||
# dep/owner overlap reopen; unrelated change preserves.
|
||||
doc, untouched = ep_loader.bl_invalidate(
|
||||
doc, changed_entities=["dep:unrelated"], now="2026-10-03T23:04:00Z")
|
||||
rec_a = ep_loader.bl_get(doc, "alpha")
|
||||
test("unrelated change invalidates nothing; AUTO_VERIFIED preserved",
|
||||
untouched == [] and rec_a is not None and rec_a["state"] == "AUTO_VERIFIED")
|
||||
doc, hit = ep_loader.bl_invalidate(
|
||||
doc, changed_entities=["dep:one"], generation="g2",
|
||||
reasons={"behaviour:alpha": "dep one changed"}, now="2026-10-03T23:05:00Z")
|
||||
test("dependency hit invalidates exactly the confirmed behaviour",
|
||||
len(hit) == 1 and hit[0]["state"] == "RETEST_REQUIRED",
|
||||
f"got {[r['state'] for r in hit]}")
|
||||
inv_entry = hit[0]["invalidations"][0]
|
||||
test("invalidation entry carries because/generation/was/changed_entities",
|
||||
inv_entry.get("because") == "dep one changed"
|
||||
and inv_entry.get("generation") == "g2"
|
||||
and inv_entry.get("was") == "AUTO_VERIFIED"
|
||||
and inv_entry.get("changed_entities") == ["dep:one"],
|
||||
f"got {inv_entry}")
|
||||
doc, rehit = ep_loader.bl_invalidate(
|
||||
doc, changed_entities=["dep:one"], now="2026-10-03T23:06:00Z")
|
||||
rec_a = ep_loader.bl_get(doc, "alpha")
|
||||
test("re-invalidating a non-confirmed record is a no-op",
|
||||
rehit == [] and rec_a is not None and rec_a["state"] == "RETEST_REQUIRED")
|
||||
|
||||
out = ep_loader.bl_outstanding(doc)
|
||||
test("outstanding() puts TEST_FAILED first (queue ordering)",
|
||||
out and out[0]["state"] == "TEST_FAILED",
|
||||
f"got {[(r['behaviour_id'], r['state']) for r in out]}")
|
||||
summ = ep_loader.bl_summary(doc)
|
||||
test("summary() shape {total, states, outstanding}",
|
||||
summ.get("total") == 2 and isinstance(summ.get("states"), dict)
|
||||
and "outstanding" in summ, f"got {summ}")
|
||||
hist = ep_loader.bl_retest_history(doc, "alpha")
|
||||
test("retest_history(alpha) -> [automated_verification, selective_invalidation]",
|
||||
[e.get("kind") for e in hist] == ["automated_verification", "selective_invalidation"],
|
||||
f"got {[e.get('kind') for e in hist]}")
|
||||
|
||||
try:
|
||||
ep_loader.bl_define(doc, behaviour_id="alpha", owning_project="x",
|
||||
intended_behaviour="y", now="2026-10-03T23:07:00Z")
|
||||
test("duplicate define raises ValueError", False, "no raise")
|
||||
except ValueError as e:
|
||||
test("duplicate define error message byte-exact",
|
||||
str(e) == "behaviour exists: behaviour:alpha", f"got {e}")
|
||||
try:
|
||||
ep_loader.bl_record_auto(doc, "ghost", passed=True, now="2026-10-03T23:08:00Z")
|
||||
test("unknown behaviour record raises ValueError", False, "no raise")
|
||||
except ValueError as e:
|
||||
test("unknown behaviour error message byte-exact",
|
||||
str(e) == "unknown behaviour 'ghost'", f"got {e}")
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
test("port-4 behaviour_ledger FFI", False, str(e))
|
||||
|
||||
# --- 10. Port 5 (behavior): decision math through the shim on an
|
||||
# isolated db (RUNE_HOME + RUNE_MEMORY_DIR pinned BEFORE import —
|
||||
# memory_dir() honors RUNE_MEMORY_DIR first, and DB_PATH resolves at
|
||||
# import time). SQLite stays Python; the math is Rust.
|
||||
print("\n--- 10. Port 5: behavior shim (isolated db) ---")
|
||||
_iso_home = tempfile.mkdtemp(prefix="alcanary_runehome_")
|
||||
os.environ["RUNE_HOME"] = _iso_home
|
||||
os.environ["RUNE_MEMORY_DIR"] = os.path.join(_iso_home, "memory")
|
||||
try:
|
||||
import behavior as bh
|
||||
|
||||
test("behavior DB resolved under the isolated RUNE_HOME",
|
||||
bh.DB_PATH.startswith(os.environ["RUNE_HOME"]), f"got {bh.DB_PATH}")
|
||||
cid = bh.add_correction("say X", "say Y", domain="canary")["added"]
|
||||
r = bh.reinforce(cid, "success", item_type="correction")
|
||||
test("correction reinforce success: confidence 0.5 -> 0.6, stays active",
|
||||
r == {"reinforced": cid, "confidence": 0.6, "state": "active"},
|
||||
f"got {r}")
|
||||
r = bh.reinforce(cid, "failure", item_type="correction")
|
||||
test("correction reinforce failure: 0.6 -> 0.3, archives at the boundary",
|
||||
r == {"reinforced": cid, "confidence": 0.3, "state": "archived"},
|
||||
f"got {r}")
|
||||
hid = bh.add_habit("trigger t", "action a", name="canary habit")["added"]
|
||||
for _ in range(3):
|
||||
r = bh.reinforce(hid, "success")
|
||||
test("habit promotion: candidate -> active at min_activations (3)",
|
||||
r["state"] == "active" and r["confidence"] == 1,
|
||||
f"got {r}")
|
||||
test("habit not-found returns error dict (not raise)",
|
||||
bh.reinforce(99999, "success") == {"error": "Habit 99999 not found"})
|
||||
test("correction not-found returns error dict (not raise)",
|
||||
bh.reinforce(99999, "success", item_type="correction")
|
||||
== {"error": "Correction 99999 not found"})
|
||||
stats = bh.behavior_stats()
|
||||
test("behavior_stats carries the five-key shape",
|
||||
set(stats.keys()) == {"corrections_active", "corrections_archived",
|
||||
"habits_active", "habits_candidate",
|
||||
"total_activations"}, f"got {sorted(stats.keys())}")
|
||||
test("stats reflect the battery: 1 archived correction, 1 active habit",
|
||||
stats["corrections_archived"] == 1 and stats["habits_active"] == 1,
|
||||
f"got {stats}")
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
test("port-5 behavior shim", False, str(e))
|
||||
|
||||
# --- 11. Port 6 (claim_audit): full pipeline through the shim on an
|
||||
# isolated journal (MEM_DIR resolves under the pinned RUNE_HOME).
|
||||
# SYMMETRY GUARD never-fatal: research_lens absence must not crash.
|
||||
print("\n--- 11. Port 6: claim_audit shim (isolated journal) ---")
|
||||
try:
|
||||
import claim_audit as ca
|
||||
|
||||
claims = ca.scan("We exceeded the frontier. Results are flawless.")
|
||||
test("scan flags the comparative + absolute claims",
|
||||
[(c["type"], c["match"]) for c in claims]
|
||||
== [("comparative", "we exceeded"), ("absolute", "flawless")],
|
||||
f"got {[(c['type'], c['match']) for c in claims]}")
|
||||
st = ca.scan_text("We exceeded anything.")
|
||||
test("scan_text returns the repaired {claims, clean, degraded, rewritten} surface",
|
||||
set(st.keys()) == {"claims", "clean", "degraded", "rewritten"},
|
||||
f"got {sorted(st.keys())}")
|
||||
test("scan_text degrades the unsupported comparative (clean False)",
|
||||
st["clean"] is False and len(st["degraded"]) == 1
|
||||
and st["degraded"][0]["verdict"] == "DEGRADE",
|
||||
f"clean={st['clean']} degraded={st['degraded']}")
|
||||
res = ca.audit("We exceeded the frontier. Results are flawless.")
|
||||
test("audit() -> both claims DEGRADE, clean False",
|
||||
res["clean"] is False
|
||||
and [c["verdict"] for c in res["claims"]] == ["DEGRADE", "DEGRADE"],
|
||||
f"got {[(c['verdict']) for c in res['claims']]} clean={res['clean']}")
|
||||
test("audit() rewrote the draft (degrade templates applied)",
|
||||
res["rewritten"] != "We exceeded the frontier. Results are flawless.")
|
||||
with open(ca.JOURNAL) as f:
|
||||
rows = f.readlines()
|
||||
test("journal wrote one row per claim + scan_text row (isolated)",
|
||||
len(rows) == 3, f"got {len(rows)} rows at {ca.JOURNAL}")
|
||||
rep = ca.report()
|
||||
test("report() aggregates through the Rust core: total + by_verdict + by_type",
|
||||
rep.get("total") == 3
|
||||
and rep.get("by_verdict", {}).get("DEGRADE") == 3
|
||||
and rep.get("by_type", {}).get("comparative") == 2
|
||||
and rep.get("by_type", {}).get("absolute") == 1,
|
||||
f"got {rep}")
|
||||
test("verify() on an unsupported plain claim -> ('DEGRADE', 0)",
|
||||
ca.verify("the module renders a menu when opened") == ("DEGRADE", 0))
|
||||
test("evidence-term stoplist through the Rust core (>=4 chars, trio dropped)",
|
||||
ca.ep_loader.ca_evidence_terms(["this", "hash", "canary", "ru"])
|
||||
== ["this", "hash", "canary"],
|
||||
f"got {ca.ep_loader.ca_evidence_terms(['this', 'hash', 'canary', 'ru'])}")
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
test("port-6 claim_audit shim", False, str(e))
|
||||
|
||||
print(f"\n{'=' * 47}")
|
||||
print(f"RESULTS: {PASS} pass, {FAIL} fail")
|
||||
sys.exit(1 if FAIL else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = os.path.join(sys.path[0], "canary_audit_ledger.py")
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--selfcheck":
|
||||
print("selfcheck ok")
|
||||
sys.exit(0)
|
||||
main()
|
||||
@@ -231,7 +231,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.86.0"' in rune_version,
|
||||
'__version__ = "0.90.0"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""canary_rune_entry.py — H1 canary for the Go first-call shim.
|
||||
|
||||
Same PASS/FAIL discipline as the slice canaries (counter harness, exit 1 on
|
||||
any FAIL). The canary builds the zero-dep Go module into a temp dir at run
|
||||
start (works on the desktop and in CI — system Go is world-readable), then
|
||||
verifies the observed launcher contract:
|
||||
|
||||
--version -> "rune <x.y.z>" matching the deployed pyproject version
|
||||
--python-only -> the exact handoff shape of the deployed bash launcher
|
||||
(cd <deployed>/src && <venv-python> -m rune <args>)
|
||||
--help -> usage text, exit 0
|
||||
unknown flag -> usage on stderr, exit 2
|
||||
go test -> the module's own unit battery green (subprocess)
|
||||
|
||||
Expectations are OBSERVED, not invented: the bash launcher at
|
||||
~/.local/bin/rune (cd + env contract) and the deployed pyproject version.
|
||||
|
||||
Run under ~/.venvs/memory/bin/python3.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
REPO = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
|
||||
GO_MOD = os.path.join(REPO, "src", "go", "rune-entry")
|
||||
|
||||
|
||||
def test(name, cond, detail=""):
|
||||
global PASS, FAIL
|
||||
mark = "✓" if cond else "✗"
|
||||
print(f" {mark} {name}" + (f" — {detail}" if detail and not cond else ""))
|
||||
if cond:
|
||||
PASS += 1
|
||||
else:
|
||||
FAIL += 1
|
||||
|
||||
|
||||
def deployed_version():
|
||||
for cand in ("/mnt/workdrive/rune-code/current/pyproject.toml",
|
||||
os.path.join(REPO, "pyproject.toml")):
|
||||
try:
|
||||
for line in open(cand):
|
||||
if line.startswith('version = "'):
|
||||
return line.split('"')[1]
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print("=== RUNE ENTRY (Go first-call) CANARY (H1) ===\n")
|
||||
|
||||
# --- 0. Build the artifact (zero-dep; system Go) ---
|
||||
print("--- 0. Build ---")
|
||||
tmp = tempfile.mkdtemp(prefix="runeentry_canary_")
|
||||
bin_path = os.path.join(tmp, "rune")
|
||||
r = subprocess.run(["go", "build", "-o", bin_path, "."],
|
||||
capture_output=True, text=True, cwd=GO_MOD)
|
||||
test("go build succeeds (zero-dep, system toolchain)", r.returncode == 0,
|
||||
f"rc={r.returncode} err={r.stderr[:200]!r}")
|
||||
if r.returncode != 0:
|
||||
print(f"\nRESULTS: {PASS} pass, {FAIL} fail")
|
||||
sys.exit(1)
|
||||
|
||||
# --- 1. Version surface ---
|
||||
print("\n--- 1. Version ---")
|
||||
want = deployed_version()
|
||||
r = subprocess.run([bin_path, "--version"], capture_output=True, text=True)
|
||||
test("--version exits 0", r.returncode == 0, f"rc={r.returncode}")
|
||||
test("--version prints 'rune <x.y.z>'",
|
||||
r.stdout.startswith("rune ") and r.stdout.split()[-1].count(".") == 2,
|
||||
f"got {r.stdout!r}")
|
||||
test("version matches the deployed pyproject",
|
||||
want is not None and r.stdout.strip() == f"rune {want}",
|
||||
f"got {r.stdout.strip()!r} want 'rune {want}'")
|
||||
|
||||
# --- 2. Handoff shape (the bash launcher's contract) ---
|
||||
print("\n--- 2. Handoff shape ---")
|
||||
r = subprocess.run([bin_path, "--python-only", "status"],
|
||||
capture_output=True, text=True)
|
||||
out = r.stdout.strip()
|
||||
test("--python-only exits 0", r.returncode == 0, f"rc={r.returncode}")
|
||||
test("handoff cd's into the deployed src revision",
|
||||
out.startswith("(cd /mnt/workdrive/rune-code/current/src"),
|
||||
f"got {out!r}")
|
||||
venv_py = os.path.join(os.environ.get("HOME", ""), ".venvs", "memory", "bin", "python3")
|
||||
if os.path.isfile(venv_py):
|
||||
test("handoff execs the memory-venv python with -m rune",
|
||||
".venvs/memory/bin/python3 -m rune" in out, f"got {out!r}")
|
||||
else:
|
||||
test("handoff falls back to PATH python3 when the memory venv is absent "
|
||||
"(bare HOME — CI sandbox)",
|
||||
"python3 -m rune" in out, f"got {out!r}")
|
||||
test("args pass through the handoff",
|
||||
out.endswith("status)"), f"got {out!r}")
|
||||
|
||||
# --- 3. Flag discipline ---
|
||||
print("\n--- 3. Flag discipline ---")
|
||||
r = subprocess.run([bin_path, "--help"], capture_output=True, text=True)
|
||||
test("--help exits 0 with usage text",
|
||||
r.returncode == 0 and "usage:" in r.stdout,
|
||||
f"rc={r.returncode} out={r.stdout[:60]!r}")
|
||||
r = subprocess.run([bin_path, "--frobnicate"], capture_output=True, text=True)
|
||||
test("unknown flag -> exit 2 + usage on stderr",
|
||||
r.returncode == 2 and "usage:" in r.stderr,
|
||||
f"rc={r.returncode} err={r.stderr[:80]!r}")
|
||||
|
||||
# --- 4. The module's own unit battery ---
|
||||
print("\n--- 4. go test ---")
|
||||
r = subprocess.run(["go", "test", "./..."], capture_output=True, text=True,
|
||||
cwd=GO_MOD)
|
||||
test("go test ./... green (the Go unit battery)",
|
||||
r.returncode == 0 and "ok" in r.stdout,
|
||||
f"rc={r.returncode} out={r.stdout[-120:]!r}")
|
||||
|
||||
# --- 5. go vet ---
|
||||
print("\n--- 5. go vet ---")
|
||||
r = subprocess.run(["go", "vet", "./..."], capture_output=True, text=True,
|
||||
cwd=GO_MOD)
|
||||
test("go vet clean", r.returncode == 0,
|
||||
f"rc={r.returncode} err={r.stderr[:120]!r}")
|
||||
|
||||
import shutil
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
print(f"\n{'=' * 47}")
|
||||
print(f"RESULTS: {PASS} pass, {FAIL} fail")
|
||||
sys.exit(1 if FAIL else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""canary_rune_serve.py — H2 parity battery (T4.3, contract
|
||||
BUILDER-2026-10-04T08:32:43Z-cli).
|
||||
|
||||
The H2 contract: the Go front door (`rune serve`) serves IDENTICAL shapes
|
||||
to the Python core server for the same canned conversations — byte-level
|
||||
for metadata (modulo volatile id/created), exact for NDJSON line sequences.
|
||||
|
||||
Determinism law: no live model calls. Both servers run against a stub
|
||||
operator with canned responses (the established _execute_on_model stub
|
||||
class of seam — the server's shape is the contract under test, not the
|
||||
cognition behind it).
|
||||
|
||||
Sections:
|
||||
1. Python core server boots (stdlib, ephemeral port)
|
||||
2. Python shape freeze (models / status / chat / stream / 404)
|
||||
3. Go serve subcommand builds and answers --help
|
||||
4. Cross-language parity (Go front → Python core, same canned set)
|
||||
5. Core-down honesty (502 envelope, both servers agree)
|
||||
|
||||
Run: python3 src/rune/canary_rune_serve.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
|
||||
def test(name: str, ok: bool, detail: str = "") -> None:
|
||||
global PASS, FAIL
|
||||
if ok:
|
||||
PASS += 1
|
||||
print(f" ✓ {name}")
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f" ✗ {name} — {detail}")
|
||||
|
||||
|
||||
# ── stub operator: canned, deterministic ────────────────────────────────────
|
||||
|
||||
CANNED_TASK = {
|
||||
"task_id": "task-canned-001",
|
||||
"status": "completed",
|
||||
"model": "stub-model",
|
||||
"route": "direct",
|
||||
"user_text": "Canned reply: the shape is the contract.",
|
||||
"success": True,
|
||||
"degraded": [],
|
||||
"iterations_used": 1,
|
||||
"iteration_budget": 6,
|
||||
}
|
||||
|
||||
|
||||
class StubOperator:
|
||||
def list_models(self):
|
||||
return [{"name": "stub-model-a", "size_gb": 4.1},
|
||||
{"name": "stub-model-b", "size_gb": 8.2}]
|
||||
|
||||
def get_status(self):
|
||||
return {"identity": "rune", "role": "operator", "current_task": None,
|
||||
"active_model": "stub-model-a", "tasks_pending": 0,
|
||||
"tasks_running": 0, "tasks_completed": 3}
|
||||
|
||||
def send_task(self, description, model=None, channel="operator"):
|
||||
out = dict(CANNED_TASK)
|
||||
out["echo"] = description # proves the request reached the core
|
||||
return out
|
||||
|
||||
def conversation_window(self, channel="operator", n=8):
|
||||
return [{"role": "user", "content": "earlier", "channel_id": channel}][:n]
|
||||
|
||||
def resolve_pending_approval(self, approved):
|
||||
return {"resolved": bool(approved), "approval_id": "appr-1"}
|
||||
|
||||
|
||||
# ── http helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def http(method: str, url: str, body: dict | None = None):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
raw = resp.read().decode()
|
||||
return resp.status, raw
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()
|
||||
|
||||
|
||||
def normalize(obj):
|
||||
"""Strip volatile fields (id, created) for parity comparison."""
|
||||
if isinstance(obj, dict):
|
||||
return {k: normalize(v) for k, v in obj.items()
|
||||
if k not in ("id", "created")}
|
||||
if isinstance(obj, list):
|
||||
return [normalize(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def wait_ready(url: str, timeout: float = 10.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=1) as r:
|
||||
if r.status == 200:
|
||||
return True
|
||||
except Exception: # noqa: BLE001 — readiness poll: any failure = not yet
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
CANNED_CHAT = {"model": "stub-model-a",
|
||||
"messages": [{"role": "user", "content": "Say the canned thing."}]}
|
||||
|
||||
|
||||
def fire_shape_set(base: str) -> dict:
|
||||
"""The canned request battery against one server; returns raw results."""
|
||||
out = {}
|
||||
out["models"] = http("GET", f"{base}/v1/models")
|
||||
out["status"] = http("GET", f"{base}/v1/status")
|
||||
out["chat"] = http("POST", f"{base}/v1/chat/completions", CANNED_CHAT)
|
||||
out["stream"] = http("POST", f"{base}/v1/chat/completions",
|
||||
{**CANNED_CHAT, "stream": True})
|
||||
out["conversation"] = http("GET", f"{base}/v1/conversation?channel=operator&n=8")
|
||||
out["approve"] = http("POST", f"{base}/v1/approvals/resolve", {"approved": True})
|
||||
out["missing"] = http("GET", f"{base}/v1/definitely-not-here")
|
||||
return out
|
||||
|
||||
|
||||
def check_shapes(prefix: str, results: dict) -> None:
|
||||
s, raw = results["models"]
|
||||
body = json.loads(raw) if s == 200 else {}
|
||||
test(f"{prefix}: /v1/models 200 + list envelope",
|
||||
s == 200 and body.get("object") == "list"
|
||||
and [m.get("id") for m in body.get("data", [])] == ["stub-model-a", "stub-model-b"]
|
||||
and all(m.get("object") == "model" and m.get("owned_by") == "rune"
|
||||
for m in body.get("data", [])),
|
||||
f"status={s} body={raw[:160]!r}")
|
||||
|
||||
s, raw = results["status"]
|
||||
body = json.loads(raw) if s == 200 else {}
|
||||
test(f"{prefix}: /v1/status pass-through keys",
|
||||
s == 200 and body.get("active_model") == "stub-model-a"
|
||||
and body.get("tasks_completed") == 3,
|
||||
f"status={s} body={raw[:160]!r}")
|
||||
|
||||
s, raw = results["chat"]
|
||||
body = json.loads(raw) if s == 200 else {}
|
||||
choices = body.get("choices", [{}])
|
||||
test(f"{prefix}: chat.completion envelope + rune metadata",
|
||||
s == 200 and body.get("object") == "chat.completion"
|
||||
and choices[0].get("message", {}).get("role") == "assistant"
|
||||
and "Canned reply" in choices[0].get("message", {}).get("content", "")
|
||||
and body.get("rune", {}).get("task_id") == "task-canned-001"
|
||||
and body.get("rune", {}).get("echo") == "Say the canned thing.",
|
||||
f"status={s} body={raw[:200]!r}")
|
||||
|
||||
s, raw = results["stream"]
|
||||
lines = [l for l in raw.strip().splitlines() if l.strip()]
|
||||
parsed = []
|
||||
for l in lines:
|
||||
try:
|
||||
parsed.append(json.loads(l))
|
||||
except json.JSONDecodeError:
|
||||
parsed.append(None)
|
||||
test(f"{prefix}: NDJSON stream — chunk(s) then terminal",
|
||||
s == 200 and len(parsed) >= 2 and None not in parsed
|
||||
and all(p.get("object") == "chat.completion.chunk" for p in parsed)
|
||||
and parsed[-1].get("choices", [{}])[0].get("finish_reason") == "stop"
|
||||
and "Canned reply" in (parsed[0].get("choices", [{}])[0]
|
||||
.get("delta", {}).get("content", "")),
|
||||
f"status={s} lines={len(lines)} raw={raw[:200]!r}")
|
||||
|
||||
s, raw = results["conversation"]
|
||||
body = json.loads(raw) if s == 200 else {}
|
||||
test(f"{prefix}: /v1/conversation window shape",
|
||||
s == 200 and isinstance(body.get("messages"), list)
|
||||
and body["messages"][0]["role"] == "user",
|
||||
f"status={s} body={raw[:160]!r}")
|
||||
|
||||
s, raw = results["approve"]
|
||||
body = json.loads(raw) if s == 200 else {}
|
||||
test(f"{prefix}: /v1/approvals/resolve round-trip",
|
||||
s == 200 and body.get("resolved") is True,
|
||||
f"status={s} body={raw[:160]!r}")
|
||||
|
||||
s, raw = results["missing"]
|
||||
body = json.loads(raw) if raw else {}
|
||||
test(f"{prefix}: 404 error envelope",
|
||||
s == 404 and body.get("error", {}).get("code") == "not_found",
|
||||
f"status={s} body={raw[:160]!r}")
|
||||
|
||||
|
||||
def compare_results(py: dict, go: dict) -> None:
|
||||
for key in ("models", "status", "chat", "conversation", "approve", "missing"):
|
||||
(ps, praw), (gs, graw) = py[key], go[key]
|
||||
same_status = ps == gs
|
||||
try:
|
||||
same_body = normalize(json.loads(praw)) == normalize(json.loads(graw))
|
||||
except json.JSONDecodeError:
|
||||
same_body = praw == graw
|
||||
test(f"parity: {key} identical modulo volatile",
|
||||
same_status and same_body,
|
||||
f"py=({ps}) {praw[:140]!r} go=({gs}) {graw[:140]!r}")
|
||||
# NDJSON: exact line-sequence equality modulo volatile
|
||||
(ps, praw), (gs, graw) = py["stream"], go["stream"]
|
||||
pl = [normalize(json.loads(l)) for l in praw.strip().splitlines() if l.strip()]
|
||||
gl = [normalize(json.loads(l)) for l in graw.strip().splitlines() if l.strip()]
|
||||
test("parity: NDJSON line sequences identical modulo volatile",
|
||||
ps == gs and pl == gl,
|
||||
f"py_lines={len(pl)} go_lines={len(gl)}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 47)
|
||||
print("canary_rune_serve — H2 Go front-door parity battery")
|
||||
print("=" * 47)
|
||||
|
||||
# ── 1. Python core server boots ───────────────────────────────────────
|
||||
print("\n--- 1. Python core server ---")
|
||||
try:
|
||||
from rune import core_server
|
||||
except ImportError as e:
|
||||
test("core_server imports", False, str(e))
|
||||
print(f"\nRESULTS: {PASS} pass, {FAIL} fail")
|
||||
return 1
|
||||
test("core_server imports", True)
|
||||
|
||||
server = core_server.make_server("127.0.0.1", 0, StubOperator())
|
||||
port = server.server_address[1]
|
||||
import threading
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
t.start()
|
||||
py_base = f"http://127.0.0.1:{port}"
|
||||
test("core server answers on an ephemeral port",
|
||||
wait_ready(f"{py_base}/v1/status"), py_base)
|
||||
|
||||
# ── 2. Python shape freeze ────────────────────────────────────────────
|
||||
print("\n--- 2. Python shape freeze ---")
|
||||
py_results = fire_shape_set(py_base)
|
||||
check_shapes("python", py_results)
|
||||
|
||||
# ── 3. Go serve subcommand ────────────────────────────────────────────
|
||||
print("\n--- 3. Go serve subcommand ---")
|
||||
go_dir = REPO / "src" / "go" / "rune-entry"
|
||||
env = dict(os.environ)
|
||||
env["GOFLAGS"] = env.get("GOFLAGS", "") + " -mod=mod"
|
||||
# Per-run output path: a fixed /tmp name collides cross-user (nick owns
|
||||
# the local build; the runner cannot overwrite it — run 18322 EACCES).
|
||||
go_bin = f"/tmp/rune-serve-canary-{os.getpid()}"
|
||||
r = subprocess.run(["go", "build", "-o", go_bin, "."],
|
||||
cwd=go_dir, env=env, capture_output=True, text=True, timeout=300)
|
||||
test("go build exits 0", r.returncode == 0,
|
||||
f"rc={r.returncode} err={r.stderr[-200:]!r}")
|
||||
if r.returncode != 0:
|
||||
print(f"\nRESULTS: {PASS} pass, {FAIL} fail")
|
||||
return 1
|
||||
r = subprocess.run([go_bin, "serve", "--help"],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
test("rune serve --help exits 0 with usage",
|
||||
r.returncode == 0 and "core" in (r.stdout + r.stderr).lower(),
|
||||
f"rc={r.returncode} out={(r.stdout + r.stderr)[:160]!r}")
|
||||
|
||||
# ── 4. Cross-language parity ──────────────────────────────────────────
|
||||
print("\n--- 4. Parity: Go front → Python core ---")
|
||||
go_port = port + 1
|
||||
proc = subprocess.Popen(
|
||||
[go_bin, "serve", "--port", str(go_port),
|
||||
"--core", py_base],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
go_base = f"http://127.0.0.1:{go_port}"
|
||||
try:
|
||||
ready = wait_ready(f"{go_base}/v1/status")
|
||||
test("go front answers on its port", ready, go_base)
|
||||
if ready:
|
||||
go_results = fire_shape_set(go_base)
|
||||
check_shapes("go", go_results)
|
||||
compare_results(py_results, go_results)
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
# ── 5. Core-down honesty ──────────────────────────────────────────────
|
||||
print("\n--- 5. Core-down honesty ---")
|
||||
proc = subprocess.Popen(
|
||||
[go_bin, "serve", "--port", str(go_port),
|
||||
"--core", "http://127.0.0.1:1"], # nothing listening
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
try:
|
||||
time.sleep(0.5) # listener comes up even though the core never will
|
||||
s, raw = http("POST", f"{go_base}/v1/chat/completions", CANNED_CHAT)
|
||||
body = json.loads(raw) if raw else {}
|
||||
test("go 502 envelope when the core is down",
|
||||
s == 502 and body.get("error", {}).get("code") == "core_unavailable",
|
||||
f"status={s} body={raw[:160]!r}")
|
||||
s2, raw2 = http("POST", f"{py_base}/v1/chat/completions", CANNED_CHAT)
|
||||
test("python core still healthy after go core-down probe",
|
||||
s2 == 200, f"status={s2}")
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
server.shutdown()
|
||||
print(f"\n{'=' * 47}")
|
||||
print(f"RESULTS: {PASS} pass, {FAIL} fail")
|
||||
return 1 if FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""canary_spine_meta.py — meta-scaffold canary (H-meta).
|
||||
|
||||
Contract BUILDER-2026-10-04T06:24:56Z-cli (Nick directive 2026-10-04):
|
||||
every part of rune must exist as a modeled entity in rune's spine — the
|
||||
spine is the meta-scaffold. This canary proves the projection models the
|
||||
surface classes the closures have been honestly reporting as unmodeled:
|
||||
|
||||
1. rust-crate entities for both crates (evidence_provenance, query_memory)
|
||||
2. go-surface entity for src/go/rune-entry
|
||||
3. module entities for canary_audit_ledger / canary_rune_entry /
|
||||
canary_evidence_provenance_rust (with obligated_canary edges)
|
||||
4. workflow entities for every .gitea/workflows/*.yml
|
||||
5. config entities for the tranche config files
|
||||
6. determinism: two consecutive builds differ only in generated_at
|
||||
7. open-world law: every pre-existing entity survives the rebuild
|
||||
|
||||
PASS/FAIL counter harness, exit 1 on any FAIL. Expectations observed from
|
||||
the spine's own projection rules (src/rune/spine.py) and the umbrella
|
||||
manifest — never invented.
|
||||
|
||||
Run under ~/.venvs/memory/bin/python3.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
REPO = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
|
||||
SPINE_STORE = os.path.join(REPO, "store", "spine", "current.json")
|
||||
|
||||
|
||||
def test(name, cond, detail=""):
|
||||
global PASS, FAIL
|
||||
mark = "✓" if cond else "✗"
|
||||
print(f" {mark} {name}" + (f" — {detail}" if detail and not cond else ""))
|
||||
if cond:
|
||||
PASS += 1
|
||||
else:
|
||||
FAIL += 1
|
||||
|
||||
|
||||
def build_spine():
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-m", "rune.spine", "build"],
|
||||
capture_output=True, text=True, timeout=180,
|
||||
env={"PYTHONPATH": os.path.join(REPO, "src"), "PATH": os.environ.get("PATH", ""),
|
||||
"HOME": os.environ.get("HOME", "")},
|
||||
cwd=REPO)
|
||||
return r
|
||||
|
||||
|
||||
def load_spine():
|
||||
return json.load(open(SPINE_STORE))
|
||||
|
||||
|
||||
def main():
|
||||
print("=== SPINE META-SCAFFOLD CANARY (H-meta) ===\n")
|
||||
pre = load_spine() if os.path.isfile(SPINE_STORE) else {"entities": []}
|
||||
pre_ids = {e["id"] for e in pre["entities"]}
|
||||
|
||||
# --- 0. Build ---
|
||||
print("--- 0. Build ---")
|
||||
r = build_spine()
|
||||
test("spine build exits 0", r.returncode == 0, f"rc={r.returncode} err={r.stderr[-200:]!r}")
|
||||
if r.returncode != 0:
|
||||
print(f"\nRESULTS: {PASS} pass, {FAIL} fail")
|
||||
sys.exit(1)
|
||||
doc = load_spine()
|
||||
ents = {e["id"]: e for e in doc["entities"]}
|
||||
rels = doc.get("relationships", [])
|
||||
|
||||
# --- 1. Rust crates ---
|
||||
print("\n--- 1. Rust crates ---")
|
||||
for crate in ("evidence_provenance", "query_memory"):
|
||||
eid = f"rust-crate:{crate}"
|
||||
test(f"{eid} exists with kind rust-crate",
|
||||
eid in ents and ents[eid]["kind"] == "rust-crate",
|
||||
f"ids matching: {[i for i in ents if crate in i][:4]}")
|
||||
|
||||
# --- 2. Go surface ---
|
||||
print("\n--- 2. Go surface ---")
|
||||
test("go-surface:rune-entry exists with kind go-surface",
|
||||
"go-surface:rune-entry" in ents
|
||||
and ents["go-surface:rune-entry"]["kind"] == "go-surface",
|
||||
f"ids matching: {[i for i in ents if 'rune-entry' in i][:4]}")
|
||||
|
||||
# --- 3. Missing canaries modeled ---
|
||||
print("\n--- 3. Canaries ---")
|
||||
for canary in ("canary_audit_ledger", "canary_rune_entry",
|
||||
"canary_evidence_provenance_rust"):
|
||||
mid = f"module:{canary}"
|
||||
test(f"{mid} entity exists", mid in ents,
|
||||
f"present: {mid in ents}")
|
||||
edge = any(r["type"] == "obligated_canary" and r["to"] == mid for r in rels)
|
||||
test(f"{mid} has an obligated_canary edge from its umbrella", edge)
|
||||
|
||||
# --- 4. Workflows ---
|
||||
print("\n--- 4. Workflows ---")
|
||||
import glob
|
||||
wf_files = sorted(os.path.basename(p)
|
||||
for p in glob.glob(os.path.join(REPO, ".gitea", "workflows", "*.yml")))
|
||||
test("workflow inventory is non-empty", len(wf_files) >= 10, f"found {len(wf_files)}")
|
||||
missing = [w for w in wf_files if f"workflow:{w}" not in ents]
|
||||
test("every workflow file has a workflow entity", not missing,
|
||||
f"missing: {missing[:4]}")
|
||||
|
||||
# --- 5. Configs ---
|
||||
print("\n--- 5. Configs ---")
|
||||
for cfg in ("go-version.txt", "lint-baseline.json", "pyright-baseline.json",
|
||||
".coveragerc"):
|
||||
cid = f"config:{cfg}"
|
||||
test(f"{cid} entity exists with kind config",
|
||||
cid in ents and ents[cid]["kind"] == "config",
|
||||
f"present: {cid in ents}")
|
||||
|
||||
# --- 6. Determinism (steady-state pair) ---
|
||||
# The honest determinism contract: once the store already carries the
|
||||
# projector's facts, two consecutive builds with identical inputs must
|
||||
# differ only in generated_at. A MIGRATION pair (store predating the
|
||||
# projector vs store carrying it) is not a determinism failure — the
|
||||
# first build after any projector change legitimately rewrites state.
|
||||
# The warm-up build makes this check judge steady-state, not migration.
|
||||
print("\n--- 6. Determinism (steady-state) ---")
|
||||
warm = build_spine()
|
||||
test("warm-up build exits 0", warm.returncode == 0,
|
||||
f"rc={warm.returncode} err={warm.stderr[-160:]!r}")
|
||||
r = build_spine()
|
||||
first = load_spine()
|
||||
r = build_spine()
|
||||
second = load_spine()
|
||||
def strip_ts(d):
|
||||
"""Deep-strip time-varying fields: determinism is a STRUCTURAL
|
||||
contract (same entities/relationships/observation content), not a
|
||||
clock contract. observed_at/deployed_at/generated_at tick every
|
||||
build by design — comparing them made this check pass or fail on
|
||||
whether the two builds crossed a one-second boundary."""
|
||||
TIME_KEYS = {"generated_at", "observed_at", "deployed_at", "updated_at",
|
||||
"last_success"}
|
||||
def walk(x):
|
||||
if isinstance(x, dict):
|
||||
return {k: walk(v) for k, v in x.items() if k not in TIME_KEYS}
|
||||
if isinstance(x, list):
|
||||
return [walk(v) for v in x]
|
||||
return x
|
||||
def normalize(d):
|
||||
d = walk(d)
|
||||
# build() appends one deployed_identity observation per pass and
|
||||
# the store dedups on the FULL observation (timestamp included),
|
||||
# so the observations list grows by one identical-modulo-timestamp
|
||||
# entry per build. Structural content = the deduped set.
|
||||
seen, obs = set(), []
|
||||
for o in d.get("observations", []):
|
||||
key = json.dumps(o, sort_keys=True)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
obs.append(o)
|
||||
if "observations" in d:
|
||||
d["observations"] = obs
|
||||
return d
|
||||
return normalize(json.loads(json.dumps(d)))
|
||||
fa, fb = strip_ts(first), strip_ts(second)
|
||||
if fa != fb:
|
||||
# Diagnostic: write the stripped pair so the divergence is inspectable
|
||||
# instead of a bare boolean verdict.
|
||||
with open("/tmp/spine_meta_determinism_first.json", "w") as f:
|
||||
json.dump(fa, f, indent=2, sort_keys=True)
|
||||
with open("/tmp/spine_meta_determinism_second.json", "w") as f:
|
||||
json.dump(fb, f, indent=2, sort_keys=True)
|
||||
test("two consecutive steady-state builds are structurally identical "
|
||||
"(timestamps free to tick)",
|
||||
fa == fb,
|
||||
"rebuild not structurally stable "
|
||||
"(stripped pair dumped to /tmp/spine_meta_determinism_*.json)")
|
||||
|
||||
# --- 7. Open-world law ---
|
||||
print("\n--- 7. Open-world law ---")
|
||||
lost = pre_ids - {e["id"] for e in doc["entities"]}
|
||||
test("every pre-existing entity survives the rebuild", not lost,
|
||||
f"lost: {sorted(lost)[:4]}")
|
||||
|
||||
print(f"\n{'=' * 47}")
|
||||
print(f"RESULTS: {PASS} pass, {FAIL} fail")
|
||||
sys.exit(1 if FAIL else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""canary_tui_transport.py — H4 slice 1 battery (T4.5, contract
|
||||
BUILDER-2026-10-04T10:32:00Z-cli).
|
||||
|
||||
The TUI transport seam contract: InProcessTransport and V1Transport answer
|
||||
the TUI's four-method call set EQUIVALENTLY against the same stub core —
|
||||
V1 through the real HTTP stack (core_server), InProcess against the stub
|
||||
operator directly. And the default is inprocess: the seam changes nothing
|
||||
until RUNE_TUI_TRANSPORT=v1 opts in.
|
||||
|
||||
Sections:
|
||||
1. /v1 surface carries the TUI endpoints (conversation + approvals)
|
||||
2. transport factory defaults to inprocess (zero behavior change)
|
||||
3. four-method equivalence (send_task, get_status,
|
||||
get_conversation_window, resolve_pending_approval)
|
||||
4. approval boolean round-trip both ways (True and False)
|
||||
5. V1 transport against a dead core degrades loudly, never silently
|
||||
|
||||
Run: python3 src/rune/canary_tui_transport.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
|
||||
def test(name: str, ok: bool, detail: str = "") -> None:
|
||||
global PASS, FAIL
|
||||
if ok:
|
||||
PASS += 1
|
||||
print(f" ✓ {name}")
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f" ✗ {name} — {detail}")
|
||||
|
||||
|
||||
class StubOperator:
|
||||
"""The TUI call set, canned."""
|
||||
|
||||
def get_status(self):
|
||||
return {"identity": "rune", "role": "operator", "current_task": None,
|
||||
"active_model": "stub-model", "tasks_pending": 1,
|
||||
"tasks_running": 0, "tasks_completed": 2}
|
||||
|
||||
def send_task(self, description, model=None, channel="operator"):
|
||||
return {"task_id": "task-stub-1", "status": "completed",
|
||||
"user_text": f"stub answer to: {description}",
|
||||
"model": model or "stub-model", "success": True,
|
||||
"degraded": []}
|
||||
|
||||
def conversation_window(self, channel="operator", n=8):
|
||||
return [{"role": "user", "content": "earlier question",
|
||||
"channel_id": channel},
|
||||
{"role": "assistant", "content": "earlier answer",
|
||||
"channel_id": channel}][:n]
|
||||
|
||||
def resolve_pending_approval(self, approved):
|
||||
return {"resolved": bool(approved), "approval_id": "appr-stub-1"}
|
||||
|
||||
|
||||
def http(method: str, url: str, body: dict | None = None):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return resp.status, resp.read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 47)
|
||||
print("canary_tui_transport — H4 slice 1 transport seam battery")
|
||||
print("=" * 47)
|
||||
|
||||
from rune import core_server # noqa: E402
|
||||
|
||||
server = core_server.make_server("127.0.0.1", 0, StubOperator())
|
||||
port = server.server_address[1]
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
t.start()
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
|
||||
# ── 1. /v1 surface carries the TUI endpoints ──────────────────────────
|
||||
print("\n--- 1. TUI endpoints on the /v1 surface ---")
|
||||
s, raw = http("GET", f"{base}/v1/conversation?channel=operator&n=8")
|
||||
body = json.loads(raw) if s == 200 else {}
|
||||
test("GET /v1/conversation returns the window",
|
||||
s == 200 and isinstance(body.get("messages"), list)
|
||||
and body["messages"][0]["role"] == "user",
|
||||
f"status={s} body={raw[:140]!r}")
|
||||
|
||||
s, raw = http("POST", f"{base}/v1/approvals/resolve", {"approved": True})
|
||||
body = json.loads(raw) if s == 200 else {}
|
||||
test("POST /v1/approvals/resolve round-trips True",
|
||||
s == 200 and body.get("resolved") is True,
|
||||
f"status={s} body={raw[:140]!r}")
|
||||
|
||||
# ── 2. factory default ────────────────────────────────────────────────
|
||||
print("\n--- 2. Factory default ---")
|
||||
from rune import tui_transport # noqa: E402
|
||||
|
||||
os.environ.pop("RUNE_TUI_TRANSPORT", None)
|
||||
default_transport = tui_transport.get_transport()
|
||||
test("default transport is in-process (zero behavior change)",
|
||||
isinstance(default_transport, tui_transport.InProcessTransport),
|
||||
type(default_transport).__name__)
|
||||
|
||||
# ── 3. four-method equivalence ────────────────────────────────────────
|
||||
print("\n--- 3. Four-method equivalence ---")
|
||||
inproc = tui_transport.InProcessTransport(operator=StubOperator())
|
||||
v1 = tui_transport.V1Transport(base)
|
||||
|
||||
py_status = inproc.get_status()
|
||||
v1_status = v1.get_status()
|
||||
test("get_status equivalent", py_status == v1_status,
|
||||
f"inproc={py_status!r} v1={v1_status!r}")
|
||||
|
||||
py_task = inproc.send_task("the canned question", model="stub-model",
|
||||
channel="operator")
|
||||
v1_task = v1.send_task("the canned question", model="stub-model",
|
||||
channel="operator")
|
||||
test("send_task equivalent (rune block carried)",
|
||||
py_task == v1_task,
|
||||
f"inproc={py_task!r}\n v1={v1_task!r}")
|
||||
|
||||
py_win = inproc.get_conversation_window("operator", n=8)
|
||||
v1_win = v1.get_conversation_window("operator", n=8)
|
||||
test("get_conversation_window equivalent (list shape preserved)",
|
||||
py_win == v1_win and isinstance(v1_win, list),
|
||||
f"inproc={py_win!r} v1={v1_win!r}")
|
||||
|
||||
# ── 4. approval boolean round-trips ───────────────────────────────────
|
||||
print("\n--- 4. Approval round-trips ---")
|
||||
for value in (True, False):
|
||||
py_r = inproc.resolve_pending_approval(value)
|
||||
v1_r = v1.resolve_pending_approval(value)
|
||||
test(f"resolve_pending_approval({value}) equivalent",
|
||||
py_r == v1_r and v1_r.get("resolved") is value,
|
||||
f"inproc={py_r!r} v1={v1_r!r}")
|
||||
|
||||
# ── 5. dead core degrades loudly ──────────────────────────────────────
|
||||
print("\n--- 5. Dead core honesty ---")
|
||||
dead = tui_transport.V1Transport("http://127.0.0.1:1")
|
||||
try:
|
||||
dead.get_status()
|
||||
test("dead core raises (never a silent empty status)", False,
|
||||
"returned without error")
|
||||
except Exception as e: # noqa: BLE001 — the battery asserts the class, not the subtype
|
||||
test("dead core raises (never a silent empty status)", True,
|
||||
type(e).__name__)
|
||||
|
||||
server.shutdown()
|
||||
print(f"\n{'=' * 47}")
|
||||
print(f"RESULTS: {PASS} pass, {FAIL} fail")
|
||||
return 1 if FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -586,8 +586,33 @@ def build_cards():
|
||||
return cards
|
||||
|
||||
|
||||
def _cs_core():
|
||||
"""The Rust capability_scoring core when available (strangler: Python
|
||||
fallback is load-bearing). RUNE_CAPABILITY_CORE=python pins the fallback."""
|
||||
if os.environ.get("RUNE_CAPABILITY_CORE", "auto") == "python":
|
||||
return None
|
||||
try:
|
||||
from rune import cs_loader
|
||||
except Exception: # noqa: BLE001 — any loader failure means fallback, never a broken call
|
||||
return None
|
||||
try:
|
||||
return cs_loader if cs_loader.available() else None
|
||||
except Exception: # noqa: BLE001 — availability probe must never crash the fallback
|
||||
return None
|
||||
|
||||
|
||||
def score_card(card, query):
|
||||
"""Score a capability card against a query. Deterministic keyword matching."""
|
||||
"""Score a capability card against a query. Deterministic keyword matching.
|
||||
|
||||
H3 slice 4: delegates to the Rust core when built (Python fallback)."""
|
||||
core = _cs_core()
|
||||
if core is not None:
|
||||
return core.score_card(card, query)
|
||||
return _py_score_card(card, query)
|
||||
|
||||
|
||||
def _py_score_card(card, query):
|
||||
"""Python core of score_card (parity oracle for the Rust core)."""
|
||||
query_low = query.lower()
|
||||
query_words = set(re.findall(r"\w+", query_low))
|
||||
|
||||
|
||||
@@ -211,10 +211,36 @@ def discover_candidates(doc: dict, req: CapabilityRequirement) -> list[dict]:
|
||||
# ── fit evaluation ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _cs_core():
|
||||
"""The Rust capability_scoring core when available (strangler: Python
|
||||
fallback is load-bearing). RUNE_CAPABILITY_CORE=python pins the fallback."""
|
||||
import os
|
||||
if os.environ.get("RUNE_CAPABILITY_CORE", "auto") == "python":
|
||||
return None
|
||||
try:
|
||||
from rune import cs_loader
|
||||
except Exception: # noqa: BLE001 — any loader failure means fallback, never a broken call
|
||||
return None
|
||||
try:
|
||||
return cs_loader if cs_loader.available() else None
|
||||
except Exception: # noqa: BLE001 — availability probe must never crash the fallback
|
||||
return None
|
||||
|
||||
|
||||
def evaluate_fit(req: CapabilityRequirement, candidate: dict) -> dict:
|
||||
"""DETERMINISTIC fit of one candidate against a requirement.
|
||||
fit: EXACT | PARTIAL | INCOMPATIBLE | UNKNOWN.
|
||||
Names are ignored; contracts + environment + health decide."""
|
||||
Names are ignored; contracts + environment + health decide.
|
||||
|
||||
H3 slice 4: delegates to the Rust core when built (Python fallback)."""
|
||||
core = _cs_core()
|
||||
if core is not None:
|
||||
return core.evaluate_fit(req.contracts, candidate)
|
||||
return _py_evaluate_fit(req, candidate)
|
||||
|
||||
|
||||
def _py_evaluate_fit(req: CapabilityRequirement, candidate: dict) -> dict:
|
||||
"""Python core of evaluate_fit (parity oracle for the Rust core)."""
|
||||
missing_contracts = sorted(set(req.contracts) - set(candidate["provides"]))
|
||||
covered = len(req.contracts) - len(missing_contracts)
|
||||
|
||||
|
||||
+81
-28
@@ -34,16 +34,38 @@ Usage:
|
||||
claim_audit.py verify "<claim>" # ledger lookup
|
||||
claim_audit.py audit "<draft text>" # full pipeline, JSON
|
||||
claim_audit.py report # claimed-vs-verified stats
|
||||
|
||||
Phase G strangler slice 2 (port 6 — FINAL): the decision skeleton (verify
|
||||
verdict tree, report aggregation, clean fold, evidence-term stoplist) is
|
||||
served IN-PROCESS by the Rust `evidence_provenance` cdylib through
|
||||
ep_loader.py — parity 17/17 proven vs this module's Python vintage. The
|
||||
regex engine stays Python (zero-dep crate law): PATTERNS scan + sentence
|
||||
extraction + degrade templates + reframe are regex machinery. I/O (journal)
|
||||
stays Python. Timestamps pinned by the shim. API/CLI/PLUGIN unchanged.
|
||||
|
||||
VINTAGE MERGE (disclosed on #2449): the deployed scripts vintage carried the
|
||||
SYMMETRY GUARD (research_lens.symmetry_check integration, 2026-08-15) which
|
||||
src/rune's vintage lacked — preserved here (feature-preserving merge; the
|
||||
guard is load-bearing behavior). Also REPAIRED: the TUI's `scan_text()`
|
||||
consumer (rune_tui.py:3752, expecting {degraded: [...], claims: [...]}) had
|
||||
NO implementation in EITHER vintage (silent try/except death since the TUI
|
||||
call landed) — scan_text() is provided now as a first-class surface.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# ── Rust core wiring (slice-2 port-6 shim pattern) ──────────────────────────
|
||||
_CRATE_DIR = "/mnt/workdrive/rune/src/rust/evidence_provenance"
|
||||
if _CRATE_DIR not in sys.path:
|
||||
sys.path.insert(0, _CRATE_DIR)
|
||||
import ep_loader # noqa: E402 (loads target/release cdylib)
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import memory_env
|
||||
|
||||
MEM_DIR = memory_env.memory_dir()
|
||||
@@ -54,7 +76,7 @@ _SD = os.path.dirname(os.path.abspath(__file__))
|
||||
if _SD not in sys.path:
|
||||
sys.path.insert(0, _SD)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = __import__("logging").getLogger(__name__)
|
||||
|
||||
# Lazy import: warm_neuron_store is imported inside functions that use it
|
||||
# to avoid crashing the entire module on import if the dependency is missing.
|
||||
@@ -75,10 +97,7 @@ def _get_warm_neuron_store():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strong-claim patterns. Grouped by type so we can degrade per-type on
|
||||
# repeated failure. `comparative` is the highest-priority (the "we exceed X"
|
||||
# class). Regexes are deliberately conservative: they flag CLAIM SHAPES, not
|
||||
# innocent uses of words.
|
||||
# Strong-claim patterns (unchanged — regex machinery stays Python).
|
||||
# ---------------------------------------------------------------------------
|
||||
PATTERNS = [
|
||||
{
|
||||
@@ -184,6 +203,22 @@ def scan(text):
|
||||
return claims
|
||||
|
||||
|
||||
def scan_text(text):
|
||||
"""TUI consumer surface (rune_tui.py:3752): run the pipeline over a text
|
||||
snippet and return {degraded: [...], claims: [...], clean: bool}.
|
||||
|
||||
REPAIRED: neither vintage implemented this — the TUI call died silently
|
||||
in try/except since it landed. Semantics: audit the text, list degraded
|
||||
claims (verdict != PASS), surface them to the TUI.
|
||||
"""
|
||||
if not text:
|
||||
return {"degraded": [], "claims": [], "clean": True}
|
||||
res = audit(text)
|
||||
degraded = [r for r in res.get("claims", []) if r.get("verdict") != "PASS"]
|
||||
return {"degraded": degraded, "claims": res.get("claims", []),
|
||||
"clean": res.get("clean", True), "rewritten": res.get("rewritten", "")}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Evidence-ledger lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -202,7 +237,7 @@ def _graph_has(term):
|
||||
return row is not None
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
except Exception: # noqa: BLE001 — ledger lookup degrades to no-evidence by design; a broken graph must not crash claim verification
|
||||
return False
|
||||
|
||||
|
||||
@@ -214,7 +249,7 @@ def _warm_has(term):
|
||||
if wns is None:
|
||||
return False
|
||||
return wns.neuron_has(term)
|
||||
except Exception:
|
||||
except Exception: # noqa: BLE001 — warm-block lookup degrades to no-evidence by design; a broken store must not crash claim verification
|
||||
return False
|
||||
|
||||
|
||||
@@ -231,6 +266,9 @@ def verify(claim):
|
||||
it. Those claims only PASS if they carry a measurable anchor (a number, a
|
||||
probe run, a benchmark), because that's the kind of claim that needs both
|
||||
a baseline and a measurement (per the claim-audit research).
|
||||
|
||||
Decision tree served by the Rust core (parity-proven); the regex flag
|
||||
derivation + ledger lookups stay Python.
|
||||
"""
|
||||
low = claim.lower()
|
||||
is_comparative = bool(
|
||||
@@ -248,19 +286,16 @@ def verify(claim):
|
||||
low,
|
||||
)
|
||||
)
|
||||
# Comparative claims need a MEASURABLE anchor, not just a mention.
|
||||
if is_comparative and not is_quantified:
|
||||
return "DEGRADE", 0
|
||||
# Extract candidate ledger terms: the subject noun phrase.
|
||||
# Candidate ledger terms: filtered by the Rust stoplist/length rule.
|
||||
terms = re.findall(r"\b([A-Za-z][A-Za-z ]{3,30})\b", claim)
|
||||
terms = ep_loader.ca_evidence_terms(terms)
|
||||
hits = 0
|
||||
for t in terms:
|
||||
if len(t) < 4 or t.lower() in ("this is", "that is", "for the"):
|
||||
continue
|
||||
if _graph_has(t) or _warm_has(t):
|
||||
hits += 1
|
||||
if hits >= 1:
|
||||
return "PASS", hits
|
||||
gate = ep_loader.ca_verify_gate(is_comparative, is_quantified, hits, True)
|
||||
if gate["verdict"] == "PASS":
|
||||
return "PASS", gate["evidence_hits"]
|
||||
if re.search(r"\b(exceed|surpass|beat|beyond)\b", low):
|
||||
return "DEGRADE", 0 # comparative with no ledger support = the exact case
|
||||
return "DEGRADE", 0
|
||||
@@ -322,11 +357,7 @@ def report():
|
||||
return {"total": 0, "by_verdict": {}, "by_type": {}}
|
||||
with open(JOURNAL) as f:
|
||||
rows = [json.loads(l) for l in f if l.strip()]
|
||||
by_verdict, by_type = {}, {}
|
||||
for r in rows:
|
||||
by_verdict[r.get("verdict", "?")] = by_verdict.get(r.get("verdict", "?"), 0) + 1
|
||||
by_type[r.get("type", "?")] = by_type.get(r.get("type", "?"), 0) + 1
|
||||
return {"total": len(rows), "by_verdict": by_verdict, "by_type": by_type}
|
||||
return ep_loader.ca_report_aggregate(rows)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -336,6 +367,19 @@ def audit(draft):
|
||||
"""Run the full active gate over a draft. Returns verdicts + rewritten text."""
|
||||
claims = scan(draft)
|
||||
if not claims:
|
||||
# SYMMETRY GUARD (deployed-vintage feature, preserved in the merge):
|
||||
# even with no strong claim, check the draft for asymmetric-skepticism
|
||||
# markers so the baloney detector cannot be applied one-sidedly.
|
||||
try:
|
||||
sys.path.insert(0, _SD)
|
||||
from research_lens import symmetry_check
|
||||
sym = symmetry_check(draft)
|
||||
if sym.get("verdict") == "ASYMMETRY-FLAG":
|
||||
return {"claims": [], "rewritten": draft, "clean": False,
|
||||
"symmetry": sym}
|
||||
except Exception: # noqa: BLE001 — guard is never-fatal by design (deployed-vintage discipline); audit output degrades to no-symmetry, not silence
|
||||
logger.debug("symmetry_check unavailable/failed — continuing without symmetry", exc_info=True)
|
||||
pass
|
||||
return {"claims": [], "rewritten": draft, "clean": True}
|
||||
rewritten = draft
|
||||
results = []
|
||||
@@ -357,9 +401,23 @@ def audit(draft):
|
||||
"rewrite": new_text,
|
||||
"note": note,
|
||||
}
|
||||
# SYMMETRY GUARD per-claim (deployed-vintage feature, preserved):
|
||||
result["symmetry"] = _symmetry_safe(c["claim"])
|
||||
results.append(result)
|
||||
journal({"type": c["type"], "claim": c["claim"][:160], "verdict": verdict, "evidence_hits": hits})
|
||||
return {"claims": results, "rewritten": rewritten, "clean": all(r["verdict"] == "PASS" for r in results)}
|
||||
return {"claims": results, "rewritten": rewritten,
|
||||
"clean": ep_loader.ca_audit_clean(results)}
|
||||
|
||||
|
||||
def _symmetry_safe(text):
|
||||
"""symmetry_check wrapper — never fatal (deployed-vintage discipline)."""
|
||||
try:
|
||||
from research_lens import symmetry_check
|
||||
return symmetry_check(text)
|
||||
except Exception: # noqa: BLE001 — wrapper is never-fatal by design; a symmetry outage must not kill claim auditing
|
||||
logger.debug("symmetry_check unavailable/failed — no symmetry annotation", exc_info=True)
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def handle_hook(event, data=None):
|
||||
@@ -412,11 +470,6 @@ if __name__ == "__main__":
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin declaration — self-registers with the plugin registry.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hook handler — called by the microkernel when an event fires.
|
||||
# ---------------------------------------------------------------------------
|
||||
PLUGIN = {
|
||||
"name": "claim_audit",
|
||||
"version": "1.0.0",
|
||||
@@ -427,4 +480,4 @@ PLUGIN = {
|
||||
"provides": ["claim_audit"],
|
||||
"priority": 40,
|
||||
"auto_activate": True,
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,14 @@ _SURFACE_UI = "ui:"
|
||||
_SURFACE_REGION = "region:"
|
||||
_SURFACE_CONTRACT = "contract:"
|
||||
_SURFACE_MODULE = "module:"
|
||||
# H-meta entity classes (spine meta-scaffold, 567fd39): the spine models
|
||||
# these kinds as first-class entities, so contract scope resolution must
|
||||
# recognize them as structural ids too — an unrecognized kind can never
|
||||
# close as correctly_predicted (T4.7 row-1 flag).
|
||||
_SURFACE_RUST_CRATE = "rust-crate:"
|
||||
_SURFACE_GO_SURFACE = "go-surface:"
|
||||
_SURFACE_CONFIG = "config:"
|
||||
_SURFACE_WORKFLOW = "workflow:"
|
||||
|
||||
|
||||
def classify_surface(surface: str) -> str:
|
||||
@@ -141,6 +149,10 @@ def classify_surface(surface: str) -> str:
|
||||
_SURFACE_CONTRACT,
|
||||
_SURFACE_MODULE,
|
||||
_SURFACE_CAPABILITY,
|
||||
_SURFACE_RUST_CRATE,
|
||||
_SURFACE_GO_SURFACE,
|
||||
_SURFACE_CONFIG,
|
||||
_SURFACE_WORKFLOW,
|
||||
)
|
||||
):
|
||||
return "structural_entity"
|
||||
|
||||
@@ -181,9 +181,16 @@ class ConstructionInterface:
|
||||
if contract_id:
|
||||
target = self.scaffold.contract(doc, contract_id)
|
||||
else:
|
||||
# most recent non-closed contract = the active construction
|
||||
# Most recent ACTIVE contract = status open AND phase non-terminal.
|
||||
# T22 repair: a contract whose phase is terminal (abandoned/closed
|
||||
# — see scaffold.TERMINAL_PHASES) is NOT active construction, even
|
||||
# if its status field was left "open". Without the phase test the
|
||||
# projection reported stale abandoned contracts as the active
|
||||
# construction (8 such records observed in the live store).
|
||||
from .scaffold import TERMINAL_PHASES
|
||||
open_contracts = [e for e in contracts
|
||||
if e.get("status") == "open"]
|
||||
if e.get("status") == "open"
|
||||
and e.get("phase") not in TERMINAL_PHASES]
|
||||
target = (sorted(open_contracts,
|
||||
key=lambda e: e.get("observed_at", ""))[-1]
|
||||
if open_contracts else None)
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""core_server.py — stdlib HTTP sidecar exposing the Rune operator surface
|
||||
(H2, contract BUILDER-2026-10-04T08:32:43Z-cli).
|
||||
|
||||
Go owns the front door (rune serve); this is the core behind it during the
|
||||
transition — the same operator functions the TUI calls in-process, served
|
||||
over 127.0.0.1 with NDJSON streaming. The parity battery
|
||||
(canary_rune_serve.py) freezes these shapes against the Go front.
|
||||
|
||||
Endpoints:
|
||||
GET /v1/models {"object": "list", "data": [{id, object, owned_by}]}
|
||||
GET /v1/status operator.get_status() pass-through
|
||||
POST /v1/chat/completions OpenAI envelope + "rune" task metadata;
|
||||
stream=true → NDJSON chat.completion.chunk lines
|
||||
|
||||
Error envelope (every failure path): {"error": {"code", "message"}} with a
|
||||
matching HTTP status — the Go front reproduces these byte-for-byte.
|
||||
|
||||
Stdlib only (zero-dep split law). The operator is injected, so the canary
|
||||
drives a deterministic stub; main() binds the real operator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any, cast
|
||||
|
||||
CHAT_PATH = "/v1/chat/completions"
|
||||
|
||||
|
||||
def _envelope_error(code: str, message: str) -> dict:
|
||||
return {"error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
def models_view(operator: Any) -> dict:
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": m.get("name", ""), "object": "model", "owned_by": "rune"}
|
||||
for m in operator.list_models()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def chat_completion(result: dict, model: str) -> dict:
|
||||
"""Map an operator send_task result to the chat.completion envelope.
|
||||
The full operator result rides under "rune" — the front door's parity
|
||||
contract covers it; nothing the core knows is dropped."""
|
||||
return {
|
||||
"id": f"chatcmpl-{uuid.uuid4().hex[:24]}",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": result.get("model") or model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant",
|
||||
"content": result.get("user_text", "")},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
"rune": result,
|
||||
}
|
||||
|
||||
|
||||
def chat_chunks(result: dict, model: str) -> list[dict]:
|
||||
"""NDJSON chunk sequence: content chunk(s) then a terminal chunk with
|
||||
finish_reason + the rune metadata block. send_task is a blocking
|
||||
lifecycle today — the framing is the contract being proven; token
|
||||
streaming slots in when the core grows it."""
|
||||
base = {
|
||||
"id": f"chatcmpl-{uuid.uuid4().hex[:24]}",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": result.get("model") or model,
|
||||
}
|
||||
content = {
|
||||
**base,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {"role": "assistant",
|
||||
"content": result.get("user_text", "")},
|
||||
"finish_reason": None,
|
||||
}],
|
||||
}
|
||||
terminal = {
|
||||
**base,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
"rune": result,
|
||||
}
|
||||
return [content, terminal]
|
||||
|
||||
|
||||
class CoreHandler(BaseHTTPRequestHandler):
|
||||
# BaseRequestHandler.server is typed as the generic base; the only class
|
||||
# this handler ever serves under is CoreServer (make_server). cast() is
|
||||
# the honest narrowing — no ignore comments.
|
||||
@property
|
||||
def core(self) -> "CoreServer":
|
||||
return cast("CoreServer", self.server)
|
||||
|
||||
# BaseHTTPRequestHandler logs every request to stderr by default; the
|
||||
# canary and CI don't need the noise — errors surface as envelopes.
|
||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A002 — stdlib signature, the name is fixed upstream
|
||||
pass
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def _send_json(self, status: int, body: dict) -> None:
|
||||
raw = json.dumps(body).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def _send_ndjson(self, status: int, items: list[dict]) -> None:
|
||||
raw = "".join(json.dumps(i) + "\n" for i in items).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/x-ndjson")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
# ── routes ────────────────────────────────────────────────────────────
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path == "/v1/models":
|
||||
try:
|
||||
self._send_json(200, models_view(self.core.operator))
|
||||
except Exception as exc: # noqa: BLE001 — the envelope IS the error contract
|
||||
self._send_json(500, _envelope_error("internal_error", str(exc)[:200]))
|
||||
elif self.path == "/v1/status":
|
||||
try:
|
||||
self._send_json(200, self.core.operator.get_status())
|
||||
except Exception as exc: # noqa: BLE001 — the envelope IS the error contract
|
||||
self._send_json(500, _envelope_error("internal_error", str(exc)[:200]))
|
||||
elif self.path.startswith("/v1/conversation"):
|
||||
# H4 slice 1: the TUI's conversation window over the wire.
|
||||
try:
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
channel = qs.get("channel", ["operator"])[0]
|
||||
n = int(qs.get("n", ["8"])[0])
|
||||
self._send_json(200, {
|
||||
"messages": self.core.operator.conversation_window(channel, n=n)})
|
||||
except Exception as exc: # noqa: BLE001 — the envelope IS the error contract
|
||||
self._send_json(500, _envelope_error("internal_error", str(exc)[:200]))
|
||||
else:
|
||||
self._send_json(404, _envelope_error("not_found", f"no route {self.path}"))
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path == "/v1/approvals/resolve":
|
||||
# H4 slice 1: approval resolution over the wire.
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self._send_json(400, _envelope_error("bad_request", f"invalid JSON body: {exc}"))
|
||||
return
|
||||
try:
|
||||
self._send_json(200, self.core.operator.resolve_pending_approval(
|
||||
bool(body.get("approved"))))
|
||||
except Exception as exc: # noqa: BLE001 — the envelope IS the error contract
|
||||
self._send_json(500, _envelope_error("internal_error", str(exc)[:200]))
|
||||
return
|
||||
if self.path != CHAT_PATH:
|
||||
self._send_json(404, _envelope_error("not_found", f"no route {self.path}"))
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self._send_json(400, _envelope_error("bad_request", f"invalid JSON body: {exc}"))
|
||||
return
|
||||
messages = body.get("messages") or []
|
||||
user_texts = [m.get("content", "") for m in messages
|
||||
if m.get("role") == "user"]
|
||||
if not user_texts:
|
||||
self._send_json(400, _envelope_error(
|
||||
"bad_request", "messages must include a user role entry"))
|
||||
return
|
||||
model = body.get("model") or None
|
||||
try:
|
||||
result = self.core.operator.send_task(user_texts[-1], model)
|
||||
except Exception as exc: # noqa: BLE001 — the envelope IS the error contract
|
||||
self._send_json(500, _envelope_error("internal_error", str(exc)[:200]))
|
||||
return
|
||||
if body.get("stream"):
|
||||
self._send_ndjson(200, chat_chunks(result, model or ""))
|
||||
else:
|
||||
self._send_json(200, chat_completion(result, model or ""))
|
||||
|
||||
|
||||
class CoreServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, host: str, port: int, operator: Any):
|
||||
super().__init__((host, port), CoreHandler)
|
||||
self.operator = operator
|
||||
|
||||
|
||||
def make_server(host: str, port: int, operator: Any) -> CoreServer:
|
||||
"""Build the server (unstarted) — the canary's entry point."""
|
||||
return CoreServer(host, port, operator)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Rune core sidecar (H2)")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8376)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import rune_operator # noqa: E402 — late import: heavy module
|
||||
|
||||
server = make_server(args.host, args.port, rune_operator.get_operator())
|
||||
print(f"rune core server on {args.host}:{args.port}", flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ctypes loader for the Rust capability_scoring core (H3 slice 4, T4.4).
|
||||
|
||||
Contract BUILDER-2026-10-04T09:43:55Z-cli. Gives capability_fit and
|
||||
capability_broker the Rust scoring cores through the cdylib with no
|
||||
subprocess.
|
||||
|
||||
FFI contract (doc-envelope law — every call returns {"ok", "value"|"err"}):
|
||||
* cs_evaluate_fit({contracts, candidate}) -> fit dict
|
||||
* cs_score_card({card, query}) -> number
|
||||
* cs_free_string(ptr)
|
||||
|
||||
FFI segfault law: every symbol gets explicit argtypes + restype (c_void_p
|
||||
for returned char*). Zero dependencies; override the .so with CS_RUST_LIB
|
||||
(a FILE path).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[2]
|
||||
_CRATE_DIR = _REPO / "src" / "rust" / "capability_scoring"
|
||||
_LIB_PATH = Path(
|
||||
os.environ.get(
|
||||
"CS_RUST_LIB", _CRATE_DIR / "target" / "release" / "libcapability_scoring.so"
|
||||
)
|
||||
)
|
||||
|
||||
_lib = None
|
||||
|
||||
|
||||
class CapabilityScoringUnavailable(FileNotFoundError):
|
||||
"""The Rust core is not built (or CS_RUST_LIB points nowhere). Callers
|
||||
fall back to the Python implementation — strangler law."""
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
return _LIB_PATH.is_file()
|
||||
|
||||
|
||||
def _load() -> ctypes.CDLL:
|
||||
global _lib
|
||||
if _lib is not None:
|
||||
return _lib
|
||||
if not _LIB_PATH.exists():
|
||||
raise CapabilityScoringUnavailable(
|
||||
f"Rust capability_scoring core not built: {_LIB_PATH} missing — run "
|
||||
f"`cargo build --release` in {_CRATE_DIR}"
|
||||
)
|
||||
lib = ctypes.CDLL(str(_LIB_PATH))
|
||||
lib.cs_free_string.argtypes = [ctypes.c_void_p]
|
||||
lib.cs_free_string.restype = None
|
||||
for sym in ("cs_evaluate_fit", "cs_score_card"):
|
||||
fn = getattr(lib, sym)
|
||||
fn.argtypes = [ctypes.c_char_p]
|
||||
fn.restype = ctypes.c_void_p # char* — never default c_int
|
||||
_lib = lib
|
||||
return lib
|
||||
|
||||
|
||||
def _call(sym: str, payload: dict):
|
||||
lib = _load()
|
||||
fn = getattr(lib, sym)
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
ptr = fn(raw)
|
||||
if not ptr:
|
||||
raise RuntimeError(f"{sym} returned a null pointer")
|
||||
try:
|
||||
text = ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8")
|
||||
finally:
|
||||
lib.cs_free_string(ptr)
|
||||
env = json.loads(text)
|
||||
if not env.get("ok"):
|
||||
raise RuntimeError(f"{sym} core error: {env.get('err')}")
|
||||
return env["value"]
|
||||
|
||||
|
||||
def evaluate_fit(contracts: list, candidate: dict) -> dict:
|
||||
"""Deterministic EXACT/PARTIAL/INCOMPATIBLE/UNKNOWN fit decision."""
|
||||
return _call("cs_evaluate_fit",
|
||||
{"contracts": contracts, "candidate": candidate})
|
||||
|
||||
|
||||
def score_card(card: dict, query: str) -> float:
|
||||
"""Keyword scoring of a capability card against a query."""
|
||||
return _call("cs_score_card", {"card": card, "query": query})
|
||||
+50
-17
@@ -40,6 +40,54 @@ CACHE_BOOST_ROCK = 2.0 # multiplier for rock-status entries
|
||||
CACHE_BOOST_ESTABLISHED = 1.5 # multiplier for established entries
|
||||
|
||||
|
||||
# ── pure decision helpers (H3 slice 2: the Rust core serves these when
|
||||
# built; the _py_ variants are the load-bearing fallback + parity oracle).
|
||||
|
||||
from rune.drift_core import _ms_core # noqa: E402 (no cycle)
|
||||
|
||||
|
||||
def _py_compute_priority(entry: dict) -> float:
|
||||
"""Compute cache priority: wash_count × importance × status boost."""
|
||||
wash = entry.get("wash_count", 0) or 0
|
||||
imp = entry.get("importance", 0.5) or 0.5
|
||||
status = entry.get("rock_status", "washable")
|
||||
|
||||
# Logarithmic wash scaling (diminishing returns)
|
||||
wash_factor = min(1.0, math.log1p(wash) / math.log1p(20))
|
||||
|
||||
# Status boost
|
||||
status_boost = 1.0
|
||||
if status == "rock":
|
||||
status_boost = CACHE_BOOST_ROCK
|
||||
elif status == "established":
|
||||
status_boost = CACHE_BOOST_ESTABLISHED
|
||||
|
||||
return wash_factor * imp * status_boost
|
||||
|
||||
|
||||
def _py_qualifies_for_cache(entry: dict) -> bool:
|
||||
"""The add() admission decision: proven through use OR high importance."""
|
||||
wash = entry.get("wash_count", 0) or 0
|
||||
imp = entry.get("importance", 0.5) or 0.5
|
||||
return not (wash < MIN_WASH_FOR_CACHE and imp < 0.8)
|
||||
|
||||
|
||||
def compute_priority(entry: dict) -> float:
|
||||
"""Module-level priority (Rust core when built)."""
|
||||
core = _ms_core()
|
||||
if core is not None:
|
||||
return core.compute_priority(entry)
|
||||
return _py_compute_priority(entry)
|
||||
|
||||
|
||||
def qualifies_for_cache(entry: dict) -> bool:
|
||||
"""Module-level qualification (Rust core when built)."""
|
||||
core = _ms_core()
|
||||
if core is not None:
|
||||
return core.qualifies_for_cache(entry)
|
||||
return _py_qualifies_for_cache(entry)
|
||||
|
||||
|
||||
class DriftCache:
|
||||
"""Drift-aware cache with wash-count-based priority."""
|
||||
|
||||
@@ -65,21 +113,7 @@ class DriftCache:
|
||||
|
||||
def _compute_priority(self, entry: dict) -> float:
|
||||
"""Compute cache priority: wash_count × importance × status boost."""
|
||||
wash = entry.get("wash_count", 0) or 0
|
||||
imp = entry.get("importance", 0.5) or 0.5
|
||||
status = entry.get("rock_status", "washable")
|
||||
|
||||
# Logarithmic wash scaling (diminishing returns)
|
||||
wash_factor = min(1.0, math.log1p(wash) / math.log1p(20))
|
||||
|
||||
# Status boost
|
||||
status_boost = 1.0
|
||||
if status == "rock":
|
||||
status_boost = CACHE_BOOST_ROCK
|
||||
elif status == "established":
|
||||
status_boost = CACHE_BOOST_ESTABLISHED
|
||||
|
||||
return wash_factor * imp * status_boost
|
||||
return compute_priority(entry)
|
||||
|
||||
def add(self, entry_id: int, entry: dict):
|
||||
"""Add an entry to the cache.
|
||||
@@ -89,8 +123,7 @@ class DriftCache:
|
||||
- importance >= 0.8 (high importance, worth caching even if untested)
|
||||
"""
|
||||
wash = entry.get("wash_count", 0) or 0
|
||||
imp = entry.get("importance", 0.5) or 0.5
|
||||
if wash < MIN_WASH_FOR_CACHE and imp < 0.8:
|
||||
if not qualifies_for_cache(entry):
|
||||
return # Not proven enough and not important enough
|
||||
|
||||
priority = self._compute_priority(entry)
|
||||
|
||||
@@ -35,7 +35,7 @@ WASH_THRESHOLD_FULL = 20 # above this: minimal compression
|
||||
WASH_THRESHOLD_PARTIAL = 10 # above this: reduced compression
|
||||
|
||||
|
||||
def get_compression_ratio(wash_count: int = 0, rock_status: str = "washable") -> float:
|
||||
def _py_get_compression_ratio(wash_count: int = 0, rock_status: str = "washable") -> float:
|
||||
"""Get the compression ratio for an entry.
|
||||
|
||||
Returns 0.0-1.0 where 0.0 = no compression, 1.0 = maximum compression.
|
||||
@@ -61,7 +61,7 @@ def get_compression_ratio(wash_count: int = 0, rock_status: str = "washable") ->
|
||||
return base
|
||||
|
||||
|
||||
def compress_text(text: str, ratio: float) -> str:
|
||||
def _py_compress_text(text: str, ratio: float) -> str:
|
||||
"""Compress text by the given ratio.
|
||||
|
||||
Strategy: take first (1-ratio) proportion of the text.
|
||||
@@ -85,7 +85,7 @@ def compress_text(text: str, ratio: float) -> str:
|
||||
return compressed
|
||||
|
||||
|
||||
def compress_entry(entry: dict) -> str:
|
||||
def _py_compress_entry(entry: dict) -> str:
|
||||
"""Compress an entry's text based on its wash_count and rock_status.
|
||||
|
||||
Returns the compressed text.
|
||||
@@ -94,11 +94,11 @@ def compress_entry(entry: dict) -> str:
|
||||
wash_count = entry.get("wash_count", 0) or 0
|
||||
rock_status = entry.get("rock_status", "washable")
|
||||
|
||||
ratio = get_compression_ratio(wash_count, rock_status)
|
||||
return compress_text(text, ratio)
|
||||
ratio = _py_get_compression_ratio(wash_count, rock_status)
|
||||
return _py_compress_text(text, ratio)
|
||||
|
||||
|
||||
def should_warn(entry: dict) -> bool:
|
||||
def _py_should_warn(entry: dict) -> bool:
|
||||
"""Check if compressing this entry should trigger a warning.
|
||||
|
||||
Warn when:
|
||||
@@ -119,14 +119,14 @@ def should_warn(entry: dict) -> bool:
|
||||
return True
|
||||
|
||||
# Long content losing significant detail
|
||||
ratio = get_compression_ratio(wash_count, rock_status)
|
||||
ratio = _py_get_compression_ratio(wash_count, rock_status)
|
||||
if len(text) > 200 and ratio > 0.3:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def warn_before_compress(entry: dict) -> dict:
|
||||
def _py_warn_before_compress(entry: dict) -> dict:
|
||||
"""Generate a warning before compressing an entry.
|
||||
|
||||
Returns dict with warning details and recommendation.
|
||||
@@ -134,12 +134,12 @@ def warn_before_compress(entry: dict) -> dict:
|
||||
text = entry.get("text", "")
|
||||
wash_count = entry.get("wash_count", 0) or 0
|
||||
rock_status = entry.get("rock_status", "washable")
|
||||
ratio = get_compression_ratio(wash_count, rock_status)
|
||||
compressed = compress_text(text, ratio)
|
||||
ratio = _py_get_compression_ratio(wash_count, rock_status)
|
||||
compressed = _py_compress_text(text, ratio)
|
||||
chars_lost = len(text) - len(compressed)
|
||||
|
||||
warning = {
|
||||
"should_warn": should_warn(entry),
|
||||
"should_warn": _py_should_warn(entry),
|
||||
"entry_id": entry.get("id"),
|
||||
"wash_count": wash_count,
|
||||
"rock_status": rock_status,
|
||||
@@ -161,6 +161,54 @@ def warn_before_compress(entry: dict) -> dict:
|
||||
return warning
|
||||
|
||||
|
||||
# ── delegating public API (H3 slice 2 strangler) ─────────────────────────────
|
||||
# The Rust core serves these when built; the _py_ variants above are the
|
||||
# load-bearing fallback AND the parity oracle. DB-backed stats stays below.
|
||||
|
||||
from rune.drift_core import _ms_core # noqa: E402 (no cycle: drift_core
|
||||
# imports neither this module nor drift_cache)
|
||||
|
||||
|
||||
def get_compression_ratio(wash_count: int = 0, rock_status: str = "washable") -> float:
|
||||
"""Get the compression ratio for an entry (Rust core when built)."""
|
||||
core = _ms_core()
|
||||
if core is not None:
|
||||
return core.get_compression_ratio(wash_count, rock_status)
|
||||
return _py_get_compression_ratio(wash_count, rock_status)
|
||||
|
||||
|
||||
def compress_text(text: str, ratio: float) -> str:
|
||||
"""Compress text by the given ratio (Rust core when built)."""
|
||||
core = _ms_core()
|
||||
if core is not None:
|
||||
return core.compress_text(text, ratio)
|
||||
return _py_compress_text(text, ratio)
|
||||
|
||||
|
||||
def compress_entry(entry: dict) -> str:
|
||||
"""Compress an entry's text (Rust core when built)."""
|
||||
core = _ms_core()
|
||||
if core is not None:
|
||||
return core.compress_entry(entry)
|
||||
return _py_compress_entry(entry)
|
||||
|
||||
|
||||
def should_warn(entry: dict) -> bool:
|
||||
"""Check if compressing this entry should trigger a warning."""
|
||||
core = _ms_core()
|
||||
if core is not None:
|
||||
return core.should_warn(entry)
|
||||
return _py_should_warn(entry)
|
||||
|
||||
|
||||
def warn_before_compress(entry: dict) -> dict:
|
||||
"""Generate a warning before compressing an entry."""
|
||||
core = _ms_core()
|
||||
if core is not None:
|
||||
return core.warn_before_compress(entry)
|
||||
return _py_warn_before_compress(entry)
|
||||
|
||||
|
||||
def get_compression_stats(db) -> dict:
|
||||
"""Get compression statistics for the current store."""
|
||||
rows = db.execute(
|
||||
|
||||
+59
-3
@@ -64,7 +64,27 @@ WEIGHT_GOAL_RELEVANCE = 0.10 # relevance to current goals
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def compute_value(entry: dict, current_goals: List[str] = None) -> float:
|
||||
def _ms_core():
|
||||
"""The Rust memory_scoring core when available (strangler: Python
|
||||
fallback is load-bearing until deletion is proven).
|
||||
RUNE_MEMORY_CORE=python pins the fallback (parity harnesses)."""
|
||||
if os.environ.get("RUNE_MEMORY_CORE", "auto") == "python":
|
||||
return None
|
||||
try:
|
||||
from rune import ms_loader
|
||||
except Exception: # noqa: BLE001 — any loader failure means fallback, never a broken call
|
||||
return None
|
||||
try:
|
||||
return ms_loader if ms_loader.available() else None
|
||||
except Exception: # noqa: BLE001 — availability probe must never crash the fallback
|
||||
return None
|
||||
|
||||
|
||||
def _now_iso(now: str | None) -> str:
|
||||
return now or datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def compute_value(entry: dict, current_goals: List[str] | None = None) -> float:
|
||||
"""Multi-factor value score. Returns 0.0-1.0+.
|
||||
|
||||
How much is this entry worth? Higher = more proven, more useful, more trusted.
|
||||
@@ -75,7 +95,21 @@ def compute_value(entry: dict, current_goals: List[str] = None) -> float:
|
||||
- recency: exponential decay from last access
|
||||
- reliability: consistency of access pattern
|
||||
- goal_relevance: relevance to current goals (optional)
|
||||
|
||||
H3 slice 1: delegates to the Rust core when built (Python fallback —
|
||||
strangler law). The wall clock is resolved HERE and passed as an
|
||||
argument (no-clock law: both cores take `now` so parity is deterministic).
|
||||
"""
|
||||
core = _ms_core()
|
||||
if core is not None:
|
||||
return core.compute_value(entry, current_goals, _now_iso(None))
|
||||
return _py_compute_value(entry, current_goals, None)
|
||||
|
||||
|
||||
def _py_compute_value(entry: dict, current_goals: List[str] | None = None,
|
||||
now: str | None = None) -> float:
|
||||
"""Python core of compute_value (parity oracle for the Rust core).
|
||||
`now` is an ISO-8601 string; None resolves the wall clock (production)."""
|
||||
proven = entry.get("wash_count", 0) or 0 # column name preserved for compatibility
|
||||
imp = entry.get("importance", 0.5) or 0.5
|
||||
|
||||
@@ -83,12 +117,13 @@ def compute_value(entry: dict, current_goals: List[str] = None) -> float:
|
||||
proven_factor = min(1.0, math.log1p(proven) / math.log1p(PROVEN_USES_THRESHOLD))
|
||||
|
||||
# Recency factor: exponential decay
|
||||
now_dt = datetime.fromisoformat(now) if now else datetime.now(timezone.utc)
|
||||
recency = 1.0
|
||||
last_accessed = entry.get("last_accessed") or entry.get("last_washed")
|
||||
if last_accessed:
|
||||
try:
|
||||
last = datetime.fromisoformat(last_accessed)
|
||||
hours_since = max(0.1, (datetime.now(timezone.utc) - last).total_seconds() / 3600)
|
||||
hours_since = max(0.1, (now_dt - last).total_seconds() / 3600)
|
||||
recency = max(0.1, 1.0 / (1.0 + hours_since / (RECENCY_HALF_LIFE_DAYS * 24)))
|
||||
except (ValueError, TypeError):
|
||||
recency = 0.5
|
||||
@@ -134,7 +169,18 @@ def check_promotion_eligibility(entry: dict) -> Tuple[bool, str]:
|
||||
- rock_status == 'washable' or 'established'
|
||||
|
||||
Returns: (eligible, reason)
|
||||
|
||||
H3 slice 1: delegates to the Rust core when built (Python fallback).
|
||||
"""
|
||||
core = _ms_core()
|
||||
if core is not None:
|
||||
return core.check_promotion_eligibility(entry, _now_iso(None))
|
||||
return _py_check_promotion_eligibility(entry, None)
|
||||
|
||||
|
||||
def _py_check_promotion_eligibility(entry: dict,
|
||||
now: str | None = None) -> Tuple[bool, str]:
|
||||
"""Python core of check_promotion_eligibility (parity oracle)."""
|
||||
wash = entry.get("wash_count", 0) or 0
|
||||
status = entry.get("rock_status", "washable")
|
||||
|
||||
@@ -144,7 +190,7 @@ def check_promotion_eligibility(entry: dict) -> Tuple[bool, str]:
|
||||
if wash < PROVEN_USES_THRESHOLD:
|
||||
return False, f"proven_uses {wash} < threshold {PROVEN_USES_THRESHOLD}"
|
||||
|
||||
value = compute_value(entry)
|
||||
value = _py_compute_value(entry, now=now)
|
||||
if value < 0.6:
|
||||
return False, f"value {value:.3f} < 0.6 minimum"
|
||||
|
||||
@@ -317,7 +363,17 @@ def pre_commit_gate_entry(entry: dict) -> dict:
|
||||
"""Three critics for a dict entry (no db lookup needed).
|
||||
|
||||
Used by curator when promoting candidates that don't have entry_ids yet.
|
||||
|
||||
H3 slice 1: delegates to the Rust core when built (Python fallback).
|
||||
"""
|
||||
core = _ms_core()
|
||||
if core is not None:
|
||||
return core.pre_commit_gate_entry(entry)
|
||||
return _py_pre_commit_gate_entry(entry)
|
||||
|
||||
|
||||
def _py_pre_commit_gate_entry(entry: dict) -> dict:
|
||||
"""Python core of pre_commit_gate_entry (parity oracle)."""
|
||||
text = entry.get("text", "")
|
||||
critics = {}
|
||||
|
||||
|
||||
+28
-30
@@ -18,15 +18,32 @@ Discipline:
|
||||
repeated probe loops (no recursion; one probe per unknown per attempt).
|
||||
- The artifact file is the sole authority (ADAPT-9); the declared absolute
|
||||
path is injected into the worker prompt, never derived by the worker.
|
||||
|
||||
Phase G strangler slice 2 (port 3): the schema gate (validate_probe) and the
|
||||
budget count (count_probes) are served IN-PROCESS by the Rust
|
||||
`evidence_provenance` cdylib through ep_loader.py — parity 24/24 proven vs
|
||||
this module's Python vintage (byte-equal error lists incl. Python repr
|
||||
rendering: {status!r} single quotes, sorted-list error shapes, non-string
|
||||
status double-error behavior). Orchestration (run_probe, runner subprocess,
|
||||
exception semantics, build_probe_prompt) stays Python — same discipline as
|
||||
ports 1-2. Constants, ProbeResult, CLI-adjacent surfaces unchanged (git
|
||||
history holds the original).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# ── Rust core wiring (slice-2 port-3 shim pattern) ──────────────────────────
|
||||
_CRATE_DIR = "/mnt/workdrive/rune/src/rust/evidence_provenance"
|
||||
if _CRATE_DIR not in sys.path:
|
||||
sys.path.insert(0, _CRATE_DIR)
|
||||
import ep_loader # noqa: E402 (loads target/release cdylib)
|
||||
|
||||
PROBE_STATUSES = {"GROUNDED", "STILL_UNKNOWN", "CONTRADICTED"}
|
||||
PROBE_FIELDS = ("question", "source", "answer", "implication", "status")
|
||||
|
||||
@@ -93,38 +110,19 @@ research after the unknown is resolved; guess without a source."""
|
||||
|
||||
|
||||
def validate_probe(artifact: dict) -> list[str]:
|
||||
"""Schema gate. Returns gate errors (empty = pass)."""
|
||||
errors = []
|
||||
if not isinstance(artifact, dict):
|
||||
return ["probe artifact is not a JSON object"]
|
||||
for f in PROBE_FIELDS:
|
||||
if f not in artifact:
|
||||
errors.append(f"missing required field: {f}")
|
||||
elif not isinstance(artifact[f], str):
|
||||
errors.append(f"field {f} must be a string")
|
||||
elif f != "source" and not artifact[f].strip():
|
||||
errors.append(f"field {f} must be a non-empty string")
|
||||
status = artifact.get("status")
|
||||
if status is not None and status not in PROBE_STATUSES:
|
||||
errors.append(f"status must be one of {sorted(PROBE_STATUSES)}, got {status!r}")
|
||||
# A GROUNDED probe must cite its source (that is what makes it grounded).
|
||||
# STILL_UNKNOWN / CONTRADICTED may have an empty source — no evidence
|
||||
# found is the honest finding, not a schema violation.
|
||||
if status == "GROUNDED" and len(artifact.get("source", "")) < 8:
|
||||
errors.append("GROUNDED probe requires a concrete source citation")
|
||||
extra = set(artifact) - set(PROBE_FIELDS) - {"probe_id", "decided_at", "model"}
|
||||
if extra:
|
||||
errors.append(f"unexpected extra fields: {sorted(extra)}")
|
||||
return errors
|
||||
"""Schema gate. Returns gate errors (empty = pass).
|
||||
|
||||
Served by the Rust core (parity-proven byte-equal); dict inputs are
|
||||
serialized for the FFI boundary.
|
||||
"""
|
||||
return ep_loader.validate_probe(json.dumps(artifact))
|
||||
|
||||
|
||||
def count_probes(artifact_dir: str) -> int:
|
||||
"""Probes already run for this task (no-recursion budget)."""
|
||||
path = os.path.join(artifact_dir, "probe.jsonl")
|
||||
if not os.path.exists(path):
|
||||
return 0
|
||||
with open(path) as f:
|
||||
return sum(1 for line in f if line.strip())
|
||||
"""Probes already run for this task (no-recursion budget).
|
||||
|
||||
Served by the Rust core (parity-proven byte-equal)."""
|
||||
return ep_loader.count_probes(artifact_dir)
|
||||
|
||||
|
||||
def check_budget(artifact_dir: str) -> None:
|
||||
@@ -211,4 +209,4 @@ def _default_runner(prompt: str, artifact_path: str) -> None:
|
||||
with open(os.path.join(os.path.dirname(artifact_path), "evidence_probe.stdout.log"), "w") as f:
|
||||
f.write(result.stdout or "")
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"probe worker exited {result.returncode}")
|
||||
raise ValueError(f"probe worker exited {result.returncode}")
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""FFI smoke: prove the deployed consumer shape works against the Rust cdylib.
|
||||
|
||||
Deployed ground (memory_store.py:821-822):
|
||||
qm = query_memory.QueryMemory() # HOME-resolved construction
|
||||
exploration_rate = qm.adaptive_exploration_rate(query)
|
||||
|
||||
Checks (each must PASS or the script exits 1):
|
||||
S1 cdylib loads; qm_new() with isolated HOME returns a live instance
|
||||
S2 qm_classify parity vs Python classify_query (battery incl. unicode + tie-break)
|
||||
S3 qm_adaptive_rate exact float parity vs Python adaptive_exploration_rate
|
||||
on a shared store, INCLUDING an adaptive-threshold crossing (>=10 records)
|
||||
S4 qm_record exit 0; persisted entry matches Python's entry shape (timestamp masked)
|
||||
S5 qm_health JSON == json.dumps(py.get_health_report(), indent=2) (timestamps masked)
|
||||
S6 lifecycle: qm_string_free / qm_free / null-safety returns
|
||||
|
||||
Isolation: HOME is pinned to a temp dir BEFORE importing query_memory
|
||||
(QUERY_MEMORY_DIR is an import-time constant). The Rust side gets the same
|
||||
store via qm_with_dir on that exact directory.
|
||||
"""
|
||||
import ctypes
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
REPO = "/mnt/workdrive/rune"
|
||||
SO_PATH = os.path.join(REPO, "src/rust/query_memory/target/release/libquery_memory.so")
|
||||
PY_DIR = os.path.join(REPO, "src/rune")
|
||||
|
||||
TS_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?\+00:00")
|
||||
|
||||
|
||||
def mask_ts(text):
|
||||
return TS_RE.sub("<TS>", text)
|
||||
|
||||
|
||||
def main():
|
||||
tmp = tempfile.mkdtemp(prefix="ffi_smoke_")
|
||||
os.environ["HOME"] = tmp # BEFORE query_memory import (import-time constant)
|
||||
# Post-supersession (2ac2d0a): the Python vintage is deleted; the
|
||||
# "consumer side" of this smoke is now the shared/ ctypes shim — the same
|
||||
# deployed-consumer surface the smoke was built to prove.
|
||||
sys.path.insert(0, os.path.join(REPO, "shared"))
|
||||
sys.path.insert(0, PY_DIR)
|
||||
import query_memory as qm_py
|
||||
|
||||
results = []
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
results.append((name, bool(cond), detail))
|
||||
print(("PASS" if cond else "FAIL") + " " + name + ((" | " + detail) if detail and not cond else ""))
|
||||
|
||||
# --- load library ---
|
||||
lib = ctypes.CDLL(SO_PATH)
|
||||
lib.qm_new.restype = ctypes.c_void_p
|
||||
lib.qm_with_dir.argtypes = [ctypes.c_char_p]
|
||||
lib.qm_with_dir.restype = ctypes.c_void_p
|
||||
lib.qm_free.argtypes = [ctypes.c_void_p]
|
||||
lib.qm_classify.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
|
||||
lib.qm_classify.restype = ctypes.c_void_p # char* via cast
|
||||
lib.qm_adaptive_rate.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
|
||||
lib.qm_adaptive_rate.restype = ctypes.c_double
|
||||
lib.qm_record.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int]
|
||||
lib.qm_record.restype = ctypes.c_int
|
||||
lib.qm_health.argtypes = [ctypes.c_void_p]
|
||||
lib.qm_health.restype = ctypes.c_void_p
|
||||
lib.qm_string_free.argtypes = [ctypes.c_void_p]
|
||||
|
||||
def take(ptr):
|
||||
if not ptr:
|
||||
return None
|
||||
s = ctypes.string_at(ptr).decode("utf-8")
|
||||
lib.qm_string_free(ptr)
|
||||
return s
|
||||
|
||||
# Build store state via PYTHON first (deployed consumer writes through Python;
|
||||
# Rust must read the same store). Cross the C8 adaptive threshold: >=10 records.
|
||||
# BOTH sides are construction-time snapshots of the store (topic_stats is
|
||||
# loaded once in the constructor), so the deployed shape is: seed the store,
|
||||
# THEN construct each consumer fresh from disk. A consumer that wrote through
|
||||
# its own in-memory instance would be an unfair comparison shape.
|
||||
battery = [
|
||||
("how do I play a chord on guitar", "guitar"),
|
||||
("python script to debug a function", "coding"),
|
||||
("recall consolidated memory neurons", "memory"),
|
||||
("what is my persona voice constitution", "persona"),
|
||||
("project task deadline milestone", "project"),
|
||||
("llm agent training inference", "ai"),
|
||||
("nick ttrpg coffee coconut", "personal"),
|
||||
("system config troubleshooting error", "system"),
|
||||
("research paper study survey evidence", "research"),
|
||||
("write create design art", "creative"),
|
||||
("mémoire café — 记忆 recall", "memory"),
|
||||
("build", "coding"), # tie-break: build in coding AND creative
|
||||
("make music", "creative"), # tie-break: music in guitar AND creative
|
||||
("", None), # empty query edge
|
||||
]
|
||||
queries = [q for q, _ in battery]
|
||||
|
||||
# Seed store with 12 records on one topic to cross the adaptive threshold.
|
||||
# The writer is a THROWAWAY instance — both compared consumers must be
|
||||
# constructed fresh AFTER seeding (construction-time snapshot semantics).
|
||||
writer = qm_py.QueryMemory()
|
||||
for i in range(12):
|
||||
writer.record_query("recall consolidated memory neurons", "consolidated", i < 10)
|
||||
|
||||
# S1: the deployed construction shape — HOME-resolved qm_new + fresh Python consumer
|
||||
m = lib.qm_new()
|
||||
check("S1 qm_new returns live instance", bool(m))
|
||||
py = qm_py.QueryMemory()
|
||||
|
||||
# S2+S3: classify + rate parity on the shared store
|
||||
for q in queries:
|
||||
py_topic = py.classify_query(q)
|
||||
rust_topic = take(lib.qm_classify(m, q.encode("utf-8")))
|
||||
check("S2 classify parity: %r" % q[:30], py_topic == rust_topic,
|
||||
"py=%r rust=%r" % (py_topic, rust_topic))
|
||||
py_rate = py.adaptive_exploration_rate(q)
|
||||
rust_rate = lib.qm_adaptive_rate(m, q.encode("utf-8"))
|
||||
check("S3 rate parity: %r" % q[:30], py_rate == rust_rate,
|
||||
"py=%r rust=%r" % (py_rate, rust_rate))
|
||||
|
||||
# S4: record through the FFI, verify persistence matches Python shape
|
||||
rc = lib.qm_record(m, b"recall consolidated memory neurons", b"full_retrieval", 1)
|
||||
check("S4 qm_record returns 0", rc == 0, "rc=%d" % rc)
|
||||
py2 = qm_py.QueryMemory() # fresh load of the same store
|
||||
last = py2.history[-1]
|
||||
check("S4 persisted entry topic", last["topic"] == "memory", "topic=%r" % last.get("topic"))
|
||||
check("S4 persisted entry strategy", last["strategy"] == "full_retrieval", "strategy=%r" % last.get("strategy"))
|
||||
check("S4 persisted entry success", last["success"] is True)
|
||||
check("S4 persisted entry fields", set(last) == {"query", "topic", "strategy", "success", "timestamp"},
|
||||
"fields=%r" % sorted(last))
|
||||
|
||||
# S5: health report parity (timestamps masked).
|
||||
# BOTH sides must be post-record state: the Rust instance recorded through
|
||||
# itself at S4, so the Python side must be a FRESH load of the same store
|
||||
# (py2), not the pre-record snapshot (py).
|
||||
py_health = json.dumps(py2.get_health_report(), indent=2)
|
||||
rust_health = take(lib.qm_health(m))
|
||||
check("S5 health parity", mask_ts(py_health) == mask_ts(rust_health),
|
||||
"py=%r rust=%r" % (mask_ts(py_health)[:200], mask_ts(rust_health)[:200]))
|
||||
|
||||
# S6: lifecycle + null-safety
|
||||
check("S6 qm_classify(NULL) returns NULL", not lib.qm_classify(m, None))
|
||||
check("S6 qm_adaptive_rate(NULL) returns NaN", math.isnan(lib.qm_adaptive_rate(m, None)))
|
||||
check("S6 qm_record(NULL,...) returns -1",
|
||||
lib.qm_record(None, b"x", b"consolidated", 1) == -1)
|
||||
check("S6 qm_health(NULL) returns NULL", not lib.qm_health(None))
|
||||
lib.qm_string_free(None) # must not crash
|
||||
lib.qm_free(None) # must not crash
|
||||
|
||||
# S1b: qm_with_dir on the exact store dir (second deployed-compatible shape)
|
||||
store_dir = os.path.join(tmp, ".config/opencode/memory/query_memory")
|
||||
m2 = lib.qm_with_dir(store_dir.encode("utf-8"))
|
||||
check("S1b qm_with_dir on shared store returns live instance", bool(m2))
|
||||
if m2:
|
||||
r2 = lib.qm_adaptive_rate(m2, b"recall consolidated memory neurons")
|
||||
p2 = py2.adaptive_exploration_rate("recall consolidated memory neurons")
|
||||
check("S1b with_dir instance reads same store state", r2 == p2, "rust=%r py=%r" % (r2, p2))
|
||||
lib.qm_free(m2)
|
||||
|
||||
lib.qm_free(m)
|
||||
_finish(results, tmp)
|
||||
|
||||
|
||||
def _finish(results, tmp):
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
fails = [r for r in results if not r[1]]
|
||||
print("\n%d/%d PASS" % (len(results) - len(fails), len(results)))
|
||||
if fails:
|
||||
for name, _, detail in fails:
|
||||
print("FAILED: %s %s" % (name, detail))
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+68
-10
@@ -9,11 +9,69 @@ variables or a config dict — nothing is hardcoded to a specific setup.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def _request(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: dict | None = None,
|
||||
json: dict | None = None,
|
||||
timeout: float = 30,
|
||||
retries: int = 2,
|
||||
backoff: float = 0.5,
|
||||
sleep=None,
|
||||
):
|
||||
"""Resilient single-request path: retry where failure is NOISE, never
|
||||
where failure is the SIGNAL.
|
||||
|
||||
- GET: retries on ``httpx.TransportError`` and 5xx (transient
|
||||
infrastructure failure; idempotent).
|
||||
- POST: retries ONLY on connect-phase errors (``ConnectError`` /
|
||||
``ConnectTimeout``) — the request never reached the server, so a retry
|
||||
cannot double-create. Read timeouts / write errors / 5xx on a POST are
|
||||
NOT retried: the server may have processed it, and a duplicate issue is
|
||||
worse than a failure the auto-filer can catch.
|
||||
- 4xx: never retried — the client error IS the information. Raised via
|
||||
``raise_for_status`` on the first attempt.
|
||||
- Attempts: ``retries + 1`` total. Backoff ``backoff * 2**n`` before
|
||||
retry n+1; never a sleep before the first attempt.
|
||||
- ``timeout`` passes through to httpx per call. ``sleep`` is injectable
|
||||
(tests pass a recording fake); default ``time.sleep``.
|
||||
"""
|
||||
sleeper = sleep if sleep is not None else time.sleep
|
||||
sender = httpx.post if method.upper() == "POST" else httpx.get
|
||||
connect_phase = (httpx.ConnectError, httpx.ConnectTimeout)
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
r = sender(url, headers=headers, json=json, timeout=timeout)
|
||||
except connect_phase:
|
||||
if attempt < retries:
|
||||
sleeper(backoff * 2**attempt)
|
||||
continue
|
||||
raise
|
||||
except httpx.TransportError:
|
||||
# Non-connect transport error (read/write): only safe to retry
|
||||
# when the call is idempotent.
|
||||
if method.upper() == "GET" and attempt < retries:
|
||||
sleeper(backoff * 2**attempt)
|
||||
continue
|
||||
raise
|
||||
if r.status_code >= 500 and method.upper() == "GET" and attempt < retries:
|
||||
sleeper(backoff * 2**attempt)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
return r
|
||||
# Unreachable: every attempt either returns a successful response or
|
||||
# raises. The explicit raise keeps pyright from inferring an implicit
|
||||
# ``None`` return for the exhausted loop (ratchet: warnings ≤ 454).
|
||||
raise AssertionError("unreachable: _request always returns or raises")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ForgeClient(Protocol):
|
||||
def create_issue(self, title: str, body: str) -> dict: ...
|
||||
@@ -59,41 +117,41 @@ class GiteaForgejoAdapter:
|
||||
return {"Authorization": f"token {self._token}", "Content-Type": "application/json"}
|
||||
|
||||
def create_issue(self, title: str, body: str) -> dict:
|
||||
r = httpx.post(
|
||||
r = _request(
|
||||
"POST",
|
||||
f"{self._base_url}/issues",
|
||||
headers=self._headers(),
|
||||
json={"title": title, "body": body},
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def add_comment(self, issue_number: int, body: str) -> dict:
|
||||
r = httpx.post(
|
||||
r = _request(
|
||||
"POST",
|
||||
f"{self._base_url}/issues/{issue_number}/comments",
|
||||
headers=self._headers(),
|
||||
json={"body": body},
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_issue_state(self, issue_number: int) -> dict:
|
||||
r = httpx.get(
|
||||
r = _request(
|
||||
"GET",
|
||||
f"{self._base_url}/issues/{issue_number}",
|
||||
headers=self._headers(),
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def list_open_issues(self) -> list[dict]:
|
||||
r = httpx.get(
|
||||
r = _request(
|
||||
"GET",
|
||||
f"{self._base_url}/issues?state=open&limit=50",
|
||||
headers=self._headers(),
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def search_issues(self, query: str) -> list[dict]:
|
||||
@@ -115,12 +173,12 @@ class GiteaForgejoAdapter:
|
||||
while True:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
r = httpx.get(
|
||||
r = _request(
|
||||
"GET",
|
||||
f"{self._base_url}/issues?{urlencode({'state': 'open', 'type': 'issues', 'q': query, 'limit': 50, 'page': page})}",
|
||||
headers=self._headers(),
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
batch = r.json()
|
||||
results.extend(batch)
|
||||
if len(batch) < 50:
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ctypes loader for the Rust memory_scoring core (H3 slice 1, T4.4).
|
||||
|
||||
Contract BUILDER-2026-10-04T08:57:33Z-cli. Gives drift_core the Rust
|
||||
scoring trio through the cdylib with no subprocess. No-clock law: `now` is
|
||||
always an argument (ISO-8601 string) — the Python originals read the wall
|
||||
clock for recency; callers pass it so parity is deterministic.
|
||||
|
||||
FFI contract (doc-envelope law — every call returns {"ok", "value"|"err"}):
|
||||
* ms_compute_value({entry, goals, now}) -> number
|
||||
* ms_check_promotion_eligibility({entry, now}) -> [eligible, reason]
|
||||
* ms_pre_commit_gate_entry({entry}) -> {verdict, critics, reason}
|
||||
* ms_free_string(ptr)
|
||||
|
||||
FFI segfault law: every symbol gets explicit argtypes + restype (c_void_p
|
||||
for returned char* — the default c_int truncates 64-bit pointers).
|
||||
|
||||
Zero dependencies. The .so resolves against this repo's crate target dir;
|
||||
override with MS_RUST_LIB (a FILE path).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[2]
|
||||
_CRATE_DIR = _REPO / "src" / "rust" / "memory_scoring"
|
||||
_LIB_PATH = Path(
|
||||
os.environ.get(
|
||||
"MS_RUST_LIB", _CRATE_DIR / "target" / "release" / "libmemory_scoring.so"
|
||||
)
|
||||
)
|
||||
|
||||
_lib = None
|
||||
|
||||
|
||||
class MemoryScoringUnavailable(FileNotFoundError):
|
||||
"""The Rust core is not built (or MS_RUST_LIB points nowhere). Callers
|
||||
fall back to the Python implementation — strangler law: the fallback is
|
||||
load-bearing until deletion is proven."""
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
return _LIB_PATH.is_file()
|
||||
|
||||
|
||||
def _load() -> ctypes.CDLL:
|
||||
global _lib
|
||||
if _lib is not None:
|
||||
return _lib
|
||||
if not _LIB_PATH.exists():
|
||||
raise MemoryScoringUnavailable(
|
||||
f"Rust memory_scoring core not built: {_LIB_PATH} missing — run "
|
||||
f"`cargo build --release` in {_CRATE_DIR}"
|
||||
)
|
||||
lib = ctypes.CDLL(str(_LIB_PATH))
|
||||
lib.ms_free_string.argtypes = [ctypes.c_void_p]
|
||||
lib.ms_free_string.restype = None
|
||||
for sym in ("ms_compute_value", "ms_check_promotion_eligibility",
|
||||
"ms_pre_commit_gate_entry", "ms_get_compression_ratio",
|
||||
"ms_compress_text", "ms_compress_entry", "ms_should_warn",
|
||||
"ms_warn_before_compress", "ms_compute_priority",
|
||||
"ms_qualifies_for_cache", "ms_score_gate", "ms_score_finish",
|
||||
"ms_base_level_from_uses"):
|
||||
fn = getattr(lib, sym)
|
||||
fn.argtypes = [ctypes.c_char_p]
|
||||
fn.restype = ctypes.c_void_p # char* — never default c_int
|
||||
_lib = lib
|
||||
return lib
|
||||
|
||||
|
||||
def _call(sym: str, payload: dict):
|
||||
lib = _load()
|
||||
fn = getattr(lib, sym)
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
ptr = fn(raw)
|
||||
if not ptr:
|
||||
raise RuntimeError(f"{sym} returned a null pointer")
|
||||
try:
|
||||
text = ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8")
|
||||
finally:
|
||||
lib.ms_free_string(ptr)
|
||||
env = json.loads(text)
|
||||
if not env.get("ok"):
|
||||
raise RuntimeError(f"{sym} core error: {env.get('err')}")
|
||||
return env["value"]
|
||||
|
||||
|
||||
def compute_value(entry: dict, goals: list | None, now: str) -> float:
|
||||
"""Multi-factor value score (0.0-1.0+); now is an ISO-8601 string."""
|
||||
return _call("ms_compute_value", {"entry": entry, "goals": goals, "now": now})
|
||||
|
||||
|
||||
def check_promotion_eligibility(entry: dict, now: str) -> tuple:
|
||||
"""(eligible, reason) — unpacked from the core's 2-element array."""
|
||||
eligible, reason = _call("ms_check_promotion_eligibility",
|
||||
{"entry": entry, "now": now})
|
||||
return (eligible, reason)
|
||||
|
||||
|
||||
def pre_commit_gate_entry(entry: dict) -> dict:
|
||||
"""Three-critic promotion gate for a dict entry (no db context)."""
|
||||
return _call("ms_pre_commit_gate_entry", {"entry": entry})
|
||||
|
||||
|
||||
# ── H3 slice 2: drift_compression / drift_cache pure surfaces ────────────────
|
||||
|
||||
|
||||
def get_compression_ratio(wash_count: float, rock_status: str) -> float:
|
||||
return _call("ms_get_compression_ratio",
|
||||
{"wash_count": wash_count, "rock_status": rock_status})
|
||||
|
||||
|
||||
def compress_text(text: str, ratio: float) -> str:
|
||||
return _call("ms_compress_text", {"text": text, "ratio": ratio})
|
||||
|
||||
|
||||
def compress_entry(entry: dict) -> str:
|
||||
return _call("ms_compress_entry", {"entry": entry})
|
||||
|
||||
|
||||
def should_warn(entry: dict) -> bool:
|
||||
return _call("ms_should_warn", {"entry": entry})
|
||||
|
||||
|
||||
def warn_before_compress(entry: dict) -> dict:
|
||||
return _call("ms_warn_before_compress", {"entry": entry})
|
||||
|
||||
|
||||
def compute_priority(entry: dict) -> float:
|
||||
return _call("ms_compute_priority", {"entry": entry})
|
||||
|
||||
|
||||
def qualifies_for_cache(entry: dict) -> bool:
|
||||
return _call("ms_qualifies_for_cache", {"entry": entry})
|
||||
|
||||
|
||||
# ── H3 slice 3: warm scoring math ────────────────────────────────────────────
|
||||
|
||||
|
||||
def score_gate(neuron: dict, low_text: str, session_context: str | None,
|
||||
semantic_score: float) -> tuple:
|
||||
"""(fires, total, keyword_hits) — unpacked from the 3-element array."""
|
||||
fires, total, kw = _call("ms_score_gate", {
|
||||
"neuron": neuron, "low_text": low_text,
|
||||
"session_context": session_context, "semantic_score": semantic_score})
|
||||
return (fires, total, kw)
|
||||
|
||||
|
||||
def score_finish(neuron: dict, low_text: str, total: float,
|
||||
b_i: float | None, cache_priority: float | None,
|
||||
keyword_hits: float) -> float:
|
||||
return _call("ms_score_finish", {
|
||||
"neuron": neuron, "low_text": low_text, "total": total,
|
||||
"b_i": b_i, "cache_priority": cache_priority,
|
||||
"keyword_hits": keyword_hits})
|
||||
|
||||
|
||||
def base_level_from_uses(used_at: list, now: str) -> float:
|
||||
return _call("ms_base_level_from_uses", {"used_at": used_at, "now": now})
|
||||
@@ -1,257 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""query_memory.py — Query memory system for adaptive retrieval.
|
||||
|
||||
The system remembers what kinds of queries it gets, what retrieval
|
||||
strategy worked, and adapts its gate logic accordingly.
|
||||
|
||||
This prevents dead weight — memories get tested based on usage
|
||||
patterns, not just random exploration.
|
||||
|
||||
Research basis:
|
||||
- IGMiRAG (arXiv:2602.07525): Intuition-guided retrieval
|
||||
- Task-Adaptive Retrieval (arXiv:2604.07863): Learning from interaction history
|
||||
- MemCon (arXiv:2607.13591): Learned policy for consolidation/forgetting
|
||||
- Agent-Native Memory (arXiv:2606.24775): Workload-adaptive routing
|
||||
|
||||
Run under ~/.venvs/memory/bin/python3.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
QUERY_MEMORY_DIR = os.path.expanduser("~/.config/opencode/memory/query_memory")
|
||||
QUERY_HISTORY_FILE = os.path.join(QUERY_MEMORY_DIR, "history.jsonl")
|
||||
TOPIC_STATS_FILE = os.path.join(QUERY_MEMORY_DIR, "topic_stats.json")
|
||||
|
||||
# Topic keywords for classification
|
||||
TOPIC_KEYWORDS = {
|
||||
"guitar": ["guitar", "music", "teaching", "course", "lesson", "chord", "scale"],
|
||||
"coding": ["code", "python", "script", "function", "debug", "build", "deploy"],
|
||||
"memory": ["memory", "recall", "remember", "forget", "consolidat", "neuron"],
|
||||
"persona": ["persona", "identity", "voice", "constitution", "character"],
|
||||
"project": ["project", "task", "goal", "deadline", "milestone", "progress"],
|
||||
"ai": ["ai", "model", "llm", "agent", "neural", "training", "inference"],
|
||||
"personal": ["nick", "nottingham", "coffee", "coconut", "dog", "ttrpg"],
|
||||
"system": ["system", "config", "install", "setup", "troubleshoot", "error"],
|
||||
"research": ["research", "paper", "study", "analysis", "survey", "evidence"],
|
||||
"creative": ["write", "design", "create", "build", "make", "art", "music"],
|
||||
}
|
||||
|
||||
|
||||
class QueryMemory:
|
||||
"""Query memory system for adaptive retrieval."""
|
||||
|
||||
def __init__(self):
|
||||
os.makedirs(QUERY_MEMORY_DIR, exist_ok=True)
|
||||
self.history: List[dict] = self._load_history()
|
||||
self.topic_stats: Dict[str, dict] = self._load_topic_stats()
|
||||
|
||||
def _load_history(self) -> List[dict]:
|
||||
"""Load query history from disk."""
|
||||
if not os.path.exists(QUERY_HISTORY_FILE):
|
||||
return []
|
||||
entries = []
|
||||
with open(QUERY_HISTORY_FILE) as f:
|
||||
for line in f:
|
||||
try:
|
||||
entries.append(json.loads(line.strip()))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return entries
|
||||
|
||||
def _save_history(self, entry: dict):
|
||||
"""Append a query entry to history."""
|
||||
with open(QUERY_HISTORY_FILE, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
def _load_topic_stats(self) -> Dict[str, dict]:
|
||||
"""Load topic statistics from disk."""
|
||||
if os.path.exists(TOPIC_STATS_FILE):
|
||||
try:
|
||||
with open(TOPIC_STATS_FILE) as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def _save_topic_stats(self):
|
||||
"""Save topic statistics to disk."""
|
||||
with open(TOPIC_STATS_FILE, "w") as f:
|
||||
json.dump(self.topic_stats, f, indent=2)
|
||||
|
||||
def classify_query(self, query: str) -> str:
|
||||
"""Classify a query into a topic.
|
||||
|
||||
Returns the topic with the most keyword matches.
|
||||
Falls back to "general" if no strong match.
|
||||
"""
|
||||
query_lower = query.lower()
|
||||
scores = {}
|
||||
for topic, keywords in TOPIC_KEYWORDS.items():
|
||||
score = sum(1 for kw in keywords if kw in query_lower)
|
||||
if score > 0:
|
||||
scores[topic] = score
|
||||
|
||||
if not scores:
|
||||
return "general"
|
||||
|
||||
return max(scores, key=scores.get)
|
||||
|
||||
def record_query(self, query: str, strategy: str, success: bool):
|
||||
"""Record a query and its outcome.
|
||||
|
||||
Args:
|
||||
query: The query text
|
||||
strategy: "consolidated" or "full_retrieval"
|
||||
success: Whether the retrieval produced useful results
|
||||
"""
|
||||
topic = self.classify_query(query)
|
||||
entry = {
|
||||
"query": query[:100],
|
||||
"topic": topic,
|
||||
"strategy": strategy,
|
||||
"success": success,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
self.history.append(entry)
|
||||
self._save_history(entry)
|
||||
|
||||
# Update topic stats
|
||||
if topic not in self.topic_stats:
|
||||
self.topic_stats[topic] = {
|
||||
"total_queries": 0,
|
||||
"successful_queries": 0,
|
||||
"consolidated_hits": 0,
|
||||
"full_retrieval_hits": 0,
|
||||
"exploration_rate": 0.30, # default
|
||||
}
|
||||
|
||||
stats = self.topic_stats[topic]
|
||||
stats["total_queries"] += 1
|
||||
if success:
|
||||
stats["successful_queries"] += 1
|
||||
if strategy == "consolidated":
|
||||
stats["consolidated_hits"] += 1
|
||||
else:
|
||||
stats["full_retrieval_hits"] += 1
|
||||
|
||||
# Adaptive exploration rate: decrease for well-tested topics
|
||||
if stats["total_queries"] >= 10:
|
||||
success_rate = stats["successful_queries"] / stats["total_queries"]
|
||||
if success_rate > 0.8:
|
||||
stats["exploration_rate"] = max(0.10, stats["exploration_rate"] - 0.05)
|
||||
elif success_rate < 0.5:
|
||||
stats["exploration_rate"] = min(0.50, stats["exploration_rate"] + 0.05)
|
||||
|
||||
self._save_topic_stats()
|
||||
|
||||
def get_topic_stats(self) -> Dict[str, dict]:
|
||||
"""Get topic statistics."""
|
||||
return self.topic_stats
|
||||
|
||||
def adaptive_exploration_rate(self, query: str) -> float:
|
||||
"""Get the exploration rate for a query based on its topic.
|
||||
|
||||
Well-tested topics have lower exploration rates.
|
||||
Untested topics have higher exploration rates.
|
||||
"""
|
||||
topic = self.classify_query(query)
|
||||
if topic in self.topic_stats:
|
||||
return self.topic_stats[topic].get("exploration_rate", 0.30)
|
||||
return 0.50 # untested topics get high exploration
|
||||
|
||||
def get_dead_weight_topics(self) -> List[str]:
|
||||
"""Get topics with low proven uses (dead weight)."""
|
||||
dead = []
|
||||
for topic, stats in self.topic_stats.items():
|
||||
if stats["total_queries"] < 5:
|
||||
dead.append(topic)
|
||||
return dead
|
||||
|
||||
def get_health_report(self) -> dict:
|
||||
"""Get a health report of the query memory system."""
|
||||
total_queries = len(self.history)
|
||||
topics = len(self.topic_stats)
|
||||
dead_topics = len(self.get_dead_weight_topics())
|
||||
|
||||
return {
|
||||
"total_queries": total_queries,
|
||||
"topics_tracked": topics,
|
||||
"dead_weight_topics": dead_topics,
|
||||
"topic_stats": self.topic_stats,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# CLI
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Query memory system")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
|
||||
# record <query> --strategy <s> --success
|
||||
p_rec = sub.add_parser("record", help="Record a query")
|
||||
p_rec.add_argument("query", nargs="+")
|
||||
p_rec.add_argument("--strategy", default="consolidated")
|
||||
p_rec.add_argument("--success", action="store_true")
|
||||
|
||||
# stats
|
||||
sub.add_parser("stats", help="Show topic statistics")
|
||||
|
||||
# rate <query>
|
||||
p_rate = sub.add_parser("rate", help="Get exploration rate for a query")
|
||||
p_rate.add_argument("query", nargs="+")
|
||||
|
||||
# health
|
||||
sub.add_parser("health", help="Show health report")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.cmd:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
qm = QueryMemory()
|
||||
|
||||
if args.cmd == "record":
|
||||
query = " ".join(args.query)
|
||||
qm.record_query(query, args.strategy, args.success)
|
||||
print(f"Recorded: {query[:50]} → {args.strategy} ({'success' if args.success else 'fail'})")
|
||||
|
||||
elif args.cmd == "stats":
|
||||
stats = qm.get_topic_stats()
|
||||
if stats:
|
||||
print("Topic Statistics:")
|
||||
for topic, s in stats.items():
|
||||
print(
|
||||
f" {topic}: {s['total_queries']} queries, {s['successful_queries']} success, rate={s['exploration_rate']:.2f}"
|
||||
)
|
||||
else:
|
||||
print("No topic statistics yet.")
|
||||
|
||||
elif args.cmd == "rate":
|
||||
query = " ".join(args.query)
|
||||
rate = qm.adaptive_exploration_rate(query)
|
||||
topic = qm.classify_query(query)
|
||||
print(f"Query: {query[:50]}")
|
||||
print(f"Topic: {topic}")
|
||||
print(f"Exploration rate: {rate:.2f}")
|
||||
|
||||
elif args.cmd == "health":
|
||||
report = qm.get_health_report()
|
||||
print("Query Memory Health:")
|
||||
print(f" Total queries: {report['total_queries']}")
|
||||
print(f" Topics tracked: {report['topics_tracked']}")
|
||||
print(f" Dead weight topics: {report['dead_weight_topics']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -805,13 +805,22 @@ class RuneOperator:
|
||||
except Exception: # noqa: S110 — notes are best-effort by design
|
||||
pass
|
||||
|
||||
def _construction_activation(self, description: str, task_id: str) -> None:
|
||||
def _construction_activation(self, description: str, task_id: str,
|
||||
channel: str = OPERATOR_CHANNEL) -> None:
|
||||
"""SCAFFOLD-TRANCHE-0011/0012 §11: contextual construction
|
||||
classification + activation on the NORMAL input path. Deterministic;
|
||||
the mode lands in the durable scaffold store where the shared
|
||||
Construction Context (and the future TUI) reads it. Delegates all
|
||||
lifecycle decisions to construction_mode/construction_activation —
|
||||
NO duplicate intelligence here."""
|
||||
NO duplicate intelligence here.
|
||||
|
||||
§17J criterion 1 (T22 wiring): the canonical intent layer
|
||||
(construction_intent) resolves MEANING before the ONE
|
||||
classifier decides mode — the boundary the intent module
|
||||
documents ("the intent layer resolves MEANING; classify_mode
|
||||
decides mode"). A turn the intent layer resolves as
|
||||
DISCUSSION is not a construction demand, so no construction
|
||||
state is recorded for it."""
|
||||
from .construction_mode import classify_mode, _STANDALONE_WORDS
|
||||
from .construction_activation import activate
|
||||
# §15J case F: manual override feeds the SAME decision path — a
|
||||
@@ -849,6 +858,56 @@ class RuneOperator:
|
||||
from .scaffold import Scaffold
|
||||
s = Scaffold(self._construction_store(), "issue161-harness")
|
||||
doc = s.load()
|
||||
# §17J criterion 1: meaning gate BEFORE the classifier.
|
||||
# Context = the operator's own canonical conversation window
|
||||
# (no new state store); the current message is excluded so it
|
||||
# is not double-recorded as its own referent.
|
||||
try:
|
||||
from .construction_intent import (
|
||||
ConversationContext, resolve_construction_intent)
|
||||
ctx = ConversationContext()
|
||||
for _m in self.conversation_window(channel, n=8):
|
||||
_txt = str(_m.get("content", ""))
|
||||
if _txt and _txt != description:
|
||||
ctx.record_turn(str(_m.get("role", "user")), _txt)
|
||||
_intent = resolve_construction_intent(description, ctx)
|
||||
except Exception as exc: # noqa: BLE001 — intent layer
|
||||
# unavailable: PRESERVE the pre-existing behaviour exactly
|
||||
# (classify+activate) rather than silently changing
|
||||
# governance in an unproven direction. The note is appended to
|
||||
# THIS doc (not via _construction_note, which does its own
|
||||
# load/save) because the normal path saves `doc` afterwards —
|
||||
# a separate write would be clobbered (lost update).
|
||||
_intent = None
|
||||
doc.setdefault("observations", []).append({
|
||||
"kind": "construction_note",
|
||||
"text": (f"intent layer unavailable ({exc}); "
|
||||
f"classifier path unchanged")[:300],
|
||||
"observed_at": __import__("datetime").datetime.utcnow()
|
||||
.strftime("%Y-%m-%dT%H:%M:%SZ")})
|
||||
# An explicit user override is USER AUTHORITY and outranks the
|
||||
# meaning gate (§15J case F: the override is already carried with
|
||||
# user provenance into the SAME classify_mode call).
|
||||
if (override is None and _intent is not None
|
||||
and _intent.action_intent == "DISCUSSION"):
|
||||
# The intent layer resolved this turn as DISCUSSION: it is not a
|
||||
# construction demand, so no construction state is created. The
|
||||
# record keeps the SAME canonical observation kind/shape every
|
||||
# other input produces (mode vocabulary unchanged — DISCUSSION
|
||||
# maps to NOT_CONSTRUCTION, "no construction state"), with the
|
||||
# intent evidence retained so the verdict is auditable.
|
||||
doc.setdefault("observations", []).append({
|
||||
"kind": "construction_activated",
|
||||
"task": task_id,
|
||||
"mode": "NOT_CONSTRUCTION",
|
||||
"activated": False,
|
||||
"lifecycle": "",
|
||||
"classification_evidence": _intent.classification_evidence[:3],
|
||||
"intent_layer": "DISCUSSION",
|
||||
"observed_at": __import__("datetime").datetime.utcnow()
|
||||
.strftime("%Y-%m-%dT%H:%M:%SZ")})
|
||||
s.save(doc)
|
||||
return
|
||||
rec = activate(mode_decision, need=type("N", (), {
|
||||
"objective": description,
|
||||
"project": "rune",
|
||||
@@ -929,7 +988,8 @@ class RuneOperator:
|
||||
# failure NEVER blocks the ordinary task path (same lifecycle
|
||||
# guarantee as the route above).
|
||||
try:
|
||||
self._construction_activation(description, state_task.task_id)
|
||||
self._construction_activation(description, state_task.task_id,
|
||||
channel=channel)
|
||||
except Exception as exc: # noqa: BLE001 — construction must not break tasks
|
||||
try:
|
||||
self._task_state.transition(
|
||||
|
||||
+16
-6
@@ -8,7 +8,7 @@ mode system, subject header, shared memory and identity.
|
||||
|
||||
This is not an interface. This is how I see, speak, and interact with the world.
|
||||
|
||||
Version: v0.86.0
|
||||
Version: v0.90.0
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -74,6 +74,14 @@ import voice_sync
|
||||
import provider_registry
|
||||
import goal
|
||||
import rune_operator
|
||||
import tui_transport as _tui_transport
|
||||
|
||||
|
||||
def _operator_transport():
|
||||
"""H4 slice 1 transport seam: the TUI's operator calls route through
|
||||
tui_transport (RUNE_TUI_TRANSPORT=inprocess|v1, default inprocess —
|
||||
zero behavior change until the wave opts in)."""
|
||||
return _tui_transport.get_transport()
|
||||
import cron_engine
|
||||
import night_mode
|
||||
import time_awareness
|
||||
@@ -1144,7 +1152,7 @@ class ChannelView(Vertical):
|
||||
TUI must never fail to mount because the ledger is unavailable.
|
||||
"""
|
||||
try:
|
||||
window = rune_operator.get_conversation_window(self.channel_id, n=8)
|
||||
window = _operator_transport().get_conversation_window(self.channel_id, n=8)
|
||||
except Exception:
|
||||
return
|
||||
for msg in window:
|
||||
@@ -1705,6 +1713,8 @@ class RuneApp(App):
|
||||
the load_tasks fallback then handles."""
|
||||
try:
|
||||
import rune_operator as _ro
|
||||
# H4: private _task_state peek — stays in-process by design
|
||||
# (the /v1 surface carries the four public calls only).
|
||||
return getattr(_ro.get_operator(), "_task_state", None)
|
||||
except Exception as exc: # noqa: BLE001 — deliberate degradation: startup ordering; the fallback path handles it and the degradation is logged
|
||||
print(f"[task-loader] canonical repo unavailable ({exc}); "
|
||||
@@ -1885,7 +1895,7 @@ class RuneApp(App):
|
||||
# Show version info
|
||||
self.chat_channel.add_message(
|
||||
"system",
|
||||
f"[dim]Rune v0.86.0 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.90.0 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
@@ -2809,7 +2819,7 @@ class RuneApp(App):
|
||||
|
||||
def _cmd_rune(self, arg):
|
||||
try:
|
||||
status = rune_operator.get_status()
|
||||
status = _operator_transport().get_status()
|
||||
lines = ["[bold]🎭 Rune[/bold]"]
|
||||
lines.append(f" Identity: {status['identity']}")
|
||||
lines.append(f" Role: {status['role']}")
|
||||
@@ -3621,7 +3631,7 @@ class RuneApp(App):
|
||||
# Rune sends task to model with the selected model.
|
||||
# channel is forwarded so the canonical task ledger (#18)
|
||||
# keeps per-channel task identity.
|
||||
result = rune_operator.send_task(text, model=model_name, channel=channel)
|
||||
result = _operator_transport().send_task(text, model=model_name, channel=channel)
|
||||
|
||||
# Remove thinking indicator
|
||||
thinking.stop()
|
||||
@@ -3855,7 +3865,7 @@ class RuneApp(App):
|
||||
button.disabled = True
|
||||
|
||||
async def _resolve() -> None:
|
||||
result = rune_operator.resolve_pending_approval(event.approved)
|
||||
result = _operator_transport().resolve_pending_approval(event.approved)
|
||||
channel = widget.channel
|
||||
ch = self.task_channel if channel == "task" else self.chat_channel
|
||||
log = ch.query_one(f"#log-{ch.channel_id}", VerticalScroll)
|
||||
|
||||
@@ -57,7 +57,7 @@ from rune.structural_store import ( # shared generic core — no Rune constants
|
||||
task_entities,
|
||||
)
|
||||
|
||||
__all__ = ["Scaffold", "PHASES", "LEGAL_TRANSITIONS", "REPO_PLACEMENTS", "REPO_SECRET_FIELDS"]
|
||||
__all__ = ["Scaffold", "PHASES", "LEGAL_TRANSITIONS", "TERMINAL_PHASES", "REPO_PLACEMENTS", "REPO_SECRET_FIELDS"]
|
||||
|
||||
# ── constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -73,6 +73,10 @@ LEGAL_TRANSITIONS = {
|
||||
"failed": {"building", "abandoned"}, # retry path
|
||||
"abandoned": set(),
|
||||
}
|
||||
# Terminal phases are DERIVED from the canonical transition table: a
|
||||
# phase with no legal successor ends the contract. One authority —
|
||||
# consumers must not re-declare their own terminal list.
|
||||
TERMINAL_PHASES = frozenset(p for p, nxt in LEGAL_TRANSITIONS.items() if not nxt)
|
||||
STATUSES = {"open", "closed", "failed", "abandoned"}
|
||||
REPO_PLACEMENTS = {"RUNE_REPO", "EXISTING_PROJECT_REPO", "NEW_MANAGED_REPO"}
|
||||
REPO_SECRET_FIELDS = {"token", "api_key", "password", "secret", "auth_header", "credential", "api_token"}
|
||||
|
||||
+448
-133
@@ -148,6 +148,141 @@ def _canary_registry() -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
# ── meta-scaffold surface projections (H-meta, contract
|
||||
# BUILDER-2026-10-04T06:24:56Z-cli): every part of rune gets a modeled
|
||||
# entity. These projectors run in the SEEDED derivation path (build()
|
||||
# after carry-forward), so they fire on every rebuild — manifest ingest
|
||||
# alone would only fire on a cold store.
|
||||
|
||||
WORKFLOWS_DIR = ROOT / ".gitea" / "workflows"
|
||||
|
||||
|
||||
def _declared_surfaces() -> dict:
|
||||
"""Umbrella-declared non-module surfaces: rust crates, Go surfaces,
|
||||
configs. The manifest (config/umbrellas.json) is the declaration
|
||||
authority — same law as modules, new kinds."""
|
||||
try:
|
||||
manifest = json.loads((ROOT / "config" / "umbrellas.json").read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"rust_crates": [], "go_surfaces": [], "configs": []}
|
||||
out = {"rust_crates": [], "go_surfaces": [], "configs": []}
|
||||
for name, u in manifest.get("umbrellas", {}).items():
|
||||
for crate in u.get("rust_crates", []):
|
||||
out["rust_crates"].append((crate, name))
|
||||
for surface in u.get("go_surfaces", []):
|
||||
out["go_surfaces"].append((surface, name))
|
||||
for cfg in u.get("configs", []):
|
||||
out["configs"].append((cfg, name))
|
||||
return out
|
||||
|
||||
|
||||
def project_meta_surfaces(doc: dict, inputs: dict | None = None) -> int:
|
||||
"""Project rust-crate / go-surface / config / workflow entities.
|
||||
|
||||
Deterministic: declared surfaces come from the umbrella manifest,
|
||||
workflows from a sorted filesystem scan. Ids follow the kind:name
|
||||
convention (rust-crate:evidence_provenance, go-surface:rune-entry,
|
||||
config:go-version.txt, workflow:ci.yml).
|
||||
|
||||
H3-spine: when `inputs` is given (the gather-driven path), every
|
||||
filesystem probe comes from the gathered dict so the Rust core and the
|
||||
Python fallback consume byte-identical scans. The no-inputs path gathers
|
||||
on the spot (backward compatibility for direct callers)."""
|
||||
count = 0
|
||||
if inputs is None:
|
||||
inputs = _gather_meta_inputs()
|
||||
manifest = inputs.get("config_manifest") or {}
|
||||
declared = {"rust_crates": [], "go_surfaces": [], "configs": []}
|
||||
for name, u in manifest.get("umbrellas", {}).items():
|
||||
for crate in u.get("rust_crates", []):
|
||||
declared["rust_crates"].append((crate, name))
|
||||
for surface in u.get("go_surfaces", []):
|
||||
declared["go_surfaces"].append((surface, name))
|
||||
for cfg in u.get("configs", []):
|
||||
declared["configs"].append((cfg, name))
|
||||
crate_dirs = set(inputs.get("crate_dirs", []))
|
||||
go_dirs = set(inputs.get("go_dirs", []))
|
||||
config_files = set(inputs.get("config_files", []))
|
||||
crate_canaries = inputs.get("crate_canaries", {})
|
||||
for crate, umbrella in declared["rust_crates"]:
|
||||
cid = f"rust-crate:{crate}"
|
||||
add_entity(doc, id=cid, kind="rust-crate", tags=["code"],
|
||||
identities={"source": f"src/rust/{crate}"},
|
||||
evidence_refs=[f"umbrella-manifest#{umbrella}"],
|
||||
maturity="PRESENT" if crate in crate_dirs else "DECLARED")
|
||||
add_relationship(doc, type="belongs_to", from_id=cid,
|
||||
to_id=f"subsystem:{umbrella}",
|
||||
evidence_refs=[f"umbrella-manifest#{umbrella}"])
|
||||
# crate-resident canaries: projected from the crate dir itself —
|
||||
# they fail the src/rune manifest-canary convention by design
|
||||
# (they verify the crate, they belong to the crate).
|
||||
if crate in crate_dirs:
|
||||
for stem in crate_canaries.get(crate, []):
|
||||
mid = f"module:{stem}"
|
||||
add_entity(doc, id=mid, kind="module", tags=["code", "canary"],
|
||||
identities={"source": f"src/rust/{crate}/{stem}.py"},
|
||||
evidence_refs=[f"crate-scan:{crate}"],
|
||||
maturity="PRESENT")
|
||||
add_relationship(doc, type="obligated_canary", from_id=cid,
|
||||
to_id=mid, evidence_refs=[f"crate-scan:{crate}"])
|
||||
count += 1
|
||||
for surface, umbrella in declared["go_surfaces"]:
|
||||
gid = f"go-surface:{surface}"
|
||||
add_entity(doc, id=gid, kind="go-surface", tags=["code"],
|
||||
identities={"source": f"src/go/{surface}"},
|
||||
evidence_refs=[f"umbrella-manifest#{umbrella}"],
|
||||
maturity="PRESENT" if surface in go_dirs else "DECLARED")
|
||||
add_relationship(doc, type="belongs_to", from_id=gid,
|
||||
to_id=f"subsystem:{umbrella}",
|
||||
evidence_refs=[f"umbrella-manifest#{umbrella}"])
|
||||
count += 1
|
||||
for cfg, umbrella in declared["configs"]:
|
||||
cid = f"config:{cfg}"
|
||||
add_entity(doc, id=cid, kind="config",
|
||||
identities={"source": cfg},
|
||||
evidence_refs=[f"umbrella-manifest#{umbrella}"],
|
||||
maturity="PRESENT" if cfg in config_files else "DECLARED")
|
||||
add_relationship(doc, type="belongs_to", from_id=cid,
|
||||
to_id=f"subsystem:{umbrella}",
|
||||
evidence_refs=[f"umbrella-manifest#{umbrella}"])
|
||||
count += 1
|
||||
for wf in inputs.get("workflows", []):
|
||||
wid = f"workflow:{wf}"
|
||||
add_entity(doc, id=wid, kind="workflow", tags=["infra"],
|
||||
identities={"source": f".gitea/workflows/{wf}"},
|
||||
evidence_refs=["workflow-scan"],
|
||||
maturity="PRESENT")
|
||||
add_relationship(doc, type="belongs_to", from_id=wid,
|
||||
to_id="subsystem:governance",
|
||||
evidence_refs=["workflow-scan"])
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _gather_meta_inputs() -> dict:
|
||||
"""The meta-surface scan slice (shared by the full build gather)."""
|
||||
declared = _declared_surfaces()
|
||||
crate_dirs = [c for c, _ in declared["rust_crates"]
|
||||
if (ROOT / "src" / "rust" / c).is_dir()]
|
||||
try:
|
||||
config_manifest = json.loads((ROOT / "config" / "umbrellas.json").read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
config_manifest = None
|
||||
return {
|
||||
"config_manifest": config_manifest,
|
||||
"crate_dirs": crate_dirs,
|
||||
"crate_canaries": {
|
||||
c: sorted(cf.stem for cf in (ROOT / "src" / "rust" / c).glob("canary_*.py"))
|
||||
for c in crate_dirs
|
||||
},
|
||||
"go_dirs": [g for g, _ in declared["go_surfaces"]
|
||||
if (ROOT / "src" / "go" / g).is_dir()],
|
||||
"config_files": [cfg for cfg, _ in declared["configs"] if (ROOT / cfg).is_file()],
|
||||
"workflows": (sorted(wf.name for wf in WORKFLOWS_DIR.glob("*.yml"))
|
||||
if WORKFLOWS_DIR.is_dir() else []),
|
||||
}
|
||||
|
||||
|
||||
def _deployed_observation() -> dict | None:
|
||||
"""Observe the deployment authority — never invent it."""
|
||||
ident = DEPLOYED_LINK / "src" / "rune" / "_build_identity.json"
|
||||
@@ -174,15 +309,244 @@ def ingest_umbrellas(doc: dict, manifest_path: Path | None = None) -> int:
|
||||
"""Phase A: translate the (last manually maintained) umbrella manifest
|
||||
into Spine entities + relationships. Refuses a Spine-GENERATED view —
|
||||
that guards the ingest cycle after Phase C makes the view output-only.
|
||||
Returns count of subsystems ingested."""
|
||||
Returns count of subsystems ingested. (File-driven wrapper; the logic
|
||||
lives in _ingest_umbrellas_data so the gathered path shares it.)"""
|
||||
path = manifest_path or UMBRELLA_MANIFEST
|
||||
text = path.read_text()
|
||||
data = json.loads(text)
|
||||
data = json.loads(path.read_text())
|
||||
if GENERATED_VIEW_MARKER in data:
|
||||
raise ValueError(
|
||||
f"{path} is a Spine-generated compatibility view; refusing to "
|
||||
"ingest it (Phase A reads the manual authority exactly once). "
|
||||
"Structural membership now lives in the Spine store.")
|
||||
return _ingest_umbrellas_data(doc, data)
|
||||
|
||||
|
||||
def _rust_core():
|
||||
"""The Rust spine core when available (strangler: Python fallback is
|
||||
load-bearing until deletion is proven). RUNE_SPINE_CORE=python pins the
|
||||
fallback (parity harnesses and pre-cargo environments)."""
|
||||
if os.environ.get("RUNE_SPINE_CORE", "auto") == "python":
|
||||
return None
|
||||
try:
|
||||
from rune import spine_loader
|
||||
except Exception: # noqa: BLE001 — any loader failure means fallback, never a broken build
|
||||
return None
|
||||
try:
|
||||
return spine_loader if spine_loader.available() else None
|
||||
except Exception: # noqa: BLE001 — availability probe must never crash the fallback
|
||||
return None
|
||||
|
||||
|
||||
def _gather_build_inputs(manifest_path: Path | None = None) -> dict:
|
||||
"""Every I/O read build() needs, in one place (zero-dep split law:
|
||||
Python scans, the core — Rust or Python — only assembles). Both cores
|
||||
consume this dict, so parity divergence is logic, never environment."""
|
||||
existing = load()
|
||||
seeded = bool(existing) and any(
|
||||
r["type"] == "belongs_to" for r in existing["relationships"])
|
||||
# ingest manifest: only consulted when the unseeded path needs it
|
||||
# (faithful to the original build(), which never touches the file when
|
||||
# seeded). The parse is TOLERANT here — the Python fallback goes through
|
||||
# the public ingest_umbrellas (which reads + raises like the original,
|
||||
# and stays monkeypatchable for isolation tests); the Rust core consumes
|
||||
# the parsed data on the delegated path.
|
||||
ingest_manifest = None
|
||||
ingest_path = None
|
||||
if not seeded:
|
||||
ingest_path = str(Path(manifest_path) if manifest_path else UMBRELLA_MANIFEST)
|
||||
try:
|
||||
ingest_manifest = json.loads(Path(ingest_path).read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
ingest_manifest = None
|
||||
inputs = {
|
||||
"now": now(),
|
||||
"existing": existing,
|
||||
"manifest": ingest_manifest,
|
||||
"manifest_path": ingest_path,
|
||||
"graph": {m: sorted(deps) for m, deps in _import_graph().items()},
|
||||
"registry": _canary_registry(),
|
||||
"tested": sorted(_test_backed_modules()),
|
||||
"main_present": (SRC_DIR / "__main__.py").is_file(),
|
||||
"present_modules": [p.stem for p in SRC_DIR.glob("*.py")],
|
||||
"deployed_obs": _deployed_observation(),
|
||||
"deployed_sources": _deployed_source_files(),
|
||||
"canary_files": [p.stem for p in SRC_DIR.glob("canary_*.py")],
|
||||
}
|
||||
inputs.update(_gather_meta_inputs())
|
||||
return inputs
|
||||
|
||||
|
||||
def _deployed_source_files() -> list[str]:
|
||||
"""Relative paths under DEPLOYED_LINK that exist as files — the gathered
|
||||
form of `(DEPLOYED_LINK / source).is_file()` for module sources."""
|
||||
out: list[str] = []
|
||||
for base, pattern in ((DEPLOYED_LINK / "src" / "rune", "*.py"),
|
||||
(DEPLOYED_LINK / "src" / "rust", "*/canary_*.py")):
|
||||
if base.is_dir():
|
||||
for p in base.glob(pattern):
|
||||
if p.is_file():
|
||||
out.append(p.relative_to(DEPLOYED_LINK).as_posix())
|
||||
return out
|
||||
|
||||
|
||||
def _py_build_from_inputs(inputs: dict) -> dict:
|
||||
"""Deterministic full projection from GATHERED inputs. Same inputs →
|
||||
byte-identical doc (modulo generated_at). This is the Python core; the
|
||||
Rust core (project.rs) must produce structurally identical output."""
|
||||
doc = new_doc()
|
||||
ts = inputs["now"]
|
||||
|
||||
# 1. subsystem structure — from the Spine store if it already owns
|
||||
# membership (Phase B+), else from the manual manifest (Phase A).
|
||||
existing = inputs["existing"]
|
||||
seeded = False
|
||||
if existing:
|
||||
rels = [r for r in existing["relationships"] if r["type"] == "belongs_to"]
|
||||
if rels:
|
||||
seeded = True
|
||||
# CARRY EVERYTHING FORWARD — open-world requirement. Filtering
|
||||
# by known kinds/types here would silently destroy tasks,
|
||||
# contracts, capabilities, and any future entity class on every
|
||||
# refresh (the exact TOO-CLOSED failure the contract forbids;
|
||||
# observed live: task bindings vanished through build()).
|
||||
# Module-level facts (imports, tests, maturity) are RE-DERIVED
|
||||
# below onto the carried state; everything else passes through.
|
||||
doc["entities"].extend(json.loads(json.dumps(existing["entities"])))
|
||||
doc["relationships"].extend(json.loads(json.dumps(existing["relationships"])))
|
||||
doc["observations"].extend(json.loads(json.dumps(
|
||||
existing.get("observations", []))))
|
||||
if not seeded:
|
||||
mp = inputs.get("manifest_path")
|
||||
if inputs.get("manifest") is not None and not mp:
|
||||
# gather-supplied manifest data (the Rust-parity path): ingest
|
||||
# the dict directly — no file to re-read
|
||||
_ingest_umbrellas_data(doc, inputs["manifest"])
|
||||
else:
|
||||
# through the PUBLIC ingest_umbrellas (reads + raises like the
|
||||
# original build; monkeypatchable for isolation tests)
|
||||
ingest_umbrellas(doc, Path(mp) if mp else None)
|
||||
|
||||
# 2. module-level structural facts from actual code
|
||||
graph = inputs["graph"]
|
||||
registry = inputs["registry"]
|
||||
tested = set(inputs["tested"])
|
||||
present = set(inputs["present_modules"])
|
||||
mods = {e["id"].split(":", 1)[1] for e in doc["entities"] if e["kind"] == "module"}
|
||||
# T21.4 §21.4G: the ENTRY POINT is invisible to the import graph
|
||||
# (nothing imports __main__) but is a real runtime path — seed it
|
||||
# deterministically whenever the file exists.
|
||||
if inputs["main_present"] and "__main__" not in mods:
|
||||
add_entity(doc, id="module:__main__", kind="module", maturity="PRESENT",
|
||||
identities={"path": "src/rune/__main__.py",
|
||||
"role": "TUI entry point (python3 -m rune)"},
|
||||
evidence_refs=["entry-point:python3 -m rune"])
|
||||
mods.add("__main__")
|
||||
for m in sorted(mods):
|
||||
mid = f"module:{m}"
|
||||
e = entity(doc, mid)
|
||||
# Re-derivation uses the HIGH-WATER path (add_entity upgrades only,
|
||||
# never logs): fresh evidence may be lower than the store's earned
|
||||
# state on any given pass, and that is not a demotion event — it is
|
||||
# a build. Explicit promote() logging is for evidence-gated API calls.
|
||||
def _derive(state: str, ev: str) -> None:
|
||||
add_entity(doc, id=mid, kind="module", maturity=state,
|
||||
evidence_refs=[ev])
|
||||
|
||||
if m in present:
|
||||
assert e is not None # membership loop guarantees presence
|
||||
e["identities"]["source"] = f"src/rune/{m}.py"
|
||||
_derive("PRESENT", f"source:{m}.py")
|
||||
consumers = sorted(c for c, deps in graph.items() if m in deps and c != m)
|
||||
deps = sorted(d for d in graph.get(m, []) if d in mods and d != m)
|
||||
for c in consumers:
|
||||
rel = add_relationship(doc, type="consumed_by", from_id=mid,
|
||||
to_id=f"module:{c}", evidence_refs=[f"ast-import:{m}"])
|
||||
_authority_observed(doc, rel, "ast-import-projector")
|
||||
for d in deps:
|
||||
rel = add_relationship(doc, type="depends_on", from_id=mid,
|
||||
to_id=f"module:{d}", evidence_refs=[f"ast-import:{m}"])
|
||||
_authority_observed(doc, rel, "ast-import-projector")
|
||||
# WIRED: importers exist, or registry wiring (canary registry)
|
||||
if consumers or m in registry:
|
||||
_derive("WIRED", f"ast-imports:{len(consumers)}" if consumers
|
||||
else "canary-registry")
|
||||
# EXERCISED: dedicated test or canary exercise
|
||||
if m in tested:
|
||||
_derive("EXERCISED", f"tests/test_{m}.py")
|
||||
elif m in registry:
|
||||
_derive("EXERCISED", "canary-registry-exercise")
|
||||
|
||||
# 3. runtime/deployment observation (dynamic, bounded)
|
||||
obs = inputs["deployed_obs"]
|
||||
if obs:
|
||||
_authority_observed(doc, None, "deploy-identity-projector") # generation bump
|
||||
deployed_sources = set(inputs["deployed_sources"])
|
||||
dep = add_entity(doc, id="deployment:current", kind="deployment_surface",
|
||||
identities=obs, evidence_refs=["_build_identity.json"],
|
||||
maturity="LIVE_PROVEN")
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] == "module" and e["identities"].get("source", "") in deployed_sources:
|
||||
rel = add_relationship(doc, type="deployed_as", from_id=e["id"],
|
||||
to_id="deployment:current",
|
||||
evidence_refs=[f"deployed-tree:{obs['deployed_sha']}"])
|
||||
_authority_observed(doc, rel, "deploy-identity-projector")
|
||||
doc["observations"].append({"kind": "deployed_identity", **obs, "observed_at": ts})
|
||||
|
||||
# 4. meta-scaffold surface projection (H-meta): rust crates, Go
|
||||
# surfaces, configs, workflows — every part of rune modeled.
|
||||
projected = project_meta_surfaces(doc, inputs)
|
||||
if projected:
|
||||
_authority_observed(doc, None, "meta-surface-projector")
|
||||
|
||||
# 4b. manifest canaries as first-class entities (H-meta): the seeded
|
||||
# path skips ingest, and ingest only made the EDGE — the canary
|
||||
# entity itself was never created, leaving obligated_canary edges
|
||||
# pointing at nothing (observed live: zero canary entities in the
|
||||
# spine before this projector).
|
||||
manifest = inputs.get("config_manifest")
|
||||
if manifest:
|
||||
canary_files = set(inputs["canary_files"])
|
||||
for name, u in manifest.get("umbrellas", {}).items():
|
||||
for canary in u.get("canaries", []):
|
||||
mid = f"module:{canary}"
|
||||
add_entity(doc, id=mid, kind="module", tags=["code", "canary"],
|
||||
identities={"source": f"src/rune/{canary}.py"},
|
||||
evidence_refs=[f"umbrella-manifest#{name}"],
|
||||
maturity="PRESENT" if canary in canary_files else "DECLARED")
|
||||
add_relationship(doc, type="obligated_canary",
|
||||
from_id=f"subsystem:{name}", to_id=mid,
|
||||
evidence_refs=[f"umbrella-manifest#{name}"])
|
||||
|
||||
# reconcile: facts owned by projectors that no longer observe them
|
||||
# become STALE (marked, never deleted — history survives; open-world
|
||||
# preservation applies to everything, staleness is the truth marker)
|
||||
staled = reconcile_authorities(doc)
|
||||
for s in staled:
|
||||
doc["observations"].append({"kind": "fact_staled", **s, "observed_at": ts})
|
||||
|
||||
# exact-duplicate observation suppression (carry-forward + this pass
|
||||
# both record deployed_identity etc.); distinct repeats are history
|
||||
seen_obs = set()
|
||||
unique_obs = []
|
||||
for o in doc["observations"]:
|
||||
key = json.dumps(_sorted(o), sort_keys=True)
|
||||
if key not in seen_obs:
|
||||
seen_obs.add(key)
|
||||
unique_obs.append(o)
|
||||
doc["observations"] = unique_obs
|
||||
doc["generated_at"] = ts
|
||||
return doc
|
||||
|
||||
|
||||
def _ingest_umbrellas_data(doc: dict, data: dict) -> int:
|
||||
"""The original ingest_umbrellas with the file read lifted out (H3-spine
|
||||
gather boundary). Raises ValueError on a Spine-generated view, exactly
|
||||
like the file-driven original."""
|
||||
if GENERATED_VIEW_MARKER in data:
|
||||
raise ValueError(
|
||||
"the manifest is a Spine-generated compatibility view; refusing to "
|
||||
"ingest it (Phase A reads the manual authority exactly once). "
|
||||
"Structural membership now lives in the Spine store.")
|
||||
count = 0
|
||||
KNOWN_FIELDS = {"description", "modules", "depends_on", "mirrors", "canaries"}
|
||||
for name, u in data.get("umbrellas", {}).items():
|
||||
@@ -219,125 +583,52 @@ def ingest_umbrellas(doc: dict, manifest_path: Path | None = None) -> int:
|
||||
|
||||
def build(manifest_path: Path | None = None) -> dict:
|
||||
"""Deterministic full projection from authorities. Same inputs →
|
||||
byte-identical doc (modulo generated_at)."""
|
||||
doc = new_doc()
|
||||
|
||||
# 1. subsystem structure — from the Spine store if it already owns
|
||||
# membership (Phase B+), else from the manual manifest (Phase A).
|
||||
existing = load()
|
||||
seeded = False
|
||||
if existing:
|
||||
rels = [r for r in existing["relationships"] if r["type"] == "belongs_to"]
|
||||
if rels:
|
||||
seeded = True
|
||||
# CARRY EVERYTHING FORWARD — open-world requirement. Filtering
|
||||
# by known kinds/types here would silently destroy tasks,
|
||||
# contracts, capabilities, and any future entity class on every
|
||||
# refresh (the exact TOO-CLOSED failure the contract forbids;
|
||||
# observed live: task bindings vanished through build()).
|
||||
# Module-level facts (imports, tests, maturity) are RE-DERIVED
|
||||
# below onto the carried state; everything else passes through.
|
||||
doc["entities"].extend(json.loads(json.dumps(existing["entities"])))
|
||||
doc["relationships"].extend(json.loads(json.dumps(existing["relationships"])))
|
||||
doc["observations"].extend(json.loads(json.dumps(
|
||||
existing.get("observations", []))))
|
||||
if not seeded:
|
||||
ingest_umbrellas(doc, manifest_path)
|
||||
|
||||
# 2. module-level structural facts from actual code
|
||||
graph = _import_graph()
|
||||
registry = _canary_registry()
|
||||
tested = _test_backed_modules()
|
||||
mods = {e["id"].split(":", 1)[1] for e in doc["entities"] if e["kind"] == "module"}
|
||||
# T21.4 §21.4G: the ENTRY POINT is invisible to the import graph
|
||||
# (nothing imports __main__) but is a real runtime path — seed it
|
||||
# deterministically whenever the file exists.
|
||||
if (SRC_DIR / "__main__.py").is_file() and "__main__" not in mods:
|
||||
add_entity(doc, id="module:__main__", kind="module", maturity="PRESENT",
|
||||
identities={"path": "src/rune/__main__.py",
|
||||
"role": "TUI entry point (python3 -m rune)"},
|
||||
evidence_refs=["entry-point:python3 -m rune"])
|
||||
mods.add("__main__")
|
||||
for m in sorted(mods):
|
||||
mid = f"module:{m}"
|
||||
e = entity(doc, mid)
|
||||
# Re-derivation uses the HIGH-WATER path (add_entity upgrades only,
|
||||
# never logs): fresh evidence may be lower than the store's earned
|
||||
# state on any given pass, and that is not a demotion event — it is
|
||||
# a build. Explicit promote() logging is for evidence-gated API calls.
|
||||
def _derive(state: str, ev: str) -> None:
|
||||
add_entity(doc, id=mid, kind="module", maturity=state,
|
||||
evidence_refs=[ev])
|
||||
|
||||
if (SRC_DIR / f"{m}.py").is_file():
|
||||
assert e is not None # membership loop guarantees presence
|
||||
e["identities"]["source"] = f"src/rune/{m}.py"
|
||||
_derive("PRESENT", f"source:{m}.py")
|
||||
consumers = sorted(c for c, deps in graph.items() if m in deps and c != m)
|
||||
deps = sorted(d for d in graph.get(m, set()) if d in mods and d != m)
|
||||
for c in consumers:
|
||||
rel = add_relationship(doc, type="consumed_by", from_id=mid,
|
||||
to_id=f"module:{c}", evidence_refs=[f"ast-import:{m}"])
|
||||
_authority_observed(doc, rel, "ast-import-projector")
|
||||
for d in deps:
|
||||
rel = add_relationship(doc, type="depends_on", from_id=mid,
|
||||
to_id=f"module:{d}", evidence_refs=[f"ast-import:{m}"])
|
||||
_authority_observed(doc, rel, "ast-import-projector")
|
||||
# WIRED: importers exist, or registry wiring (canary registry)
|
||||
if consumers or m in registry:
|
||||
_derive("WIRED", f"ast-imports:{len(consumers)}" if consumers
|
||||
else "canary-registry")
|
||||
# EXERCISED: dedicated test or canary exercise
|
||||
if m in tested:
|
||||
_derive("EXERCISED", f"tests/test_{m}.py")
|
||||
elif m in registry:
|
||||
_derive("EXERCISED", "canary-registry-exercise")
|
||||
|
||||
# 3. runtime/deployment observation (dynamic, bounded)
|
||||
obs = _deployed_observation()
|
||||
if obs:
|
||||
_authority_observed(doc, None, "deploy-identity-projector") # generation bump
|
||||
dep = add_entity(doc, id="deployment:current", kind="deployment_surface",
|
||||
identities=obs, evidence_refs=["_build_identity.json"],
|
||||
maturity="LIVE_PROVEN")
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] == "module" and (DEPLOYED_LINK / e["identities"].get("source", "")).is_file():
|
||||
rel = add_relationship(doc, type="deployed_as", from_id=e["id"],
|
||||
to_id="deployment:current",
|
||||
evidence_refs=[f"deployed-tree:{obs['deployed_sha']}"])
|
||||
_authority_observed(doc, rel, "deploy-identity-projector")
|
||||
doc["observations"].append({"kind": "deployed_identity", **obs, "observed_at": now()})
|
||||
|
||||
# reconcile: facts owned by projectors that no longer observe them
|
||||
# become STALE (marked, never deleted — history survives; open-world
|
||||
# preservation applies to everything, staleness is the truth marker)
|
||||
staled = reconcile_authorities(doc)
|
||||
for s in staled:
|
||||
doc["observations"].append({"kind": "fact_staled", **s, "observed_at": now()})
|
||||
|
||||
# exact-duplicate observation suppression (carry-forward + this pass
|
||||
# both record deployed_identity etc.); distinct repeats are history
|
||||
seen_obs = set()
|
||||
unique_obs = []
|
||||
for o in doc["observations"]:
|
||||
key = json.dumps(_sorted(o), sort_keys=True)
|
||||
if key not in seen_obs:
|
||||
seen_obs.add(key)
|
||||
unique_obs.append(o)
|
||||
doc["observations"] = unique_obs
|
||||
doc["generated_at"] = now()
|
||||
return doc
|
||||
byte-identical doc (modulo generated_at). H3-spine: gathers the scans
|
||||
once, then delegates to the Rust core when built (Python fallback —
|
||||
strangler law: the old path stays load-bearing until deletion is proven)."""
|
||||
inputs = _gather_build_inputs(manifest_path)
|
||||
core = _rust_core()
|
||||
# The unseeded path is the one-time Phase A migration: it stays on the
|
||||
# Python core so the public ingest_umbrellas seam (monkeypatched by
|
||||
# isolation tests) is honored byte-for-byte. Steady-state (seeded)
|
||||
# builds delegate to the Rust core; the parity battery covers BOTH
|
||||
# paths, including an explicit unseeded-ingest parity section.
|
||||
seeded = bool(inputs["existing"]) and any(
|
||||
r["type"] == "belongs_to" for r in inputs["existing"]["relationships"])
|
||||
if core is not None and seeded:
|
||||
return core.project(inputs)
|
||||
return _py_build_from_inputs(inputs)
|
||||
|
||||
|
||||
# ── drift ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def drift(doc: dict) -> list[dict]:
|
||||
"""Architectural drift classes. Reuses authorities' verdicts; never
|
||||
duplicates their logic. Deterministic output."""
|
||||
def _gather_drift_inputs() -> dict:
|
||||
"""Every I/O read drift() needs (zero-dep split law — same gathered dict
|
||||
feeds the Rust core and the Python fallback)."""
|
||||
try:
|
||||
src_version = (ROOT / "pyproject.toml").read_text()
|
||||
import re
|
||||
m = re.search(r'^version\s*=\s*"([^"]+)"', src_version, re.M)
|
||||
src_version = m.group(1) if m else ""
|
||||
except OSError:
|
||||
src_version = ""
|
||||
return {
|
||||
"registry": _canary_registry(),
|
||||
"graph": {m: sorted(deps) for m, deps in _import_graph().items()},
|
||||
"tested": sorted(_test_backed_modules()),
|
||||
"src_stems": [p.stem for p in SRC_DIR.glob("*.py")],
|
||||
"deployed_obs": _deployed_observation(),
|
||||
"src_version": src_version,
|
||||
}
|
||||
|
||||
|
||||
def _py_drift_from_inputs(doc: dict, inputs: dict) -> list[dict]:
|
||||
"""Architectural drift classes from GATHERED inputs. Python core; the
|
||||
Rust core (drift.rs) must produce identical output on the same inputs."""
|
||||
out = []
|
||||
graph = _import_graph()
|
||||
registry = _canary_registry()
|
||||
tested = _test_backed_modules()
|
||||
graph = inputs["graph"]
|
||||
registry = inputs["registry"]
|
||||
tested = set(inputs["tested"])
|
||||
mods = {e["id"].split(":", 1)[1] for e in doc["entities"] if e["kind"] == "module"}
|
||||
|
||||
# 1. registered but absent (canary registry names a non-existent module)
|
||||
@@ -349,11 +640,11 @@ def drift(doc: dict) -> list[dict]:
|
||||
# 2. present but unowned (runtime source file outside every subsystem).
|
||||
# Excludes canary files (governed by the canary contract, not umbrella
|
||||
# membership) and package plumbing (__init__/__main__).
|
||||
for p in SRC_DIR.glob("*.py"):
|
||||
if p.stem.startswith(("canary_", "__")):
|
||||
for stem in inputs["src_stems"]:
|
||||
if stem.startswith(("canary_", "__")):
|
||||
continue
|
||||
if p.stem not in mods:
|
||||
out.append({"class": "present_but_unowned", "entity": f"module:{p.stem}",
|
||||
if stem not in mods:
|
||||
out.append({"class": "present_but_unowned", "entity": f"module:{stem}",
|
||||
"detail": "src/rune runtime module with no subsystem membership"})
|
||||
|
||||
# 3. registered but no static importers — OBSERVATION, not a death
|
||||
@@ -368,14 +659,8 @@ def drift(doc: dict) -> list[dict]:
|
||||
"detail": f"zero static importers; dynamic wiring possible; {dyn}"})
|
||||
|
||||
# 4. deployed identity drift (source version vs deployment authority)
|
||||
obs = _deployed_observation()
|
||||
try:
|
||||
src_version = (ROOT / "pyproject.toml").read_text()
|
||||
import re
|
||||
m = re.search(r'^version\s*=\s*"([^"]+)"', src_version, re.M)
|
||||
src_version = m.group(1) if m else ""
|
||||
except OSError:
|
||||
src_version = ""
|
||||
obs = inputs["deployed_obs"]
|
||||
src_version = inputs["src_version"]
|
||||
if obs and src_version and obs["deployed_version"] != src_version:
|
||||
out.append({"class": "version_surfaces_disagree",
|
||||
"detail": f"source {src_version} != deployed {obs['deployed_version']}"})
|
||||
@@ -405,6 +690,17 @@ def drift(doc: dict) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def drift(doc: dict) -> list[dict]:
|
||||
"""Architectural drift classes. Reuses authorities' verdicts; never
|
||||
duplicates their logic. Deterministic output. H3-spine: gathers the
|
||||
scans once, delegates to the Rust core when built (Python fallback)."""
|
||||
inputs = _gather_drift_inputs()
|
||||
core = _rust_core()
|
||||
if core is not None:
|
||||
return core.drift(doc, inputs)
|
||||
return _py_drift_from_inputs(doc, inputs)
|
||||
|
||||
|
||||
# ── snapshots / deltas ────────────────────────────────────────────────────
|
||||
|
||||
def snapshot(doc: dict, label: str = "") -> Path:
|
||||
@@ -421,9 +717,9 @@ def snapshot(doc: dict, label: str = "") -> Path:
|
||||
|
||||
# ── compatibility projection (Phase C) ────────────────────────────────────
|
||||
|
||||
def export_umbrellas(doc: dict) -> dict:
|
||||
"""Generate the legacy umbrella view FROM Spine. Output only — the old
|
||||
consumers (CI gate, henxels) read this during migration. Never ingest."""
|
||||
def _py_export_umbrellas(doc: dict) -> dict:
|
||||
"""Python core of export_umbrellas (H3-spine: the Rust core in query.rs
|
||||
must produce identical output modulo generated_at)."""
|
||||
umbrellas: dict = {}
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] != "subsystem":
|
||||
@@ -464,6 +760,16 @@ def export_umbrellas(doc: dict) -> dict:
|
||||
"umbrellas": dict(sorted(umbrellas.items()))}
|
||||
|
||||
|
||||
def export_umbrellas(doc: dict) -> dict:
|
||||
"""Generate the legacy umbrella view FROM Spine. Output only — the old
|
||||
consumers (CI gate, henxels) read this during migration. Never ingest.
|
||||
H3-spine: delegates to the Rust core when built (Python fallback)."""
|
||||
core = _rust_core()
|
||||
if core is not None:
|
||||
return core.export_umbrellas(doc, now())
|
||||
return _py_export_umbrellas(doc)
|
||||
|
||||
|
||||
# ── contracts, task binding, impact frontier (T22 + graph addenda) ────────
|
||||
#
|
||||
# GRAPH SEPARATION: Spine is its own LOGICAL graph — current structural
|
||||
@@ -503,7 +809,16 @@ FRONTIER_LEVEL2_TYPES = {"verified_by", "obligated_canary"}
|
||||
|
||||
def query(doc: dict, entity_id: str) -> dict:
|
||||
"""INTRINSIC SELF-KNOWLEDGE: one deterministic call answering the
|
||||
representative structural question set. No LLM, no Memory, no rediscovery."""
|
||||
representative structural question set. No LLM, no Memory, no rediscovery.
|
||||
H3-spine: delegates to the Rust core when built (Python fallback)."""
|
||||
core = _rust_core()
|
||||
if core is not None:
|
||||
return core.query(doc, entity_id)
|
||||
return _py_query(doc, entity_id)
|
||||
|
||||
|
||||
def _py_query(doc: dict, entity_id: str) -> dict:
|
||||
"""Python core of query (parity oracle for the Rust core)."""
|
||||
e = entity(doc, entity_id)
|
||||
if e is None:
|
||||
return {"error": "not found", "id": entity_id}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ctypes loader for the Rust rune_spine core (H3-spine strangler, T4.8).
|
||||
|
||||
Contract BUILDER-2026-10-04T07:19:36Z-cli. Gives spine.py the Rust projection
|
||||
machinery through the cdylib with no subprocess. I/O (filesystem scans, JSON
|
||||
file reads) stays in spine.py per the zero-dep split law; this loader receives
|
||||
fully-gathered input dicts and returns pure results.
|
||||
|
||||
FFI contract (doc-envelope law — every call returns {"ok", "value"|"err"}):
|
||||
* sp_project(inputs_json) -> doc
|
||||
* sp_drift({doc, inputs}) -> drift list
|
||||
* sp_query({doc, id}) -> query result
|
||||
* sp_export({doc, now}) -> umbrella compatibility view
|
||||
* sp_free_string(ptr)
|
||||
|
||||
FFI segfault law: every symbol gets explicit argtypes + restype (c_void_p for
|
||||
returned char* — the default c_int truncates 64-bit pointers, exit-139 class).
|
||||
|
||||
Zero dependencies. The .so resolves against this repo's crate target dir;
|
||||
override with SP_RUST_LIB (a FILE path — binaries resolve next to it).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[2]
|
||||
_CRATE_DIR = _REPO / "src" / "rust" / "rune_spine"
|
||||
_LIB_PATH = Path(
|
||||
os.environ.get(
|
||||
"SP_RUST_LIB", _CRATE_DIR / "target" / "release" / "librune_spine.so"
|
||||
)
|
||||
)
|
||||
|
||||
_lib = None
|
||||
|
||||
|
||||
class SpineCoreUnavailable(FileNotFoundError):
|
||||
"""The Rust core is not built (or SP_RUST_LIB points nowhere). Callers
|
||||
fall back to the Python implementation — strangler law: old code is
|
||||
deleted only when the new one is proven, so the fallback is load-bearing."""
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
return _LIB_PATH.is_file()
|
||||
|
||||
|
||||
def _load() -> ctypes.CDLL:
|
||||
global _lib
|
||||
if _lib is not None:
|
||||
return _lib
|
||||
if not _LIB_PATH.exists():
|
||||
raise SpineCoreUnavailable(
|
||||
f"Rust spine core not built: {_LIB_PATH} missing — run "
|
||||
f"`cargo build --release` in {_CRATE_DIR}"
|
||||
)
|
||||
lib = ctypes.CDLL(str(_LIB_PATH))
|
||||
lib.sp_free_string.argtypes = [ctypes.c_void_p]
|
||||
lib.sp_free_string.restype = None
|
||||
for sym in ("sp_project", "sp_drift", "sp_query", "sp_export"):
|
||||
fn = getattr(lib, sym)
|
||||
fn.argtypes = [ctypes.c_char_p]
|
||||
fn.restype = ctypes.c_void_p # char* — never default c_int
|
||||
_lib = lib
|
||||
return lib
|
||||
|
||||
|
||||
def _call(sym: str, payload: dict):
|
||||
lib = _load()
|
||||
fn = getattr(lib, sym)
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
ptr = fn(raw)
|
||||
if not ptr:
|
||||
raise RuntimeError(f"{sym} returned a null pointer")
|
||||
try:
|
||||
text = ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8")
|
||||
finally:
|
||||
lib.sp_free_string(ptr)
|
||||
env = json.loads(text)
|
||||
if not env.get("ok"):
|
||||
raise RuntimeError(f"{sym} core error: {env.get('err')}")
|
||||
return env["value"]
|
||||
|
||||
|
||||
def project(inputs: dict) -> dict:
|
||||
"""Full spine projection from gathered inputs (spine.build's core)."""
|
||||
return _call("sp_project", inputs)
|
||||
|
||||
|
||||
def drift(doc: dict, inputs: dict) -> list:
|
||||
"""Drift classification from a doc + gathered drift inputs."""
|
||||
return _call("sp_drift", {"doc": doc, "inputs": inputs})
|
||||
|
||||
|
||||
def query(doc: dict, entity_id: str) -> dict:
|
||||
"""Intrinsic self-knowledge query against a doc."""
|
||||
return _call("sp_query", {"doc": doc, "id": entity_id})
|
||||
|
||||
|
||||
def export_umbrellas(doc: dict, now: str) -> dict:
|
||||
"""Legacy umbrella compatibility view (output-only)."""
|
||||
return _call("sp_export", {"doc": doc, "now": now})
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ctypes loader for the Rust token_counting core (H3 slice 5, T4.4).
|
||||
|
||||
Contract BUILDER-2026-10-04T09:57:23Z-cli. Gives token_optimizer the Rust
|
||||
counting/compression helpers through the cdylib with no subprocess.
|
||||
|
||||
FFI contract (doc-envelope law — every call returns {"ok", "value"|"err"}):
|
||||
* tc_estimate_tokens({text}) -> int
|
||||
* tc_compress_context({text}) -> string
|
||||
* tc_free_string(ptr)
|
||||
|
||||
FFI segfault law: every symbol gets explicit argtypes + restype (c_void_p
|
||||
for returned char*). Zero dependencies; override the .so with TC_RUST_LIB
|
||||
(a FILE path).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[2]
|
||||
_CRATE_DIR = _REPO / "src" / "rust" / "token_counting"
|
||||
_LIB_PATH = Path(
|
||||
os.environ.get(
|
||||
"TC_RUST_LIB", _CRATE_DIR / "target" / "release" / "libtoken_counting.so"
|
||||
)
|
||||
)
|
||||
|
||||
_lib = None
|
||||
|
||||
|
||||
class TokenCountingUnavailable(FileNotFoundError):
|
||||
"""The Rust core is not built (or TC_RUST_LIB points nowhere). Callers
|
||||
fall back to the Python implementation — strangler law."""
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
return _LIB_PATH.is_file()
|
||||
|
||||
|
||||
def _load() -> ctypes.CDLL:
|
||||
global _lib
|
||||
if _lib is not None:
|
||||
return _lib
|
||||
if not _LIB_PATH.exists():
|
||||
raise TokenCountingUnavailable(
|
||||
f"Rust token_counting core not built: {_LIB_PATH} missing — run "
|
||||
f"`cargo build --release` in {_CRATE_DIR}"
|
||||
)
|
||||
lib = ctypes.CDLL(str(_LIB_PATH))
|
||||
lib.tc_free_string.argtypes = [ctypes.c_void_p]
|
||||
lib.tc_free_string.restype = None
|
||||
for sym in ("tc_estimate_tokens", "tc_compress_context"):
|
||||
fn = getattr(lib, sym)
|
||||
fn.argtypes = [ctypes.c_char_p]
|
||||
fn.restype = ctypes.c_void_p # char* — never default c_int
|
||||
_lib = lib
|
||||
return lib
|
||||
|
||||
|
||||
def _call(sym: str, payload: dict):
|
||||
lib = _load()
|
||||
fn = getattr(lib, sym)
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
ptr = fn(raw)
|
||||
if not ptr:
|
||||
raise RuntimeError(f"{sym} returned a null pointer")
|
||||
try:
|
||||
text = ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8")
|
||||
finally:
|
||||
lib.tc_free_string(ptr)
|
||||
env = json.loads(text)
|
||||
if not env.get("ok"):
|
||||
raise RuntimeError(f"{sym} core error: {env.get('err')}")
|
||||
return env["value"]
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""1 token ≈ 4 chars (code points), floor division."""
|
||||
return int(_call("tc_estimate_tokens", {"text": text}))
|
||||
|
||||
|
||||
def compress_context(text: str) -> str:
|
||||
"""Newline/space run collapse + prefix shortening + strip."""
|
||||
return _call("tc_compress_context", {"text": text})
|
||||
+58
-13
@@ -44,8 +44,13 @@ class CacheEntry:
|
||||
hit_count: int = 0
|
||||
|
||||
def to_dict(self):
|
||||
# `content` is required by CacheEntry; omitting it here made the
|
||||
# persisted JSON unloadable (CacheEntry(**entry_data) raised on
|
||||
# _load), which the blanket except in _load turned into silent
|
||||
# total cache loss on every restart. See issue #770.
|
||||
return {
|
||||
"key": self.key,
|
||||
"content": self.content,
|
||||
"tokens": self.tokens,
|
||||
"last_used": self.last_used,
|
||||
"use_count": self.use_count,
|
||||
@@ -116,8 +121,13 @@ class TokenOptimizer:
|
||||
self._save()
|
||||
|
||||
def estimate_tokens(self, text: str) -> int:
|
||||
"""Estimate token count (rough: 1 token ≈ 4 chars)."""
|
||||
return len(text) // 4
|
||||
"""Estimate token count (rough: 1 token ≈ 4 chars).
|
||||
|
||||
H3 slice 5: delegates to the Rust core when built (Python fallback)."""
|
||||
core = _tc_core()
|
||||
if core is not None:
|
||||
return core.estimate_tokens(text)
|
||||
return _py_estimate_tokens(text)
|
||||
|
||||
def _load_persona_definition(self) -> str:
|
||||
"""Load persona definition from persona_definition module, with fallback."""
|
||||
@@ -195,19 +205,14 @@ class TokenOptimizer:
|
||||
return compressed
|
||||
|
||||
def _compress_context(self, context: str) -> str:
|
||||
"""Compress context to reduce tokens."""
|
||||
# Remove multiple newlines
|
||||
compressed = re.sub(r"\n{3,}", "\n\n", context)
|
||||
"""Compress context to reduce tokens.
|
||||
|
||||
# Remove redundant spaces
|
||||
compressed = re.sub(r" {2,}", " ", compressed)
|
||||
H3 slice 5: delegates to the Rust core when built (Python fallback)."""
|
||||
core = _tc_core()
|
||||
if core is not None:
|
||||
return core.compress_context(context)
|
||||
return _py_compress_context(context)
|
||||
|
||||
# Shorten common patterns
|
||||
compressed = compressed.replace("User:", "U:")
|
||||
compressed = compressed.replace("Assistant:", "A:")
|
||||
compressed = compressed.replace("System:", "S:")
|
||||
|
||||
return compressed.strip()
|
||||
|
||||
def optimize_prompt(self, text: str, context: str = "") -> Dict[str, Any]:
|
||||
"""Optimize a prompt by caching and compressing."""
|
||||
@@ -295,6 +300,46 @@ def get_optimizer() -> TokenOptimizer:
|
||||
return _optimizer
|
||||
|
||||
|
||||
# ── pure helpers (H3 slice 5: Rust core when built; _py_ variants are the
|
||||
# load-bearing fallback AND the parity oracle).
|
||||
|
||||
def _tc_core():
|
||||
"""The Rust token_counting core when available (strangler law).
|
||||
RUNE_TOKEN_CORE=python pins the fallback."""
|
||||
if os.environ.get("RUNE_TOKEN_CORE", "auto") == "python":
|
||||
return None
|
||||
try:
|
||||
from rune import tc_loader
|
||||
except Exception: # noqa: BLE001 — any loader failure means fallback, never a broken call
|
||||
return None
|
||||
try:
|
||||
return tc_loader if tc_loader.available() else None
|
||||
except Exception: # noqa: BLE001 — availability probe must never crash the fallback
|
||||
return None
|
||||
|
||||
|
||||
def _py_estimate_tokens(text: str) -> int:
|
||||
"""Estimate token count (rough: 1 token ≈ 4 chars) — parity oracle."""
|
||||
return len(text) // 4
|
||||
|
||||
|
||||
def _py_compress_context(context: str) -> str:
|
||||
"""Compress context to reduce tokens — parity oracle."""
|
||||
# Remove multiple newlines
|
||||
compressed = re.sub(r"\n{3,}", "\n\n", context)
|
||||
|
||||
# Remove redundant spaces
|
||||
compressed = re.sub(r" {2,}", " ", compressed)
|
||||
|
||||
# Shorten common patterns
|
||||
compressed = compressed.replace("User:", "U:")
|
||||
compressed = compressed.replace("Assistant:", "A:")
|
||||
compressed = compressed.replace("System:", "S:")
|
||||
|
||||
return compressed.strip()
|
||||
|
||||
|
||||
|
||||
def optimize_prompt(text: str, context: str = "") -> Dict[str, Any]:
|
||||
"""Optimize a prompt."""
|
||||
return get_optimizer().optimize_prompt(text, context)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tui_transport.py — the TUI transport seam (H4 slice 1, T4.5).
|
||||
|
||||
Contract BUILDER-2026-10-04T10:32:00Z-cli. The TUI's operator surface is
|
||||
exactly four public calls; this seam lets them run IN-PROCESS (today) or
|
||||
over the /v1 wire (the migration path) behind one interface. Selected by
|
||||
RUNE_TUI_TRANSPORT=inprocess|v1 (DEFAULT inprocess — zero behavior change
|
||||
on merge; the wave opts in surface by surface).
|
||||
|
||||
The V1Transport speaks to the Go front door (or the core sidecar directly):
|
||||
get_status -> GET /v1/status
|
||||
send_task -> POST /v1/chat/completions (rune block out)
|
||||
get_conversation_window -> GET /v1/conversation?channel=&n=
|
||||
resolve_pending_approval -> POST /v1/approvals/resolve {approved}
|
||||
|
||||
Stdlib only. Dead core raises — never a silent empty answer (canary §5).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, List
|
||||
|
||||
DEFAULT_CORE_URL = "http://127.0.0.1:8377" # the Go front door's default port
|
||||
|
||||
|
||||
class InProcessTransport:
|
||||
"""Today's behavior: direct operator calls in-process."""
|
||||
|
||||
def __init__(self, operator: Any = None):
|
||||
if operator is not None:
|
||||
self._operator = operator
|
||||
else:
|
||||
import rune_operator
|
||||
self._operator = rune_operator.get_operator()
|
||||
|
||||
def get_status(self) -> dict:
|
||||
return self._operator.get_status()
|
||||
|
||||
def send_task(self, description: str, model: str | None = None,
|
||||
channel: str = "operator") -> dict:
|
||||
return self._operator.send_task(description, model, channel)
|
||||
|
||||
def get_conversation_window(self, channel: str = "operator",
|
||||
n: int = 8) -> List[dict]:
|
||||
return self._operator.conversation_window(channel, n=n)
|
||||
|
||||
def resolve_pending_approval(self, approved: bool) -> dict:
|
||||
return self._operator.resolve_pending_approval(approved)
|
||||
|
||||
|
||||
class V1Transport:
|
||||
"""The migration path: the same four calls over the /v1 wire."""
|
||||
|
||||
def __init__(self, base_url: str | None = None, timeout: float = 130.0):
|
||||
self._base = (base_url or os.environ.get(
|
||||
"RUNE_TUI_CORE_URL", DEFAULT_CORE_URL)).rstrip("/")
|
||||
self._timeout = timeout
|
||||
|
||||
def _http(self, method: str, path: str, body: dict | None = None) -> Any:
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(
|
||||
self._base + path, data=data, method=method,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode()[:200]
|
||||
raise RuntimeError(
|
||||
f"v1 transport: {method} {path} -> {e.code}: {detail}") from e
|
||||
|
||||
def get_status(self) -> dict:
|
||||
return self._http("GET", "/v1/status")
|
||||
|
||||
def send_task(self, description: str, model: str | None = None,
|
||||
channel: str = "operator") -> dict:
|
||||
body = {"messages": [{"role": "user", "content": description}]}
|
||||
if model is not None:
|
||||
body["model"] = model
|
||||
out = self._http("POST", "/v1/chat/completions", body)
|
||||
# the full operator result rides the rune block (H2 contract)
|
||||
return out["rune"]
|
||||
|
||||
def get_conversation_window(self, channel: str = "operator",
|
||||
n: int = 8) -> List[dict]:
|
||||
out = self._http("GET", f"/v1/conversation?channel={channel}&n={n}")
|
||||
return out["messages"]
|
||||
|
||||
def resolve_pending_approval(self, approved: bool) -> dict:
|
||||
return self._http("POST", "/v1/approvals/resolve",
|
||||
{"approved": bool(approved)})
|
||||
|
||||
|
||||
def get_transport(mode: str | None = None):
|
||||
"""The seam entry point. RUNE_TUI_TRANSPORT=inprocess|v1 (default
|
||||
inprocess); the wire target is RUNE_TUI_CORE_URL (default the Go front
|
||||
door's port)."""
|
||||
mode = mode or os.environ.get("RUNE_TUI_TRANSPORT", "inprocess")
|
||||
if mode == "v1":
|
||||
return V1Transport()
|
||||
if mode == "inprocess":
|
||||
return InProcessTransport()
|
||||
raise ValueError(f"unknown RUNE_TUI_TRANSPORT mode: {mode!r} "
|
||||
"(expected inprocess|v1)")
|
||||
+45
-115
@@ -7,6 +7,15 @@ file_changed, tool_receipt, constraint_check.
|
||||
Usage:
|
||||
verification_layer.py verify <result_json> <verifier_type>
|
||||
verification_layer.py test
|
||||
|
||||
Phase G strangler slice 2 (port 2): the verification semantics (all seven
|
||||
verifiers + unknown-type dispatch) are served IN-PROCESS by the Rust
|
||||
`evidence_provenance` cdylib through ep_loader.py — parity 33/33 proven vs
|
||||
this module's Python vintage (byte-equal to_dict rendering incl. Python
|
||||
str() bool rendering True/False in evidence_refs). The VerificationResult
|
||||
dataclass stays Python (consumers construct it directly — rune_operator:323);
|
||||
the verify_* functions delegate to the Rust core and reconstruct. CLI, test,
|
||||
handle_hook and PLUGIN shapes unchanged (git history holds the original).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -15,9 +24,11 @@ import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
_SD = os.path.dirname(os.path.abspath(__file__))
|
||||
if _SD not in sys.path:
|
||||
sys.path.insert(0, _SD)
|
||||
# ── Rust core wiring (slice-2 port-2 shim pattern) ──────────────────────────
|
||||
_CRATE_DIR = "/mnt/workdrive/rune/src/rust/evidence_provenance"
|
||||
if _CRATE_DIR not in sys.path:
|
||||
sys.path.insert(0, _CRATE_DIR)
|
||||
import ep_loader # noqa: E402 (loads target/release cdylib)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -42,147 +53,66 @@ class VerificationResult:
|
||||
}
|
||||
|
||||
|
||||
def _rust_verify(result: dict, verifier_type: str) -> VerificationResult:
|
||||
"""Delegate to the Rust core; reconstruct the dataclass from its dict.
|
||||
|
||||
The Rust ep_verify returns the to_dict() shape with identical key
|
||||
semantics (parity-proven) — including the unknown-verifier path.
|
||||
"""
|
||||
out = json.loads(ep_loader.verify(json.dumps(result), verifier_type))
|
||||
return VerificationResult(
|
||||
passed=out["passed"],
|
||||
verifier_type=out["verifier_type"],
|
||||
evidence_refs=out["evidence_refs"],
|
||||
failures=out["failures"],
|
||||
retryable=out["retryable"],
|
||||
escalation_reason=out["escalation_reason"],
|
||||
)
|
||||
|
||||
|
||||
def verify_exit_code(result: dict) -> VerificationResult:
|
||||
"""Verify using exit code."""
|
||||
exit_code = result.get("exit_code", 0)
|
||||
passed = exit_code == 0
|
||||
|
||||
return VerificationResult(
|
||||
passed=passed,
|
||||
verifier_type="exit_code",
|
||||
evidence_refs=[f"exit_code={exit_code}"],
|
||||
failures=[] if passed else [f"Exit code {exit_code}"],
|
||||
retryable=not passed,
|
||||
escalation_reason=None if passed else "exit_code_nonzero",
|
||||
)
|
||||
return _rust_verify(result, "exit_code")
|
||||
|
||||
|
||||
def verify_unit_test(result: dict) -> VerificationResult:
|
||||
"""Verify using unit tests."""
|
||||
test_output = result.get("test_output", "")
|
||||
tests_passed = result.get("tests_passed", 0)
|
||||
tests_failed = result.get("tests_failed", 0)
|
||||
tests_total = result.get("tests_total", 0)
|
||||
|
||||
passed = tests_failed == 0 and tests_total > 0
|
||||
|
||||
return VerificationResult(
|
||||
passed=passed,
|
||||
verifier_type="unit_test",
|
||||
evidence_refs=[f"tests_passed={tests_passed}", f"tests_failed={tests_failed}"],
|
||||
failures=[] if passed else [f"{tests_failed} tests failed"],
|
||||
retryable=not passed,
|
||||
escalation_reason=None if passed else "test_failures",
|
||||
)
|
||||
return _rust_verify(result, "unit_test")
|
||||
|
||||
|
||||
def verify_schema(result: dict) -> VerificationResult:
|
||||
"""Verify using JSON schema."""
|
||||
schema_valid = result.get("schema_valid", True)
|
||||
schema_errors = result.get("schema_errors", [])
|
||||
|
||||
passed = schema_valid and len(schema_errors) == 0
|
||||
|
||||
return VerificationResult(
|
||||
passed=passed,
|
||||
verifier_type="schema",
|
||||
evidence_refs=[f"schema_valid={schema_valid}"],
|
||||
failures=schema_errors,
|
||||
retryable=not passed,
|
||||
escalation_reason=None if passed else "schema_invalid",
|
||||
)
|
||||
return _rust_verify(result, "schema")
|
||||
|
||||
|
||||
def verify_file_exists(result: dict) -> VerificationResult:
|
||||
"""Verify using file existence."""
|
||||
file_path = result.get("file_path", "")
|
||||
exists = os.path.exists(file_path) if file_path else False
|
||||
|
||||
return VerificationResult(
|
||||
passed=exists,
|
||||
verifier_type="file_exists",
|
||||
evidence_refs=[f"file_path={file_path}"],
|
||||
failures=[] if exists else [f"File not found: {file_path}"],
|
||||
retryable=not exists,
|
||||
escalation_reason=None if exists else "file_not_found",
|
||||
)
|
||||
return _rust_verify(result, "file_exists")
|
||||
|
||||
|
||||
def verify_file_changed(result: dict) -> VerificationResult:
|
||||
"""Verify using file changes."""
|
||||
file_path = result.get("file_path", "")
|
||||
expected_hash = result.get("expected_hash", "")
|
||||
actual_hash = result.get("actual_hash", "")
|
||||
|
||||
passed = expected_hash == actual_hash if expected_hash else True
|
||||
|
||||
return VerificationResult(
|
||||
passed=passed,
|
||||
verifier_type="file_changed",
|
||||
evidence_refs=[f"file_path={file_path}", f"hash_match={passed}"],
|
||||
failures=[] if passed else ["File hash mismatch"],
|
||||
retryable=not passed,
|
||||
escalation_reason=None if passed else "hash_mismatch",
|
||||
)
|
||||
return _rust_verify(result, "file_changed")
|
||||
|
||||
|
||||
def verify_tool_receipt(result: dict) -> VerificationResult:
|
||||
"""Verify using tool receipt."""
|
||||
tool_success = result.get("tool_success", True)
|
||||
tool_error = result.get("tool_error", "")
|
||||
|
||||
passed = tool_success
|
||||
|
||||
return VerificationResult(
|
||||
passed=passed,
|
||||
verifier_type="tool_receipt",
|
||||
evidence_refs=[f"tool_success={tool_success}"],
|
||||
failures=[] if passed else [tool_error],
|
||||
retryable=not passed,
|
||||
escalation_reason=None if passed else "tool_error",
|
||||
)
|
||||
return _rust_verify(result, "tool_receipt")
|
||||
|
||||
|
||||
def verify_constraint_check(result: dict) -> VerificationResult:
|
||||
"""Verify using constraint check."""
|
||||
constraints_met = result.get("constraints_met", True)
|
||||
constraint_violations = result.get("constraint_violations", [])
|
||||
|
||||
passed = constraints_met and len(constraint_violations) == 0
|
||||
|
||||
return VerificationResult(
|
||||
passed=passed,
|
||||
verifier_type="constraint_check",
|
||||
evidence_refs=[f"constraints_met={constraints_met}"],
|
||||
failures=constraint_violations,
|
||||
retryable=not passed,
|
||||
escalation_reason=None if passed else "constraint_violation",
|
||||
)
|
||||
return _rust_verify(result, "constraint_check")
|
||||
|
||||
|
||||
def verify(result: dict, verifier_type: str) -> VerificationResult:
|
||||
"""Verify a result using the specified verifier."""
|
||||
verifiers = {
|
||||
"exit_code": verify_exit_code,
|
||||
"unit_test": verify_unit_test,
|
||||
"schema": verify_schema,
|
||||
"file_exists": verify_file_exists,
|
||||
"file_changed": verify_file_changed,
|
||||
"tool_receipt": verify_tool_receipt,
|
||||
"constraint_check": verify_constraint_check,
|
||||
}
|
||||
"""Verify a result using the specified verifier.
|
||||
|
||||
verifier = verifiers.get(verifier_type)
|
||||
if not verifier:
|
||||
return VerificationResult(
|
||||
passed=False,
|
||||
verifier_type=verifier_type,
|
||||
failures=[f"Unknown verifier: {verifier_type}"],
|
||||
retryable=False,
|
||||
escalation_reason="unknown_verifier",
|
||||
)
|
||||
|
||||
return verifier(result)
|
||||
Dispatch is served by the Rust core (unknown verifier → the same
|
||||
passed=False / retryable=False / escalation_reason=unknown_verifier
|
||||
shape, parity-proven).
|
||||
"""
|
||||
return _rust_verify(result, verifier_type)
|
||||
|
||||
|
||||
def test_verification():
|
||||
@@ -256,4 +186,4 @@ PLUGIN = {
|
||||
"provides": ["verification_layer"],
|
||||
"priority": 45,
|
||||
"auto_activate": True,
|
||||
}
|
||||
}
|
||||
+146
-84
@@ -139,6 +139,50 @@ def record_neuron_fire(entry_id, score=0.0, session_id="", outcome="unknown"):
|
||||
logger.debug("record_neuron_fire failed", exc_info=True) # Non-critical — don't break firing
|
||||
|
||||
|
||||
def _py_base_level_from_uses(used_at_list, now_iso):
|
||||
"""ACT-R math over gathered timestamps (H3 slice 3 parity oracle).
|
||||
`now_iso` is an ISO-8601 string (no-clock law). Per-row parse failures
|
||||
(ValueError/TypeError) are skipped like the original; a non-string row
|
||||
raises AttributeError — the original's outer catch turns that into -2.0
|
||||
for the whole call (see base_level_from_uses)."""
|
||||
import math
|
||||
|
||||
if not used_at_list:
|
||||
return -2.0 # Cold start — never fired
|
||||
|
||||
now = datetime.fromisoformat(now_iso)
|
||||
total = 0.0
|
||||
for used_at_str in used_at_list:
|
||||
try:
|
||||
used_at = datetime.fromisoformat(used_at_str.replace("Z", "+00:00"))
|
||||
t_hours = max(0.1, (now - used_at).total_seconds() / 3600)
|
||||
total += t_hours ** (-BASE_LEVEL_DECAY)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
if total <= 0:
|
||||
return -2.0
|
||||
|
||||
b_i = math.log(total)
|
||||
# Clamp to prevent extreme values
|
||||
return max(-2.0, min(b_i, 2.0))
|
||||
|
||||
|
||||
def base_level_from_uses(used_at_list, now_iso):
|
||||
"""Delegated ACT-R math (Rust core when built) with the original's
|
||||
outer exception contract: ANY unexpected failure degrades to -2.0."""
|
||||
from rune.drift_core import _ms_core
|
||||
|
||||
try:
|
||||
core = _ms_core()
|
||||
if core is not None:
|
||||
return core.base_level_from_uses(used_at_list, now_iso)
|
||||
return _py_base_level_from_uses(used_at_list, now_iso)
|
||||
except Exception:
|
||||
logger.debug("base_level_from_uses failed", exc_info=True)
|
||||
return -2.0 # Graceful fallback (the original outer catch)
|
||||
|
||||
|
||||
def base_level_activation(entry_id, db=None):
|
||||
"""ACT-R base-level learning: B_i = ln(Σ t_k^{-d}).
|
||||
|
||||
@@ -146,10 +190,11 @@ def base_level_activation(entry_id, db=None):
|
||||
Power-law decay matches empirical human memory data.
|
||||
Returns a score that can be added to the neuron's total activation.
|
||||
`db` is an optional pre-opened read-only connection (audit fix: reuse).
|
||||
|
||||
H3 slice 3: the SQLite scan stays here (zero-dep split law); the decay
|
||||
math delegates to the Rust core when built.
|
||||
"""
|
||||
import sqlite3
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_own_db = db is None
|
||||
try:
|
||||
@@ -164,22 +209,8 @@ def base_level_activation(entry_id, db=None):
|
||||
if not rows:
|
||||
return -2.0 # Cold start — never fired
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
total = 0.0
|
||||
for (used_at_str,) in rows:
|
||||
try:
|
||||
used_at = datetime.fromisoformat(used_at_str.replace("Z", "+00:00"))
|
||||
t_hours = max(0.1, (now - used_at).total_seconds() / 3600)
|
||||
total += t_hours ** (-BASE_LEVEL_DECAY)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
if total <= 0:
|
||||
return -2.0
|
||||
|
||||
b_i = math.log(total)
|
||||
# Clamp to prevent extreme values
|
||||
return max(-2.0, min(b_i, 2.0))
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
return base_level_from_uses([r[0] for r in rows], now_iso)
|
||||
|
||||
except Exception:
|
||||
logger.debug("base_level_activation failed", exc_info=True)
|
||||
@@ -358,53 +389,18 @@ def write_neuron(slug, triggers, body, importance=0.5):
|
||||
# ---------------------------------------------------------------- routing ----
|
||||
|
||||
|
||||
def score_neuron(neuron, low_text, session_context=None, query_vec=None):
|
||||
"""Score how strongly an incoming text fires this neuron.
|
||||
|
||||
Multi-signal scoring (not just keyword matching):
|
||||
1. Keyword match — trigger words in the input (current behavior)
|
||||
2. Semantic match — embedding similarity between input and neuron content
|
||||
3. Context match — relevance to conversation history
|
||||
4. Recency — recently-fired neurons get a boost (active priming)
|
||||
5. Importance — neuron's base importance weight
|
||||
|
||||
The keyword signal is fast and deterministic.
|
||||
The semantic signal catches meaning even without keyword overlap.
|
||||
The context signal provides continuity across turns.
|
||||
`query_vec` is the pre-computed embedding for low_text (audit fix: compute
|
||||
once, reuse for all neurons instead of N HTTP calls).
|
||||
"""
|
||||
# Signal 1: Keyword match (existing behavior, fast)
|
||||
def _py_score_gate(neuron, low_text, session_context, semantic_score):
|
||||
"""Pure gate (H3 slice 3 parity oracle): returns
|
||||
(fires, total, keyword_hits) — the pre-bonus combination decision.
|
||||
Signals 1 (keyword) and 3 (context) are pure; signal 2 (semantic)
|
||||
arrives as an argument because embeddings are I/O."""
|
||||
keyword_hits = 0
|
||||
for t in neuron["triggers"]:
|
||||
if t and t in low_text:
|
||||
keyword_hits += 1
|
||||
|
||||
# Signal 2: Semantic similarity (embedding-based, catches meaning)
|
||||
semantic_score = 0.0
|
||||
try:
|
||||
body = neuron.get("body", "") or neuron.get("text", "")
|
||||
if body and len(body) > 20:
|
||||
# Use pre-computed query embedding (audit fix: was computing per neuron)
|
||||
import memory_store as ms
|
||||
|
||||
if query_vec is None:
|
||||
query_vec = ms._embed([low_text]).get(low_text)
|
||||
body_vec = ms._embed([body]).get(body)
|
||||
if query_vec and body_vec:
|
||||
# Cosine similarity
|
||||
dot = sum(a * b for a, b in zip(query_vec, body_vec))
|
||||
na = sum(a * a for a in query_vec) ** 0.5
|
||||
nb = sum(b * b for b in body_vec) ** 0.5
|
||||
if na and nb:
|
||||
semantic_score = dot / (na * nb)
|
||||
except Exception:
|
||||
logger.debug("score_neuron failed", exc_info=True) # Semantic scoring is optional — keyword fallback works
|
||||
|
||||
# Signal 3: Context match (if session context provided)
|
||||
context_score = 0.0
|
||||
if session_context:
|
||||
# Check if neuron's triggers appear in recent context
|
||||
context_low = session_context.lower()
|
||||
for t in neuron["triggers"]:
|
||||
if t and t in context_low:
|
||||
@@ -414,34 +410,30 @@ def score_neuron(neuron, low_text, session_context=None, query_vec=None):
|
||||
# Keywords are the primary gate — high precision for curated triggers.
|
||||
# Semantic is a secondary boost — catches meaning without keywords.
|
||||
# Context provides continuity across turns.
|
||||
# This is better than simple weighted sum because it preserves keyword
|
||||
# precision while allowing semantic to catch what keywords miss.
|
||||
if keyword_hits > 0:
|
||||
# Keywords matched — primary gate open
|
||||
keyword_component = keyword_hits * 1.0
|
||||
# Semantic boost (only if keywords already matched)
|
||||
semantic_boost = max(0, semantic_score - 0.3) * 0.5 if semantic_score > 0.3 else 0.0
|
||||
context_component = min(context_score, 1.0)
|
||||
total = keyword_component + semantic_boost + context_component
|
||||
return (True, total, keyword_hits)
|
||||
elif semantic_score > 0.55:
|
||||
# No keywords, but strong semantic match — secondary gate
|
||||
# Higher threshold (0.55 vs 0.3) prevents false positives
|
||||
total = semantic_score * 0.7
|
||||
else:
|
||||
# No signal — don't fire
|
||||
return 0.0
|
||||
return (True, semantic_score * 0.7, keyword_hits)
|
||||
return (False, 0.0, keyword_hits)
|
||||
|
||||
|
||||
def _py_score_finish(neuron, low_text, total, b_i, cache_priority, keyword_hits):
|
||||
"""Pure finish (H3 slice 3 parity oracle): bonuses, penalties, and the
|
||||
protected boost atop the gate's total. b_i / cache_priority arrive as
|
||||
arguments (None skips that contribution) because SQLite and the cache
|
||||
file are I/O — the wrapper only gathers them on the fired path."""
|
||||
# Add importance and length bonus
|
||||
score = total + neuron["importance"]
|
||||
|
||||
# Signal 4: Base-level learning (ACT-R B_i)
|
||||
# Frequently-used neurons become permanently easier to retrieve.
|
||||
# Power-law decay: recent uses matter more, but frequency counts.
|
||||
# Signal 4: Base-level learning contribution (cap at 0.3)
|
||||
entry_id = neuron.get("id")
|
||||
if entry_id:
|
||||
b_i = base_level_activation(entry_id)
|
||||
# Normalize B_i to a small contribution (cap at 0.3 of total score)
|
||||
# This prevents rich-get-richer while still rewarding useful neurons
|
||||
if entry_id and b_i is not None:
|
||||
b_contribution = max(0, min(b_i * 0.1, 0.3))
|
||||
score += b_contribution
|
||||
|
||||
@@ -454,17 +446,8 @@ def score_neuron(neuron, low_text, session_context=None, query_vec=None):
|
||||
score += wash_boost
|
||||
|
||||
# Signal 6: Cache priority (drift-with-integrity)
|
||||
try:
|
||||
import drift_cache
|
||||
|
||||
cache = drift_cache.DriftCache()
|
||||
if entry_id and cache.contains(entry_id):
|
||||
cache_priority = cache.get_priority(entry_id)
|
||||
cache_boost = min(0.2, cache_priority * 0.1)
|
||||
score += cache_boost
|
||||
cache.access(entry_id)
|
||||
except Exception:
|
||||
logger.debug("score_neuron failed", exc_info=True)
|
||||
if entry_id and cache_priority is not None:
|
||||
score += min(0.2, cache_priority * 0.1)
|
||||
|
||||
# Signal 7: Body-knowledge relevance (Souveraine enhancement)
|
||||
body_knowledge = neuron.get("body_knowledge", "")
|
||||
@@ -501,6 +484,85 @@ def score_neuron(neuron, low_text, session_context=None, query_vec=None):
|
||||
return score
|
||||
|
||||
|
||||
def score_neuron(neuron, low_text, session_context=None, query_vec=None):
|
||||
"""Score how strongly an incoming text fires this neuron.
|
||||
|
||||
Multi-signal scoring (not just keyword matching):
|
||||
1. Keyword match — trigger words in the input (current behavior)
|
||||
2. Semantic match — embedding similarity between input and neuron content
|
||||
3. Context match — relevance to conversation history
|
||||
4. Recency — recently-fired neurons get a boost (active priming)
|
||||
5. Importance — neuron's base importance weight
|
||||
|
||||
The keyword signal is fast and deterministic.
|
||||
The semantic signal catches meaning even without keyword overlap.
|
||||
The context signal provides continuity across turns.
|
||||
`query_vec` is the pre-computed embedding for low_text (audit fix: compute
|
||||
once, reuse for all neurons instead of N HTTP calls).
|
||||
|
||||
H3 slice 3: I/O signals are gathered HERE (embeddings, SQLite
|
||||
base-level, cache file — the original's exact reach order), then the
|
||||
combination delegates to the Rust core when built (_py_ variants are
|
||||
the load-bearing fallback AND the parity oracle).
|
||||
"""
|
||||
from rune.drift_core import _ms_core
|
||||
|
||||
# Signal 2 gather: semantic similarity (embedding-based, catches meaning)
|
||||
semantic_score = 0.0
|
||||
try:
|
||||
body = neuron.get("body", "") or neuron.get("text", "")
|
||||
if body and len(body) > 20:
|
||||
# Use pre-computed query embedding (audit fix: was computing per neuron)
|
||||
import memory_store as ms
|
||||
|
||||
if query_vec is None:
|
||||
query_vec = ms._embed([low_text]).get(low_text)
|
||||
body_vec = ms._embed([body]).get(body)
|
||||
if query_vec and body_vec:
|
||||
# Cosine similarity
|
||||
dot = sum(a * b for a, b in zip(query_vec, body_vec))
|
||||
na = sum(a * a for a in query_vec) ** 0.5
|
||||
nb = sum(b * b for b in body_vec) ** 0.5
|
||||
if na and nb:
|
||||
semantic_score = dot / (na * nb)
|
||||
except Exception:
|
||||
logger.debug("score_neuron failed", exc_info=True) # Semantic scoring is optional — keyword fallback works
|
||||
|
||||
core = _ms_core()
|
||||
if core is not None:
|
||||
fires, total, keyword_hits = core.score_gate(
|
||||
neuron, low_text, session_context, semantic_score)
|
||||
else:
|
||||
fires, total, keyword_hits = _py_score_gate(
|
||||
neuron, low_text, session_context, semantic_score)
|
||||
if not fires:
|
||||
# No signal — don't fire (and, faithful to the original, gather NO
|
||||
# base-level or cache I/O on this path)
|
||||
return 0.0
|
||||
|
||||
# Signal 4 gather: Base-level learning (ACT-R B_i) — fired path only
|
||||
entry_id = neuron.get("id")
|
||||
b_i = base_level_activation(entry_id) if entry_id else None
|
||||
|
||||
# Signal 6 gather: cache priority + access reinforcement — fired path only
|
||||
cache_priority = None
|
||||
try:
|
||||
import drift_cache
|
||||
|
||||
cache = drift_cache.DriftCache()
|
||||
if entry_id and cache.contains(entry_id):
|
||||
cache_priority = cache.get_priority(entry_id)
|
||||
cache.access(entry_id)
|
||||
except Exception:
|
||||
logger.debug("score_neuron failed", exc_info=True)
|
||||
|
||||
if core is not None:
|
||||
return core.score_finish(
|
||||
neuron, low_text, total, b_i, cache_priority, keyword_hits)
|
||||
return _py_score_finish(
|
||||
neuron, low_text, total, b_i, cache_priority, keyword_hits)
|
||||
|
||||
|
||||
def route(text, max_tokens=DEFAULT_MAX_TOKENS, max_blocks=None, session_id=""):
|
||||
"""Return the neurons that fire for `text`, constrained by a TOKEN budget.
|
||||
|
||||
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "capability_scoring"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "capability_scoring"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "H3 slice 4 (T4.4) — capability_fit.evaluate_fit + capability_broker.score_card Rust port per contract BUILDER-2026-10-04T09:43:55Z-cli; zero-dep"
|
||||
|
||||
[lib]
|
||||
name = "capability_scoring"
|
||||
crate-type = ["rlib", "cdylib"]
|
||||
|
||||
[[bin]]
|
||||
name = "capability_scoring"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""canary_capability_scoring.py — parity battery for the capability_scoring
|
||||
crate (H3 slice 4, T4.4, contract BUILDER-2026-10-04T09:43:55Z-cli).
|
||||
|
||||
Strangler law: the Rust core must produce BYTE-IDENTICAL output to the
|
||||
Python scoring originals (capability_fit.evaluate_fit, capability_broker.
|
||||
score_card) on identical inputs.
|
||||
|
||||
Sections:
|
||||
1. crate builds (rustup PATH-first per the SYSTEM RUSTC BROKEN law)
|
||||
2. evaluate_fit parity (every fit class + exact reason strings)
|
||||
3. score_card parity (weights, generic penalty, stopwords, priority)
|
||||
|
||||
Run: python3 src/rust/capability_scoring/canary_capability_scoring.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CRATE_DIR = Path(__file__).resolve().parent
|
||||
REPO = CRATE_DIR.parents[2]
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
|
||||
def test(name: str, ok: bool, detail: str = "") -> None:
|
||||
global PASS, FAIL
|
||||
if ok:
|
||||
PASS += 1
|
||||
print(f" ✓ {name}")
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f" ✗ {name} — {detail}")
|
||||
|
||||
|
||||
def build_crate() -> subprocess.CompletedProcess:
|
||||
env = dict(os.environ)
|
||||
# SYSTEM RUSTC BROKEN law: the healthy toolchain lives at the ABSOLUTE
|
||||
# /home/nick path (the runner's HOME has no toolchain — x-ACL covers the
|
||||
# runner user). Fall back to the caller's HOME for other hosts.
|
||||
_absolute = Path("/home/nick/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin")
|
||||
rustup_bin = _absolute if _absolute.is_dir() else (
|
||||
Path.home() / ".rustup" / "toolchains" / "stable-x86_64-unknown-linux-gnu" / "bin")
|
||||
env["PATH"] = f"{rustup_bin}:{env.get('PATH', '')}"
|
||||
env["CARGO_TARGET_DIR"] = env.get("CARGO_TARGET_DIR", str(CRATE_DIR / "target"))
|
||||
return subprocess.run(
|
||||
["cargo", "build", "--release"],
|
||||
cwd=CRATE_DIR, env=env, capture_output=True, text=True, timeout=600)
|
||||
|
||||
|
||||
def fit_battery() -> list[tuple[str, list[str], dict]]:
|
||||
return [
|
||||
("EXACT all covered",
|
||||
["contract:a", "contract:b"],
|
||||
{"id": "cap:x", "provides": ["contract:a", "contract:b"],
|
||||
"status": "CURRENT", "maturity": "WIRED", "consumers": ["module:y"]}),
|
||||
("PARTIAL some covered",
|
||||
["contract:a", "contract:b", "contract:c"],
|
||||
{"id": "cap:x", "provides": ["contract:a"],
|
||||
"status": "CURRENT", "maturity": "DECLARED", "consumers": []}),
|
||||
("INCOMPATIBLE none covered",
|
||||
["contract:a"],
|
||||
{"id": "cap:x", "provides": ["contract:zzz"],
|
||||
"status": "CURRENT", "maturity": "PRESENT", "consumers": []}),
|
||||
("UNKNOWN no provides",
|
||||
["contract:a"],
|
||||
{"id": "cap:x", "provides": [],
|
||||
"status": "CURRENT", "maturity": "DECLARED", "consumers": []}),
|
||||
("unhealthy SUPERSEDED",
|
||||
["contract:a"],
|
||||
{"id": "cap:x", "provides": ["contract:a"],
|
||||
"status": "SUPERSEDED", "maturity": "WIRED", "consumers": []}),
|
||||
("unhealthy STALE",
|
||||
["contract:a"],
|
||||
{"id": "cap:x", "provides": ["contract:a"],
|
||||
"status": "STALE", "maturity": "WIRED", "consumers": []}),
|
||||
("unhealthy superseded_by set",
|
||||
["contract:a"],
|
||||
{"id": "cap:x", "provides": ["contract:a"], "status": "CURRENT",
|
||||
"superseded_by": "cap:newer", "maturity": "WIRED", "consumers": []}),
|
||||
("no contracts required -> EXACT",
|
||||
[],
|
||||
{"id": "cap:x", "provides": ["contract:a"],
|
||||
"status": "CURRENT", "maturity": "LIVE_PROVEN", "consumers": ["a", "b"]}),
|
||||
("unknown maturity ranks zero",
|
||||
["contract:a"],
|
||||
{"id": "cap:x", "provides": ["contract:a"],
|
||||
"status": "CURRENT", "maturity": "WEIRD", "consumers": []}),
|
||||
("duplicate provides dedup via set math",
|
||||
["contract:a"],
|
||||
{"id": "cap:x", "provides": ["contract:a", "contract:a"],
|
||||
"status": "CURRENT", "maturity": "EXERCISED", "consumers": []}),
|
||||
]
|
||||
|
||||
|
||||
def card_battery() -> list[tuple[str, dict, str]]:
|
||||
return [
|
||||
("name match 4.0",
|
||||
{"name": "query_memory", "capabilities": [], "description": "",
|
||||
"semantic_examples": [], "priority": 100},
|
||||
"run the query_memory tool"),
|
||||
("capability match 3.0",
|
||||
{"name": "x", "capabilities": ["spine_projection"], "description": "",
|
||||
"semantic_examples": [], "priority": 100},
|
||||
"compute the spine_projection now"),
|
||||
("generic capability penalized 0.3",
|
||||
{"name": "x", "capabilities": ["audit"], "description": "",
|
||||
"semantic_examples": [], "priority": 100},
|
||||
"please audit this"),
|
||||
("description match 0.8",
|
||||
{"name": "x", "capabilities": [],
|
||||
"description": "manages deployment pipelines", "semantic_examples": [],
|
||||
"priority": 100},
|
||||
"deployment pipelines"),
|
||||
("semantic examples 2.0",
|
||||
{"name": "x", "capabilities": [], "description": "",
|
||||
"semantic_examples": ["port the drift scoring"], "priority": 100},
|
||||
"drift scoring"),
|
||||
("priority bonus high priority",
|
||||
{"name": "x", "capabilities": [], "description": "",
|
||||
"semantic_examples": [], "priority": 0},
|
||||
"anything"),
|
||||
("stopwords excluded",
|
||||
{"name": "x", "capabilities": ["the"], "description": "",
|
||||
"semantic_examples": [], "priority": 100},
|
||||
"the and or but"),
|
||||
("empty query",
|
||||
{"name": "x", "capabilities": ["spine"], "description": "",
|
||||
"semantic_examples": [], "priority": 50},
|
||||
""),
|
||||
("underscore word chars",
|
||||
{"name": "x", "capabilities": [], "description": "a_b c_d",
|
||||
"semantic_examples": [], "priority": 100},
|
||||
"a_b"),
|
||||
("missing keys default",
|
||||
{},
|
||||
"nothing matches here"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 47)
|
||||
print("canary_capability_scoring — capability scoring parity battery")
|
||||
print("=" * 47)
|
||||
|
||||
print("\n--- 1. Crate builds ---")
|
||||
r = build_crate()
|
||||
test("cargo build --release exits 0", r.returncode == 0,
|
||||
f"rc={r.returncode} err={r.stderr[-300:]!r}")
|
||||
if r.returncode != 0:
|
||||
print(f"\nRESULTS: {PASS} pass, {FAIL} fail")
|
||||
return 1
|
||||
default_so = Path(os.environ.get(
|
||||
"CARGO_TARGET_DIR", str(CRATE_DIR / "target"))) / "release" / "libcapability_scoring.so"
|
||||
so = Path(os.environ.get("CS_RUST_LIB", default_so))
|
||||
test("libcapability_scoring.so exists", so.is_file(), str(so))
|
||||
if not so.is_file():
|
||||
print(f"\nRESULTS: {PASS} pass, {FAIL} fail")
|
||||
return 1
|
||||
|
||||
from rune import capability_broker # noqa: E402
|
||||
from rune import capability_fit # noqa: E402
|
||||
from rune import cs_loader # noqa: E402
|
||||
|
||||
# ── 2. evaluate_fit parity ────────────────────────────────────────────
|
||||
print("\n--- 2. evaluate_fit parity ---")
|
||||
for name, contracts, candidate in fit_battery():
|
||||
req = capability_fit.CapabilityRequirement("test objective",
|
||||
contracts=contracts)
|
||||
py = capability_fit._py_evaluate_fit(req, candidate)
|
||||
rs = cs_loader.evaluate_fit(contracts, candidate)
|
||||
test(f"fit: {name}", py == rs,
|
||||
f"py={py!r}\n rs={rs!r}")
|
||||
|
||||
# ── 3. score_card parity ──────────────────────────────────────────────
|
||||
print("\n--- 3. score_card parity ---")
|
||||
for name, card, query in card_battery():
|
||||
py = capability_broker._py_score_card(card, query)
|
||||
rs = cs_loader.score_card(card, query)
|
||||
test(f"card: {name}", py == rs, f"py={py!r} rs={rs!r}")
|
||||
|
||||
# ── 4. delegation sanity ──────────────────────────────────────────────
|
||||
print("\n--- 4. Public API delegation ---")
|
||||
req = capability_fit.CapabilityRequirement("obj", contracts=["contract:a"])
|
||||
cand = {"id": "cap:x", "provides": ["contract:a"], "status": "CURRENT",
|
||||
"maturity": "WIRED", "consumers": []}
|
||||
test("public evaluate_fit matches core",
|
||||
capability_fit.evaluate_fit(req, cand)
|
||||
== capability_fit._py_evaluate_fit(req, cand), "mismatch")
|
||||
card = {"name": "query_memory", "capabilities": [], "description": "",
|
||||
"semantic_examples": [], "priority": 100}
|
||||
test("public score_card matches core",
|
||||
capability_broker.score_card(card, "query_memory")
|
||||
== capability_broker._py_score_card(card, "query_memory"), "mismatch")
|
||||
|
||||
print(f"\n{'=' * 47}")
|
||||
print(f"RESULTS: {PASS} pass, {FAIL} fail")
|
||||
return 1 if FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,103 @@
|
||||
//! lib.rs — FFI surface for the capability_scoring crate (H3 slice 4).
|
||||
//!
|
||||
//! Every function takes ONE JSON argument string and returns a JSON envelope
|
||||
//! string: {"ok": true, "value": ...} or {"ok": false, "err": "..."} — the
|
||||
//! doc-envelope law. The caller frees returned strings with cs_free_string.
|
||||
//! FFI segfault law: cs_loader.py pins argtypes + restype for every symbol.
|
||||
|
||||
pub mod pyjson;
|
||||
pub mod scoring;
|
||||
pub mod wordlists;
|
||||
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::raw::c_char;
|
||||
|
||||
fn read_arg(ptr: *const c_char) -> Result<String, String> {
|
||||
if ptr.is_null() {
|
||||
return Err("null argument pointer".to_string());
|
||||
}
|
||||
unsafe { CStr::from_ptr(ptr) }
|
||||
.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.map_err(|e| format!("argument is not valid UTF-8: {}", e))
|
||||
}
|
||||
|
||||
fn envelope(result: Result<pyjson::Json, String>) -> *mut c_char {
|
||||
let body = match result {
|
||||
Ok(value) => pyjson::Json::Obj(vec![
|
||||
("ok".to_string(), pyjson::Json::Bool(true)),
|
||||
("value".to_string(), value),
|
||||
]),
|
||||
Err(err) => pyjson::Json::Obj(vec![
|
||||
("ok".to_string(), pyjson::Json::Bool(false)),
|
||||
("err".to_string(), pyjson::Json::Str(err)),
|
||||
]),
|
||||
};
|
||||
let out = pyjson::dumps(&body);
|
||||
CString::new(out)
|
||||
.map(|c| c.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
fn arg_json(text: &str) -> Result<pyjson::Json, String> {
|
||||
pyjson::parse(text).map_err(|e| format!("argument JSON parse failed: {}", e))
|
||||
}
|
||||
|
||||
fn get<'a>(v: &'a pyjson::Json, key: &str) -> Option<&'a pyjson::Json> {
|
||||
match v {
|
||||
pyjson::Json::Obj(pairs) => pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_str<'a>(v: &'a pyjson::Json, key: &str) -> Option<&'a str> {
|
||||
match get(v, key) {
|
||||
Some(pyjson::Json::Str(s)) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn str_list(v: Option<&pyjson::Json>) -> Vec<String> {
|
||||
match v {
|
||||
Some(pyjson::Json::Arr(items)) => items
|
||||
.iter()
|
||||
.filter_map(|x| match x {
|
||||
pyjson::Json::Str(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// cs_evaluate_fit({"contracts": [...], "candidate": {...}}) -> fit dict
|
||||
#[no_mangle]
|
||||
pub extern "C" fn cs_evaluate_fit(arg: *const c_char) -> *mut c_char {
|
||||
let result = read_arg(arg).and_then(|text| arg_json(&text)).and_then(|a| {
|
||||
let contracts = str_list(get(&a, "contracts"));
|
||||
let candidate = get(&a, "candidate").cloned().ok_or("arg.candidate required")?;
|
||||
Ok(scoring::evaluate_fit(&contracts, &candidate))
|
||||
});
|
||||
envelope(result)
|
||||
}
|
||||
|
||||
/// cs_score_card({"card": {...}, "query": "..."}) -> number
|
||||
#[no_mangle]
|
||||
pub extern "C" fn cs_score_card(arg: *const c_char) -> *mut c_char {
|
||||
let result = read_arg(arg).and_then(|text| arg_json(&text)).and_then(|a| {
|
||||
let card = get(&a, "card").cloned().ok_or("arg.card required")?;
|
||||
let query = get_str(&a, "query").unwrap_or("");
|
||||
Ok(pyjson::Json::Num(scoring::score_card(&card, query)))
|
||||
});
|
||||
envelope(result)
|
||||
}
|
||||
|
||||
/// Free a string returned by any cs_* function.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn cs_free_string(ptr: *mut c_char) {
|
||||
if !ptr.is_null() {
|
||||
unsafe {
|
||||
let _ = CString::from_raw(ptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! main.rs — CLI front for the capability_scoring core (runner/debug use).
|
||||
//!
|
||||
//! stdin: {"op": "evaluate_fit"|"score_card", "arg": <json>}
|
||||
//! stdout: the same {ok, value|err} envelope the FFI returns.
|
||||
|
||||
use capability_scoring::{pyjson, scoring};
|
||||
use std::io::Read;
|
||||
|
||||
fn get<'a>(v: &'a pyjson::Json, key: &str) -> Option<&'a pyjson::Json> {
|
||||
match v {
|
||||
pyjson::Json::Obj(pairs) => pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn str_list(v: Option<&pyjson::Json>) -> Vec<String> {
|
||||
match v {
|
||||
Some(pyjson::Json::Arr(items)) => items
|
||||
.iter()
|
||||
.filter_map(|x| match x {
|
||||
pyjson::Json::Str(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut input = String::new();
|
||||
if std::io::stdin().read_to_string(&mut input).is_err() {
|
||||
emit(Err("failed to read stdin".to_string()), false);
|
||||
return;
|
||||
}
|
||||
let parsed = match pyjson::parse(&input) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
emit(Err(format!("stdin JSON parse failed: {}", e)), false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let op = match get(&parsed, "op") {
|
||||
Some(pyjson::Json::Str(s)) => s.clone(),
|
||||
_ => {
|
||||
emit(Err("op required".to_string()), false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let arg = get(&parsed, "arg").cloned().unwrap_or(pyjson::Json::Null);
|
||||
let result = match op.as_str() {
|
||||
"evaluate_fit" => {
|
||||
let contracts = str_list(get(&arg, "contracts"));
|
||||
match get(&arg, "candidate") {
|
||||
Some(c) => Ok(scoring::evaluate_fit(&contracts, c)),
|
||||
None => Err("arg.candidate required".to_string()),
|
||||
}
|
||||
}
|
||||
"score_card" => match get(&arg, "card") {
|
||||
Some(c) => {
|
||||
let query = match get(&arg, "query") {
|
||||
Some(pyjson::Json::Str(s)) => s.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
Ok(pyjson::Json::Num(scoring::score_card(c, &query)))
|
||||
}
|
||||
None => Err("arg.card required".to_string()),
|
||||
},
|
||||
other => Err(format!("unknown op: {:?}", other)),
|
||||
};
|
||||
let ok = result.is_ok();
|
||||
emit(result, ok);
|
||||
}
|
||||
|
||||
fn emit(result: Result<pyjson::Json, String>, ok: bool) {
|
||||
let body = match result {
|
||||
Ok(value) => pyjson::Json::Obj(vec![
|
||||
("ok".to_string(), pyjson::Json::Bool(true)),
|
||||
("value".to_string(), value),
|
||||
]),
|
||||
Err(err) => pyjson::Json::Obj(vec![
|
||||
("ok".to_string(), pyjson::Json::Bool(false)),
|
||||
("err".to_string(), pyjson::Json::Str(err)),
|
||||
]),
|
||||
};
|
||||
println!("{}", pyjson::dumps(&body));
|
||||
std::process::exit(if ok { 0 } else { 1 });
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
//! json_min.py — minimal JSON parse/serialize with Python-parity output.
|
||||
//!
|
||||
//! Exists because contract C1 demands byte-comparable state files with the
|
||||
//! Python implementation (`json.dumps` / `json.dump(indent=2)`):
|
||||
//! - compact separators: ", " between items, ": " between key/value
|
||||
//! (Python json.dumps DEFAULTS — no separators arg means the space-y form)
|
||||
//! - ensure_ascii: non-ASCII escaped as \uXXXX (with surrogate pairs)
|
||||
//! - floats: shortest round-trip repr (Rust `{}` == Python `repr` for f64
|
||||
//! in every value this module produces, including arithmetic artifacts
|
||||
//! like 0.30000000000000004)
|
||||
//! - indent=2 dump: newline+indent after every item separator, "{}" when
|
||||
//! empty, NO trailing newline (json.dump never writes one)
|
||||
//! Object key order is preserved (Vec of pairs) — matches Python 3.7+ dict
|
||||
//! insertion order, which the classification tie-break law depends on.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// Preserved-order JSON value.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Json {
|
||||
Null,
|
||||
Bool(bool),
|
||||
Num(f64),
|
||||
Str(String),
|
||||
Arr(Vec<Json>),
|
||||
Obj(Vec<(String, Json)>),
|
||||
}
|
||||
|
||||
impl Json {
|
||||
pub fn get(&self, key: &str) -> Option<&Json> {
|
||||
match self {
|
||||
Json::Obj(pairs) => pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── parsing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn parse(input: &str) -> Result<Json, String> {
|
||||
let bytes = input.as_bytes();
|
||||
let mut p = Parser { b: bytes, i: 0 };
|
||||
p.skip_ws();
|
||||
let v = p.value()?;
|
||||
p.skip_ws();
|
||||
if p.i != bytes.len() {
|
||||
return Err(format!("trailing data at byte {}", p.i));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
b: &'a [u8],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn skip_ws(&mut self) {
|
||||
while self.i < self.b.len() && matches!(self.b[self.i], b' ' | b'\t' | b'\n' | b'\r') {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.b.get(self.i).copied()
|
||||
}
|
||||
|
||||
fn expect(&mut self, c: u8) -> Result<(), String> {
|
||||
if self.peek() == Some(c) {
|
||||
self.i += 1;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("expected '{}' at byte {}", c as char, self.i))
|
||||
}
|
||||
}
|
||||
|
||||
fn value(&mut self) -> Result<Json, String> {
|
||||
match self.peek() {
|
||||
Some(b'{') => self.object(),
|
||||
Some(b'[') => self.array(),
|
||||
Some(b'"') => Ok(Json::Str(self.string()?)),
|
||||
Some(b't') => self.literal("true", Json::Bool(true)),
|
||||
Some(b'f') => self.literal("false", Json::Bool(false)),
|
||||
Some(b'n') => self.literal("null", Json::Null),
|
||||
Some(c) if c == b'-' || c.is_ascii_digit() => self.number(),
|
||||
_ => Err(format!("unexpected byte at {}", self.i)),
|
||||
}
|
||||
}
|
||||
|
||||
fn literal(&mut self, lit: &str, v: Json) -> Result<Json, String> {
|
||||
if self.b[self.i..].starts_with(lit.as_bytes()) {
|
||||
self.i += lit.len();
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(format!("bad literal at byte {}", self.i))
|
||||
}
|
||||
}
|
||||
|
||||
fn object(&mut self) -> Result<Json, String> {
|
||||
self.expect(b'{')?;
|
||||
let mut pairs = Vec::new();
|
||||
self.skip_ws();
|
||||
if self.peek() == Some(b'}') {
|
||||
self.i += 1;
|
||||
return Ok(Json::Obj(pairs));
|
||||
}
|
||||
loop {
|
||||
self.skip_ws();
|
||||
let key = self.string()?;
|
||||
self.skip_ws();
|
||||
self.expect(b':')?;
|
||||
self.skip_ws();
|
||||
let val = self.value()?;
|
||||
pairs.push((key, val));
|
||||
self.skip_ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Json::Obj(pairs));
|
||||
}
|
||||
_ => return Err(format!("expected ',' or '}}' at byte {}", self.i)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn array(&mut self) -> Result<Json, String> {
|
||||
self.expect(b'[')?;
|
||||
let mut items = Vec::new();
|
||||
self.skip_ws();
|
||||
if self.peek() == Some(b']') {
|
||||
self.i += 1;
|
||||
return Ok(Json::Arr(items));
|
||||
}
|
||||
loop {
|
||||
self.skip_ws();
|
||||
items.push(self.value()?);
|
||||
self.skip_ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
return Ok(Json::Arr(items));
|
||||
}
|
||||
_ => return Err(format!("expected ',' or ']' at byte {}", self.i)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn string(&mut self) -> Result<String, String> {
|
||||
self.expect(b'"')?;
|
||||
let mut out = String::new();
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => return Err("unterminated string".into()),
|
||||
Some(b'"') => {
|
||||
self.i += 1;
|
||||
return Ok(out);
|
||||
}
|
||||
Some(b'\\') => {
|
||||
self.i += 1;
|
||||
match self.peek() {
|
||||
Some(b'"') => out.push('"'),
|
||||
Some(b'\\') => out.push('\\'),
|
||||
Some(b'/') => out.push('/'),
|
||||
Some(b'b') => out.push('\u{0008}'),
|
||||
Some(b'f') => out.push('\u{000C}'),
|
||||
Some(b'n') => out.push('\n'),
|
||||
Some(b'r') => out.push('\r'),
|
||||
Some(b't') => out.push('\t'),
|
||||
Some(b'u') => {
|
||||
let cp = self.hex4()?;
|
||||
// surrogate pair handling (Python accepts these)
|
||||
if (0xD800..0xDC00).contains(&cp) {
|
||||
if self.b[self.i + 1..].starts_with(b"\\u") {
|
||||
self.i += 2;
|
||||
let lo = self.hex4()?;
|
||||
if (0xDC00..0xE000).contains(&lo) {
|
||||
let c = 0x10000
|
||||
+ ((cp - 0xD800) << 10)
|
||||
+ (lo - 0xDC00);
|
||||
out.push(
|
||||
char::from_u32(c)
|
||||
.ok_or("bad surrogate pair")?,
|
||||
);
|
||||
} else {
|
||||
// lone high surrogate — Python keeps it as
|
||||
// an escaped lone surrogate; we degrade to
|
||||
// U+FFFD (not produced by this writer)
|
||||
out.push('\u{FFFD}');
|
||||
}
|
||||
} else {
|
||||
out.push('\u{FFFD}');
|
||||
}
|
||||
} else {
|
||||
out.push(char::from_u32(cp).unwrap_or('\u{FFFD}'));
|
||||
}
|
||||
continue; // hex4 already advanced i
|
||||
}
|
||||
_ => return Err(format!("bad escape at byte {}", self.i)),
|
||||
}
|
||||
self.i += 1;
|
||||
}
|
||||
Some(_) => {
|
||||
// consume one UTF-8 scalar
|
||||
let s = &self.b[self.i..];
|
||||
let ch_len = utf8_len(s[0]);
|
||||
let chunk = std::str::from_utf8(&s[..ch_len.min(s.len())])
|
||||
.map_err(|_| "bad utf8")?;
|
||||
out.push_str(chunk);
|
||||
self.i += ch_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hex4(&mut self) -> Result<u32, String> {
|
||||
if self.i + 4 > self.b.len() {
|
||||
return Err("short \\u escape".into());
|
||||
}
|
||||
let s = std::str::from_utf8(&self.b[self.i + 1..self.i + 5])
|
||||
.map_err(|_| "bad \\u")?;
|
||||
let v = u32::from_str_radix(s, 16).map_err(|_| "bad \\u digits")?;
|
||||
self.i += 5; // 'u' + 4 hex digits (caller consumed the backslash)
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn number(&mut self) -> Result<Json, String> {
|
||||
let start = self.i;
|
||||
if self.peek() == Some(b'-') {
|
||||
self.i += 1;
|
||||
}
|
||||
while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
|
||||
self.i += 1;
|
||||
}
|
||||
if self.peek() == Some(b'.') {
|
||||
self.i += 1;
|
||||
while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
if matches!(self.peek(), Some(b'e') | Some(b'E')) {
|
||||
self.i += 1;
|
||||
if matches!(self.peek(), Some(b'+') | Some(b'-')) {
|
||||
self.i += 1;
|
||||
}
|
||||
while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
let s = std::str::from_utf8(&self.b[start..self.i]).map_err(|_| "bad number")?;
|
||||
s.parse::<f64>()
|
||||
.map(Json::Num)
|
||||
.map_err(|_| format!("bad number '{}'", s))
|
||||
}
|
||||
}
|
||||
|
||||
fn utf8_len(b: u8) -> usize {
|
||||
if b < 0x80 {
|
||||
1
|
||||
} else if b >> 5 == 0b110 {
|
||||
2
|
||||
} else if b >> 4 == 0b1110 {
|
||||
3
|
||||
} else {
|
||||
4
|
||||
}
|
||||
}
|
||||
|
||||
// ── serialization (Python json.dumps / json.dump(indent=2) parity) ─────────
|
||||
|
||||
/// Python `json.dumps(value, sort_keys=True)` — compact separators, object
|
||||
/// keys recursively sorted. Python sorts str keys by code point; Rust's
|
||||
/// byte-wise UTF-8 `String` ordering is identical for valid UTF-8, so a
|
||||
/// plain `sort_by` on the key is faithful.
|
||||
pub fn dumps_sorted(v: &Json) -> String {
|
||||
let mut out = String::new();
|
||||
write_compact_sorted(&mut out, v);
|
||||
out
|
||||
}
|
||||
|
||||
fn write_compact_sorted(out: &mut String, v: &Json) {
|
||||
match v {
|
||||
Json::Obj(pairs) => {
|
||||
let mut sorted: Vec<&(String, Json)> = pairs.iter().collect();
|
||||
sorted.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out.push('{');
|
||||
for (i, (k, val)) in sorted.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_escaped(out, k);
|
||||
out.push_str(": ");
|
||||
write_compact_sorted(out, val);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Json::Arr(items) => {
|
||||
out.push('[');
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_compact_sorted(out, item);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
other => write_compact(out, other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Python `json.dumps(value)` — compact separators `", "` / `": "`,
|
||||
/// ensure_ascii escaping.
|
||||
pub fn dumps(v: &Json) -> String {
|
||||
let mut out = String::new();
|
||||
write_compact(&mut out, v);
|
||||
out
|
||||
}
|
||||
|
||||
fn write_compact(out: &mut String, v: &Json) {
|
||||
match v {
|
||||
Json::Null => out.push_str("null"),
|
||||
Json::Bool(true) => out.push_str("true"),
|
||||
Json::Bool(false) => out.push_str("false"),
|
||||
Json::Num(n) => out.push_str(&fmt_num(*n)),
|
||||
Json::Str(s) => write_escaped(out, s),
|
||||
Json::Arr(items) => {
|
||||
out.push('[');
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_compact(out, item);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
Json::Obj(pairs) => {
|
||||
out.push('{');
|
||||
for (i, (k, val)) in pairs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_escaped(out, k);
|
||||
out.push_str(": ");
|
||||
write_compact(out, val);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Python `json.dump(value, indent=2)` — item separator becomes
|
||||
/// `",\n<indent>"`, key separator `": "`, empty container → `{}` / `[]`,
|
||||
/// NO trailing newline.
|
||||
pub fn dump_indent2(v: &Json) -> String {
|
||||
let mut out = String::new();
|
||||
write_indent(&mut out, v, 0);
|
||||
out
|
||||
}
|
||||
|
||||
fn write_indent(out: &mut String, v: &Json, depth: usize) {
|
||||
match v {
|
||||
Json::Arr(items) if !items.is_empty() => {
|
||||
out.push_str("[\n");
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(",\n");
|
||||
}
|
||||
push_indent(out, depth + 1);
|
||||
write_indent(out, item, depth + 1);
|
||||
}
|
||||
out.push('\n');
|
||||
push_indent(out, depth);
|
||||
out.push(']');
|
||||
}
|
||||
Json::Arr(_) => out.push_str("[]"),
|
||||
Json::Obj(pairs) if !pairs.is_empty() => {
|
||||
out.push_str("{\n");
|
||||
for (i, (k, val)) in pairs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(",\n");
|
||||
}
|
||||
push_indent(out, depth + 1);
|
||||
write_escaped(out, k);
|
||||
out.push_str(": ");
|
||||
write_indent(out, val, depth + 1);
|
||||
}
|
||||
out.push('\n');
|
||||
push_indent(out, depth);
|
||||
out.push('}');
|
||||
}
|
||||
Json::Obj(_) => out.push_str("{}"),
|
||||
other => write_compact(out, other),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_indent(out: &mut String, depth: usize) {
|
||||
for _ in 0..depth {
|
||||
out.push_str(" ");
|
||||
}
|
||||
}
|
||||
|
||||
/// Integer-shaped f64 prints without a decimal point (matching how this
|
||||
/// module's writers emit counts); all other values use shortest round-trip.
|
||||
pub fn fmt_num(n: f64) -> String {
|
||||
if n.is_finite() && n.fract() == 0.0 && n.abs() < 9.007_199_254_740_992e15 {
|
||||
return format!("{}", n as i64);
|
||||
}
|
||||
format!("{}", n)
|
||||
}
|
||||
|
||||
/// Python `json.dumps` string escaping with ensure_ascii=True.
|
||||
pub fn write_escaped(out: &mut String, s: &str) {
|
||||
out.push('"');
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\u{0008}' => out.push_str("\\b"),
|
||||
'\u{000C}' => out.push_str("\\f"),
|
||||
c if (c as u32) < 0x20 => {
|
||||
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||
}
|
||||
c if (c as u32) <= 0x7E => out.push(c),
|
||||
// ensure_ascii: escape everything non-ASCII, surrogate pairs
|
||||
// for astral planes (Python behaviour)
|
||||
c => {
|
||||
let cp = c as u32;
|
||||
if cp > 0xFFFF {
|
||||
let v = cp - 0x10000;
|
||||
let hi = 0xD800 + (v >> 10);
|
||||
let lo = 0xDC00 + (v & 0x3FF);
|
||||
let _ = write!(out, "\\u{:04x}\\u{:04x}", hi, lo);
|
||||
} else {
|
||||
let _ = write!(out, "\\u{:04x}", cp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! scoring.rs — capability_fit.evaluate_fit + capability_broker.score_card,
|
||||
//! faithful ports (H3 slice 4, contract BUILDER-2026-10-04T09:43:55Z-cli).
|
||||
//!
|
||||
//! Faithfulness traps (each canary-covered):
|
||||
//! * Python set math on provides/contracts (dedup, sorted missing list).
|
||||
//! * `superseded_by` truthiness joins SUPERSEDED/STALE as unhealthy.
|
||||
//! * EXACT when no contracts are required (missing is empty).
|
||||
//! * re.findall(r"\w+") hand-rolled as ASCII word-char runs [A-Za-z0-9_]+
|
||||
//! (Python's \w is Unicode-aware, but every input these sets see in
|
||||
//! production is ASCII; the canary covers the ASCII contract).
|
||||
//! * generic_caps penalty applies per OVERLAP COUNT, not per capability.
|
||||
|
||||
use crate::pyjson::Json;
|
||||
use crate::wordlists::{GENERIC_CAPS, STOPWORDS};
|
||||
|
||||
const MATURITY_ORDER: [&str; 6] = [
|
||||
"DECLARED", "PRESENT", "WIRED", "REACHABLE", "EXERCISED", "LIVE_PROVEN",
|
||||
];
|
||||
|
||||
fn maturity_rank(m: &str) -> i64 {
|
||||
MATURITY_ORDER.iter().position(|&x| x == m).map(|i| i as i64).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn obj_get<'a>(v: &'a Json, key: &str) -> Option<&'a Json> {
|
||||
match v {
|
||||
Json::Obj(pairs) => pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn str_list(v: Option<&Json>) -> Vec<String> {
|
||||
match v {
|
||||
Some(Json::Arr(items)) => items
|
||||
.iter()
|
||||
.filter_map(|x| match x {
|
||||
Json::Str(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn str_or<'a>(v: Option<&'a Json>, default: &'a str) -> &'a str {
|
||||
match v {
|
||||
Some(Json::Str(s)) => s,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
// ── evaluate_fit ────────────────────────────────────────────────────────────
|
||||
|
||||
/// DETERMINISTIC fit of one candidate against a requirement's contracts.
|
||||
/// `contracts` is the requirement's (already sorted) contract list.
|
||||
pub fn evaluate_fit(contracts: &[String], candidate: &Json) -> Json {
|
||||
let provides = str_list(obj_get(candidate, "provides"));
|
||||
let provides_set: Vec<&String> = provides.iter().collect();
|
||||
let mut missing: Vec<String> = contracts
|
||||
.iter()
|
||||
.filter(|c| !provides_set.contains(c))
|
||||
.cloned()
|
||||
.collect();
|
||||
missing.sort();
|
||||
missing.dedup();
|
||||
let covered = contracts.len() as i64 - missing.len() as i64;
|
||||
|
||||
let status = str_or(obj_get(candidate, "status"), "");
|
||||
let superseded_by = obj_get(candidate, "superseded_by");
|
||||
// Python bool(candidate.get("superseded_by")): any truthy value.
|
||||
let unhealthy = status == "SUPERSEDED"
|
||||
|| status == "STALE"
|
||||
|| matches!(superseded_by, Some(Json::Str(s)) if !s.is_empty())
|
||||
|| matches!(superseded_by, Some(Json::Bool(true)))
|
||||
|| matches!(superseded_by, Some(Json::Num(n)) if *n != 0.0);
|
||||
|
||||
let maturity = maturity_rank(str_or(obj_get(candidate, "maturity"), "DECLARED"));
|
||||
let id = str_or(obj_get(candidate, "id"), "");
|
||||
|
||||
let mk = |fit: &str, reason: String, covered: i64, with_extras: bool| {
|
||||
let mut out = Json::Obj(vec![
|
||||
("candidate".to_string(), Json::Str(id.to_string())),
|
||||
("fit".to_string(), Json::Str(fit.to_string())),
|
||||
("reason".to_string(), Json::Str(reason)),
|
||||
(
|
||||
"missing_contracts".to_string(),
|
||||
Json::Arr(missing.iter().map(|m| Json::Str(m.clone())).collect()),
|
||||
),
|
||||
("covered".to_string(), Json::Num(covered as f64)),
|
||||
]);
|
||||
if with_extras {
|
||||
if let Json::Obj(ref mut pairs) = out {
|
||||
pairs.push(("maturity_rank".to_string(), Json::Num(maturity as f64)));
|
||||
pairs.push((
|
||||
"consumers".to_string(),
|
||||
obj_get(candidate, "consumers").cloned().unwrap_or(Json::Arr(vec![])),
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
if unhealthy {
|
||||
return mk(
|
||||
"INCOMPATIBLE",
|
||||
format!("candidate status {} — not reusable as-is", status),
|
||||
covered,
|
||||
false,
|
||||
);
|
||||
}
|
||||
if !contracts.is_empty() && provides.is_empty() {
|
||||
return mk(
|
||||
"UNKNOWN",
|
||||
"candidate exposes no contracts to check".to_string(),
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
if missing.is_empty() {
|
||||
mk("EXACT", "all required contracts provided".to_string(), covered, true)
|
||||
} else if covered > 0 {
|
||||
mk(
|
||||
"PARTIAL",
|
||||
format!("covers {}/{} required contracts", covered, contracts.len()),
|
||||
covered,
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
mk(
|
||||
"INCOMPATIBLE",
|
||||
"provides none of the required contracts".to_string(),
|
||||
covered,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── score_card ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Python re.findall(r"\w+", s) for ASCII inputs: maximal [A-Za-z0-9_] runs.
|
||||
fn word_runs(s: &str) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut cur = String::new();
|
||||
for ch in s.chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
cur.push(ch);
|
||||
} else if !cur.is_empty() {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn word_set(s: &str) -> Vec<String> {
|
||||
let mut out = word_runs(s);
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
fn minus(hay: &[String], needles: &[&str]) -> Vec<String> {
|
||||
hay.iter().filter(|w| !needles.contains(&w.as_str())).cloned().collect()
|
||||
}
|
||||
|
||||
fn overlap_count(a: &[String], b: &[String]) -> usize {
|
||||
a.iter().filter(|w| b.contains(w)).count()
|
||||
}
|
||||
|
||||
pub fn score_card(card: &Json, query: &str) -> f64 {
|
||||
let query_low = query.to_lowercase();
|
||||
let query_words = word_set(&query_low);
|
||||
let query_content = minus(&query_words, &STOPWORDS);
|
||||
|
||||
let mut score = 0.0;
|
||||
|
||||
// Direct capability name match (highest weight, generic penalized)
|
||||
for cap in str_list(obj_get(card, "capabilities")) {
|
||||
let cap_words = word_set(&cap.to_lowercase());
|
||||
let overlap = overlap_count(&query_content, &cap_words);
|
||||
if overlap > 0 {
|
||||
if GENERIC_CAPS.contains(&cap.to_lowercase().as_str()) {
|
||||
score += 0.3 * overlap as f64;
|
||||
} else {
|
||||
score += 3.0 * overlap as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Description word match (medium weight)
|
||||
let desc = str_or(obj_get(card, "description"), "").to_lowercase();
|
||||
let desc_words = minus(&word_set(&desc), &STOPWORDS);
|
||||
score += 0.8 * overlap_count(&query_content, &desc_words) as f64;
|
||||
|
||||
// Semantic example match (high weight — curated)
|
||||
for example in str_list(obj_get(card, "semantic_examples")) {
|
||||
let example_words = minus(&word_set(&example.to_lowercase()), &STOPWORDS);
|
||||
let overlap = overlap_count(&query_content, &example_words);
|
||||
if overlap > 0 {
|
||||
score += 2.0 * overlap as f64;
|
||||
}
|
||||
}
|
||||
|
||||
// Name match (very high weight — direct name reference)
|
||||
let name_words = word_set(&str_or(obj_get(card, "name"), "").to_lowercase());
|
||||
let overlap = overlap_count(&query_words, &name_words);
|
||||
if overlap > 0 {
|
||||
score += 4.0 * overlap as f64;
|
||||
}
|
||||
|
||||
// Priority bonus (lower number = higher priority)
|
||||
let priority = match obj_get(card, "priority") {
|
||||
Some(Json::Num(n)) => *n,
|
||||
_ => 100.0,
|
||||
};
|
||||
score += ((100.0 - priority) / 200.0).max(0.0);
|
||||
|
||||
score
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Generated from capability_broker.py score_card literals — do not edit by hand.
|
||||
|
||||
pub const GENERIC_CAPS: [&str; 12] = [
|
||||
"audit",
|
||||
"growth",
|
||||
"memory_env",
|
||||
"read",
|
||||
"reasoning",
|
||||
"recall",
|
||||
"research",
|
||||
"skills",
|
||||
"status",
|
||||
"tasks",
|
||||
"voice",
|
||||
"write",
|
||||
];
|
||||
|
||||
pub const STOPWORDS: [&str; 94] = [
|
||||
"a",
|
||||
"about",
|
||||
"above",
|
||||
"after",
|
||||
"again",
|
||||
"against",
|
||||
"all",
|
||||
"an",
|
||||
"and",
|
||||
"are",
|
||||
"as",
|
||||
"at",
|
||||
"be",
|
||||
"because",
|
||||
"been",
|
||||
"before",
|
||||
"being",
|
||||
"below",
|
||||
"between",
|
||||
"both",
|
||||
"but",
|
||||
"by",
|
||||
"can",
|
||||
"could",
|
||||
"did",
|
||||
"do",
|
||||
"does",
|
||||
"during",
|
||||
"each",
|
||||
"every",
|
||||
"few",
|
||||
"for",
|
||||
"from",
|
||||
"further",
|
||||
"had",
|
||||
"has",
|
||||
"have",
|
||||
"here",
|
||||
"how",
|
||||
"if",
|
||||
"in",
|
||||
"into",
|
||||
"is",
|
||||
"it",
|
||||
"its",
|
||||
"just",
|
||||
"may",
|
||||
"might",
|
||||
"more",
|
||||
"most",
|
||||
"no",
|
||||
"nor",
|
||||
"not",
|
||||
"of",
|
||||
"off",
|
||||
"on",
|
||||
"once",
|
||||
"only",
|
||||
"or",
|
||||
"other",
|
||||
"out",
|
||||
"over",
|
||||
"own",
|
||||
"same",
|
||||
"shall",
|
||||
"should",
|
||||
"so",
|
||||
"some",
|
||||
"such",
|
||||
"than",
|
||||
"that",
|
||||
"the",
|
||||
"then",
|
||||
"there",
|
||||
"these",
|
||||
"this",
|
||||
"those",
|
||||
"through",
|
||||
"to",
|
||||
"too",
|
||||
"under",
|
||||
"very",
|
||||
"was",
|
||||
"were",
|
||||
"what",
|
||||
"when",
|
||||
"where",
|
||||
"which",
|
||||
"while",
|
||||
"who",
|
||||
"why",
|
||||
"will",
|
||||
"with",
|
||||
"would",
|
||||
];
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "evidence_provenance"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "evidence_provenance"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Phase G slice 2 (T7.3) — evidence-provenance Rust candidate: audit_ledger port 1 per frozen contract plans/2026-10-03-phase-g-evidence-provenance-contract.md"
|
||||
|
||||
[lib]
|
||||
name = "evidence_provenance"
|
||||
crate-type = ["rlib", "cdylib"]
|
||||
|
||||
[[bin]]
|
||||
name = "evidence_provenance"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,432 @@
|
||||
#!/usr/bin/env python3
|
||||
"""canary_evidence_provenance_rust.py — Rust canary for slice-2 ports 1-6.
|
||||
|
||||
Mirrors src/rune/canary_audit_ledger.py's PASS/FAIL discipline (counter
|
||||
harness, exit 1 on any FAIL) but drives the COMPILED RUST SURFACES — both the
|
||||
evidence_provenance binary (CLI parity shapes) and the cdylib via ep_loader
|
||||
(FFI contract: compute_hash / append_entry / verify_chain / get_entries /
|
||||
default_ledger_path / ep_verify / ep_validate_probe / ep_count_probes /
|
||||
ep_bl_* / ep_habit_reinforce / ep_correction_reinforce / ep_ca_*) — against
|
||||
the live-chain key rule from the frozen contract §3.
|
||||
|
||||
All expectations pinned to OBSERVED behaviour (probed 2026-10-03):
|
||||
- binary verify on missing ledger -> "Chain valid: True" + "Entries: 0" +
|
||||
"Reason: empty ledger", exit 0
|
||||
- binary no-args -> help + exit 1; invalid choice -> exit 2
|
||||
- binary resolves the deployed-vintage default path under fake HOME
|
||||
- ep_loader cdylib functions present and callable; live chain intact
|
||||
- §4 live-chain check is dual-mode (same law as the Python canary): the
|
||||
entries-floor pin fires only when the live ledger exists in this HOME;
|
||||
otherwise an isolated FFI-built ledger proves the same shapes.
|
||||
|
||||
Run under ~/.venvs/memory/bin/python3.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
CRATE = os.path.join(REPO, "src", "rust", "evidence_provenance")
|
||||
BIN = os.path.join(CRATE, "target", "release", "evidence_provenance")
|
||||
|
||||
sys.path.insert(0, CRATE)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
global passed, failed
|
||||
mark = "✓" if cond else "✗"
|
||||
print(f" {mark} {name}" + (f" [{detail}]" if detail and not cond else ""))
|
||||
if cond:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
|
||||
def run(home, *args):
|
||||
env = dict(os.environ, HOME=home)
|
||||
return subprocess.run([BIN, *args], capture_output=True, text=True, env=env)
|
||||
|
||||
|
||||
def main():
|
||||
global passed, failed
|
||||
|
||||
print("=== EVIDENCE PROVENANCE RUST CANARY (slice-2 port 1) ===\n")
|
||||
|
||||
# --- 1. Build artifacts present ---
|
||||
print("--- 1. Build artifacts ---")
|
||||
check("release binary exists", os.path.exists(BIN), BIN)
|
||||
check("release cdylib exists",
|
||||
os.path.exists(os.path.join(CRATE, "target", "release",
|
||||
"libevidence_provenance.so")))
|
||||
|
||||
# --- 2. CLI shapes ---
|
||||
print("\n--- 2. CLI shapes ---")
|
||||
tmp = tempfile.mkdtemp(prefix="epcanary_")
|
||||
try:
|
||||
r = run(tmp, "verify")
|
||||
check("verify on missing ledger -> valid-empty, exit 0",
|
||||
r.returncode == 0 and "Chain valid: True" in r.stdout
|
||||
and "Entries: 0" in r.stdout and "empty ledger" in r.stdout,
|
||||
f"rc={r.returncode} out={r.stdout!r}")
|
||||
r = run(tmp)
|
||||
check("no-args -> help + exit 1", r.returncode == 1 and "usage:" in r.stdout,
|
||||
f"rc={r.returncode}")
|
||||
r = run(tmp, "frobnicate")
|
||||
check("invalid choice -> exit 2",
|
||||
r.returncode == 2 and "invalid choice" in r.stderr,
|
||||
f"rc={r.returncode} err={r.stderr!r}")
|
||||
r = run(tmp, "list")
|
||||
check("list on missing ledger -> 'No ledger entries.', exit 0",
|
||||
r.returncode == 0 and r.stdout.strip() == "No ledger entries.",
|
||||
f"rc={r.returncode} out={r.stdout!r}")
|
||||
r = run(tmp, "list", "--limit")
|
||||
check("list --limit without value -> exit 2",
|
||||
r.returncode == 2 and "expected one argument" in r.stderr,
|
||||
f"rc={r.returncode} err={r.stderr!r}")
|
||||
r = run(tmp, "list", "--limit", "abc")
|
||||
check("list --limit abc -> exit 2 invalid int",
|
||||
r.returncode == 2 and "invalid int value: 'abc'" in r.stderr,
|
||||
f"rc={r.returncode} harness err={r.stderr!r}")
|
||||
r = run(tmp, "list", "--limit=3")
|
||||
check("--limit=3 accepted (argparse --limit=N form)",
|
||||
r.returncode == 0,
|
||||
f"rc={r.returncode} err={r.stderr!r}")
|
||||
r = run(tmp, "log-promotion")
|
||||
check("log-promotion missing args -> exit 2 usage",
|
||||
r.returncode == 2 and "usage:" in r.stderr,
|
||||
f"rc={r.returncode} err={r.stderr!r}")
|
||||
r = run(tmp, "log-promotion", "4242", "established", "rock", "20", "canary")
|
||||
check("log-promotion appends under fake HOME, exit 0",
|
||||
r.returncode == 0 and "Logged:" in r.stdout,
|
||||
f"rc={r.returncode} out={r.stdout!r} err={r.stderr!r}")
|
||||
r = run(tmp, "verify")
|
||||
check("verify after append -> chain intact, 1 entry",
|
||||
r.returncode == 0 and "Chain valid: True" in r.stdout
|
||||
and "Entries: 1" in r.stdout and "chain intact" in r.stdout,
|
||||
f"rc={r.returncode} out={r.stdout!r}")
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
# --- 3. FFI contract via ep_loader ---
|
||||
print("\n--- 3. FFI contract (ep_loader) ---")
|
||||
try:
|
||||
import ep_loader
|
||||
|
||||
for fn in ("compute_hash", "append_entry", "verify_chain", "get_entries",
|
||||
"default_ledger_path"):
|
||||
check(f"ep_loader has {fn}", hasattr(ep_loader, fn))
|
||||
p = ep_loader.default_ledger_path()
|
||||
check("default_ledger_path matches deployed default",
|
||||
p.endswith("promotion_ledger.jsonl"), p)
|
||||
h = ep_loader.compute_hash("genesis", '{"entry_id": 1}', "k")
|
||||
check("compute_hash returns 64-hex", isinstance(h, str) and len(h) == 64
|
||||
and all(c in "0123456789abcdef" for c in h), h[:16])
|
||||
tmp2 = tempfile.mkdtemp(prefix="epcanary_ffi_")
|
||||
try:
|
||||
fake = os.path.join(tmp2, "promotion_ledger.jsonl")
|
||||
e = ep_loader.append_entry(fake, "rune-drift-integrity-2024",
|
||||
"promotion", 7, '{"reason": "canary"}')
|
||||
check("FFI append_entry returns hash-chained dict",
|
||||
isinstance(e, dict) and "hash" in e and "prev_hash" in e,
|
||||
str(e)[:120])
|
||||
v = ep_loader.verify_chain(fake, "rune-drift-integrity-2024")
|
||||
check("FFI verify_chain on fresh ledger -> intact",
|
||||
v.get("valid") is True and v.get("entries") == 1, str(v))
|
||||
g = ep_loader.get_entries(fake, 5)
|
||||
check("FFI get_entries returns the appended entry",
|
||||
isinstance(g, list) and len(g) == 1 and g[0].get("entry_id") == 7,
|
||||
str(g)[:120])
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(tmp2, ignore_errors=True)
|
||||
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
check("ep_loader import + FFI contract", False, str(e))
|
||||
|
||||
# --- 4. Live-chain key rule (read-only; dual-mode) ---
|
||||
print("\n--- 4. Live-chain key rule ---")
|
||||
live = os.path.expanduser("~/.config/opencode/memory/promotion_ledger.jsonl")
|
||||
try:
|
||||
import ep_loader
|
||||
|
||||
if os.path.isfile(live):
|
||||
v = ep_loader.verify_chain(live, "rune-drift-integrity-2024")
|
||||
check("live ledger verifies under the DRIFT key (§3 linchpin)",
|
||||
v.get("valid") is True and v.get("entries") >= 23,
|
||||
f"valid={v.get('valid')} entries={v.get('entries')}")
|
||||
else:
|
||||
# CI mode: no live ledger in this HOME — prove the same chain
|
||||
# shapes on an FFI-built isolated ledger instead.
|
||||
iso = os.path.join(tempfile.mkdtemp(prefix="epcanary_live_"),
|
||||
"promotion_ledger.jsonl")
|
||||
ep_loader.append_entry(iso, "rune-drift-integrity-2024",
|
||||
"promotion", 9001, '{"reason": "canary-ci"}')
|
||||
ep_loader.append_entry(iso, "rune-drift-integrity-2024",
|
||||
"promotion", 9002, '{"reason": "canary-ci"}')
|
||||
v = ep_loader.verify_chain(iso, "rune-drift-integrity-2024")
|
||||
check("isolated FFI ledger verifies under the DRIFT key (CI mode)",
|
||||
v.get("valid") is True and v.get("entries") == 2
|
||||
and v.get("reason") == "chain intact", f"got {v}")
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
check("ledger drift-key verification", False, str(e))
|
||||
|
||||
# --- 5. Port 2 (verification_layer) FFI ---
|
||||
print("\n--- 5. Port 2: verification_layer ep_verify FFI ---")
|
||||
import json
|
||||
try:
|
||||
v = ep_loader.verify('{"exit_code": 0}', "exit_code")
|
||||
o = json.loads(v)
|
||||
check("ep_verify exit_code 0 -> passed", o.get("passed") is True, str(o)[:100])
|
||||
v = ep_loader.verify('{"exit_code": false}', "exit_code")
|
||||
o = json.loads(v)
|
||||
check("ep_verify bool-false str() rendering (False, not 0)",
|
||||
o.get("passed") is True and o.get("evidence_refs") == ["exit_code=False"],
|
||||
str(o)[:120])
|
||||
v = ep_loader.verify('{"tests_passed": 8, "tests_failed": 2, "tests_total": 10}', "unit_test")
|
||||
o = json.loads(v)
|
||||
check("ep_verify unit_test fail -> 2 tests failed",
|
||||
o.get("passed") is False and "2 tests failed" in o.get("failures", []),
|
||||
str(o)[:120])
|
||||
v = ep_loader.verify('{}', "frobnicate")
|
||||
o = json.loads(v)
|
||||
check("ep_verify unknown verifier -> unknown_verifier shape",
|
||||
o.get("passed") is False and o.get("retryable") is False
|
||||
and o.get("escalation_reason") == "unknown_verifier", str(o)[:120])
|
||||
# to_dict key order parity (insertion order = Python dict literal)
|
||||
v = json.loads(ep_loader.verify('{}', "constraint_check"))
|
||||
check("ep_verify to_dict key order parity",
|
||||
list(v.keys()) == ["passed", "verifier_type", "evidence_refs",
|
||||
"failures", "retryable", "escalation_reason"],
|
||||
str(list(v.keys())))
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
check("port-2 ep_verify FFI", False, str(e))
|
||||
|
||||
# --- 6. Port 3 (evidence_probe): schema gate + budget count FFI ---
|
||||
# Expectations probed against the cdylib 2026-10-03.
|
||||
print("\n--- 6. Port 3: ep_validate_probe / ep_count_probes FFI ---")
|
||||
try:
|
||||
import ep_loader
|
||||
|
||||
errs = ep_loader.validate_probe("{}")
|
||||
check("empty artifact -> 5 missing-field errors in field order",
|
||||
errs == ["missing required field: question",
|
||||
"missing required field: source",
|
||||
"missing required field: answer",
|
||||
"missing required field: implication",
|
||||
"missing required field: status"], f"got {errs}")
|
||||
good = json.dumps({"question": "q", "source": "s", "answer": "a",
|
||||
"implication": "i", "status": "STILL_UNKNOWN"})
|
||||
check("valid STILL_UNKNOWN artifact -> []",
|
||||
ep_loader.validate_probe(good) == [])
|
||||
int_status = json.dumps({"question": "q", "source": "s", "answer": "a",
|
||||
"implication": "i", "status": 7})
|
||||
check("non-string status -> field error AND membership error",
|
||||
ep_loader.validate_probe(int_status) ==
|
||||
["field status must be a string",
|
||||
"status must be one of ['CONTRADICTED', 'GROUNDED', 'STILL_UNKNOWN'], got 7"],
|
||||
f"got {ep_loader.validate_probe(int_status)}")
|
||||
blank_q = json.dumps({"question": "", "source": "s", "answer": "a",
|
||||
"implication": "i", "status": "GROUNDED"})
|
||||
check("blank question + GROUNDED -> non-empty + citation errors",
|
||||
ep_loader.validate_probe(blank_q) ==
|
||||
["field question must be a non-empty string",
|
||||
"GROUNDED probe requires a concrete source citation"],
|
||||
f"got {ep_loader.validate_probe(blank_q)}")
|
||||
extra = json.dumps({"question": "q", "source": "s", "answer": "a",
|
||||
"implication": "i", "status": "GROUNDED",
|
||||
"source_url": "https://x", "extra": 1})
|
||||
tail_errs = ep_loader.validate_probe(extra)
|
||||
check("extra fields error appended (sorted names)",
|
||||
tail_errs[-1].startswith("unexpected extra fields:"),
|
||||
f"got {tail_errs}")
|
||||
check("non-object artifact -> single not-an-object error",
|
||||
ep_loader.validate_probe("[1, 2]") == ["probe artifact is not a JSON object"])
|
||||
|
||||
pd = tempfile.mkdtemp(prefix="epcanary_probe_")
|
||||
try:
|
||||
with open(os.path.join(pd, "probe.jsonl"), "w") as f:
|
||||
f.write('{"q": 1}\n\n{"r": 2}\n\n\n{"s": 3}\n')
|
||||
check("count_probes counts non-empty lines only",
|
||||
ep_loader.count_probes(pd) == 3)
|
||||
check("count_probes on missing dir -> 0",
|
||||
ep_loader.count_probes(pd + "/nope") == 0)
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(pd, ignore_errors=True)
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
check("port-3 probe FFI", False, str(e))
|
||||
|
||||
# --- 7. Port 4 (behaviour_ledger): doc-envelope state machine FFI ---
|
||||
print("\n--- 7. Port 4: ep_bl_* FFI (doc-in/doc-out) ---")
|
||||
try:
|
||||
import ep_loader
|
||||
|
||||
doc = {"behaviours": {}}
|
||||
doc, rec = ep_loader.bl_define(
|
||||
doc, behaviour_id="alpha", owning_project="proj",
|
||||
intended_behaviour="ib", owner_component="comp:alpha",
|
||||
dependencies=["dep:one"], now="2026-10-03T23:00:00Z")
|
||||
check("define -> normalized id + UNVALIDATED + evidence/history lists",
|
||||
rec["behaviour_id"] == "behaviour:alpha" and rec["state"] == "UNVALIDATED"
|
||||
and rec["evidence"] == [] and rec["history"] == [])
|
||||
doc, _ = ep_loader.bl_record_auto(doc, "alpha", passed=True,
|
||||
detail="tests green", generation="g1",
|
||||
now="2026-10-03T23:01:00Z")
|
||||
check("record_auto pass -> AUTO_VERIFIED",
|
||||
ep_loader.bl_get(doc, "alpha")["state"] == "AUTO_VERIFIED")
|
||||
doc, _ = ep_loader.bl_define(doc, behaviour_id="beta", owning_project="proj",
|
||||
intended_behaviour="ib2", now="2026-10-03T23:02:00Z")
|
||||
doc, _ = ep_loader.bl_record_auto(doc, "beta", passed=False,
|
||||
now="2026-10-03T23:03:00Z")
|
||||
check("record_auto fail -> TEST_FAILED",
|
||||
ep_loader.bl_get(doc, "beta")["state"] == "TEST_FAILED")
|
||||
|
||||
doc, untouched = ep_loader.bl_invalidate(doc, changed_entities=["dep:zzz"],
|
||||
now="2026-10-03T23:04:00Z")
|
||||
check("unrelated change invalidates nothing", untouched == [])
|
||||
doc, hit = ep_loader.bl_invalidate(doc, changed_entities=["dep:one"],
|
||||
generation="g2", now="2026-10-03T23:05:00Z")
|
||||
check("dep hit -> RETEST_REQUIRED with default Python-repr reason",
|
||||
len(hit) == 1 and hit[0]["state"] == "RETEST_REQUIRED"
|
||||
and hit[0]["invalidations"][0]["because"]
|
||||
== "structural change touched: ['dep:one']",
|
||||
f"got {hit[0]['invalidations'] if hit else hit}")
|
||||
check("invalidation entry records was/generation/changed_entities",
|
||||
hit[0]["invalidations"][0]["was"] == "AUTO_VERIFIED"
|
||||
and hit[0]["invalidations"][0]["generation"] == "g2"
|
||||
and hit[0]["invalidations"][0]["changed_entities"] == ["dep:one"])
|
||||
doc, rehit = ep_loader.bl_invalidate(doc, changed_entities=["dep:one"],
|
||||
now="2026-10-03T23:06:00Z")
|
||||
check("non-confirmed record is not re-invalidated", rehit == [])
|
||||
|
||||
out = ep_loader.bl_outstanding(doc)
|
||||
check("outstanding() is TEST_FAILED-first",
|
||||
out and out[0]["state"] == "TEST_FAILED")
|
||||
check("retest_history kinds: automated_verification then selective_invalidation",
|
||||
[e["kind"] for e in ep_loader.bl_retest_history(doc, "alpha")]
|
||||
== ["automated_verification", "selective_invalidation"])
|
||||
summ = ep_loader.bl_summary(doc)
|
||||
check("summary() = {total, states, outstanding}",
|
||||
summ.get("total") == 2 and isinstance(summ.get("states"), dict)
|
||||
and "outstanding" in summ, f"got {summ}")
|
||||
|
||||
try:
|
||||
ep_loader.bl_define(doc, behaviour_id="alpha", owning_project="x",
|
||||
intended_behaviour="y", now="2026-10-03T23:07:00Z")
|
||||
check("duplicate define raises", False, "no raise")
|
||||
except ValueError as e:
|
||||
check("duplicate define error byte-exact",
|
||||
str(e) == "behaviour exists: behaviour:alpha", f"got {e}")
|
||||
try:
|
||||
ep_loader.bl_record_auto(doc, "ghost", passed=True, now="2026-10-03T23:08:00Z")
|
||||
check("unknown behaviour raises", False, "no raise")
|
||||
except ValueError as e:
|
||||
check("unknown behaviour error byte-exact",
|
||||
str(e) == "unknown behaviour 'ghost'", f"got {e}")
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
check("port-4 behaviour_ledger FFI", False, str(e))
|
||||
|
||||
# --- 8. Port 5 (behavior): decision math FFI (zero-dep split law:
|
||||
# SQLite I/O stays Python; counting/confidence/state math is Rust).
|
||||
print("\n--- 8. Port 5: ep_habit_reinforce / ep_correction_reinforce FFI ---")
|
||||
try:
|
||||
import ep_loader
|
||||
|
||||
row = {"activation_count": 3, "success_count": 2, "failure_count": 1,
|
||||
"confidence": 0.5, "state": "CANDIDATE"}
|
||||
r = ep_loader.habit_reinforce(row, "success")
|
||||
check("habit success: counts +1/success+1, confidence 0.875",
|
||||
r == {"activation_count": 4, "success_count": 3, "failure_count": 1,
|
||||
"confidence": 0.875, "state": "CANDIDATE"}, f"got {r}")
|
||||
r = ep_loader.habit_reinforce(row, "failure")
|
||||
check("habit failure: failure+1, confidence 0.75",
|
||||
r == {"activation_count": 4, "success_count": 2, "failure_count": 2,
|
||||
"confidence": 0.75, "state": "CANDIDATE"}, f"got {r}")
|
||||
r = ep_loader.habit_reinforce({"activation_count": 2, "success_count": 1,
|
||||
"failure_count": 1, "confidence": 0.75,
|
||||
"state": "active"}, "frobnicate")
|
||||
check("unknown outcome: activation+1, confidence recomputed from counts",
|
||||
r == {"activation_count": 3, "success_count": 1, "failure_count": 1,
|
||||
"confidence": 0.6666666666666666, "state": "active"}, f"got {r}")
|
||||
r = ep_loader.habit_reinforce({"activation_count": 2, "success_count": 2,
|
||||
"failure_count": 0, "confidence": 0.5,
|
||||
"state": "candidate"}, "success")
|
||||
check("lowercase candidate promotes at min_activations (3) -> active, conf 1",
|
||||
r["state"] == "active" and r["confidence"] == 1, f"got {r}")
|
||||
r = ep_loader.habit_reinforce(row, "success")
|
||||
check("uppercase CANDIDATE is NOT promoted (case-sensitive state law)",
|
||||
r["state"] == "CANDIDATE", f"got {r['state']}")
|
||||
|
||||
r = ep_loader.correction_reinforce({"confidence": 0.4, "evidence_count": 2,
|
||||
"state": "ACTIVE"}, "success")
|
||||
check("correction success: +0.1 conf, evidence+1",
|
||||
r == {"confidence": 0.5, "evidence_count": 3, "state": "active"}, f"got {r}")
|
||||
r = ep_loader.correction_reinforce({"confidence": 0.4, "evidence_count": 2,
|
||||
"state": "ACTIVE"}, "failure")
|
||||
check("correction failure: -0.3 conf (f64 exact), archived under 0.3",
|
||||
r == {"confidence": 0.10000000000000003, "evidence_count": 2,
|
||||
"state": "archived"}, f"got {r}")
|
||||
r = ep_loader.correction_reinforce({"confidence": 0.6, "evidence_count": 3,
|
||||
"state": "active"}, "failure")
|
||||
check("boundary: 0.6-0.3 == 0.3 archives (not > 0.3)",
|
||||
r["state"] == "archived" and r["confidence"] == 0.3, f"got {r}")
|
||||
r = ep_loader.correction_reinforce({"confidence": 0.61, "evidence_count": 3,
|
||||
"state": "active"}, "failure")
|
||||
check("boundary: 0.61-0.3 == 0.31 stays active",
|
||||
r["state"] == "active" and r["confidence"] == 0.31, f"got {r}")
|
||||
r = ep_loader.correction_reinforce({"confidence": 0.5, "evidence_count": 3,
|
||||
"state": "active"}, "frobnicate")
|
||||
check("correction unknown outcome: counts pass through unchanged",
|
||||
r == {"confidence": 0.5, "evidence_count": 3, "state": "active"}, f"got {r}")
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
check("port-5 behavior math FFI", False, str(e))
|
||||
|
||||
# --- 9. Port 6 (claim_audit): decision skeleton FFI ---
|
||||
print("\n--- 9. Port 6: ep_ca_* FFI ---")
|
||||
try:
|
||||
import ep_loader
|
||||
|
||||
check("gate: plain unsupported -> DEGRADE/0",
|
||||
ep_loader.ca_verify_gate(False, False, 0, False)
|
||||
== {"verdict": "DEGRADE", "evidence_hits": 0})
|
||||
check("gate: comparative without anchor -> DEGRADE even with a hit",
|
||||
ep_loader.ca_verify_gate(True, False, 1, True)
|
||||
== {"verdict": "DEGRADE", "evidence_hits": 0})
|
||||
check("gate: quantified-but-no-hits -> DEGRADE",
|
||||
ep_loader.ca_verify_gate(False, True, 0, True)
|
||||
== {"verdict": "DEGRADE", "evidence_hits": 0})
|
||||
check("gate: ledger hit -> PASS with hits",
|
||||
ep_loader.ca_verify_gate(False, False, 1, False)
|
||||
== {"verdict": "PASS", "evidence_hits": 1})
|
||||
check("gate: comparative+quantified+hits -> PASS",
|
||||
ep_loader.ca_verify_gate(True, True, 2, True)
|
||||
== {"verdict": "PASS", "evidence_hits": 2})
|
||||
check("audit_clean fold: all-PASS True / mixed False / empty True",
|
||||
ep_loader.ca_audit_clean([{"verdict": "PASS"}, {"verdict": "PASS"}]) is True
|
||||
and ep_loader.ca_audit_clean([{"verdict": "PASS"}, {"verdict": "DEGRADE"}]) is False
|
||||
and ep_loader.ca_audit_clean([]) is True)
|
||||
agg = ep_loader.ca_report_aggregate([{"verdict": "PASS", "type": "factual"},
|
||||
{"verdict": "DEGRADE", "type": "factual"}])
|
||||
check("report_aggregate: total + sorted by_verdict + by_type",
|
||||
agg == {"total": 2, "by_verdict": {"DEGRADE": 1, "PASS": 1},
|
||||
"by_type": {"factual": 2}}, f"got {agg}")
|
||||
check("evidence_terms: <4 chars + stoplist trio dropped",
|
||||
ep_loader.ca_evidence_terms(["hash", "gite", "abc", "ab",
|
||||
"This is", "That is", "For the"])
|
||||
== ["hash", "gite"])
|
||||
except Exception as e: # noqa: BLE001 — canary battery law: a failed probe is a FAIL test, not a crash
|
||||
check("port-6 claim_audit FFI", False, str(e))
|
||||
|
||||
print(f"\n{'=' * 47}")
|
||||
print(f"RESULTS: {passed} pass, {failed} fail")
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ctypes loader for the Rust evidence_provenance core (Phase G strangler slice 2).
|
||||
|
||||
Serves the deployed audit_ledger consumer shape. The deployed consumers
|
||||
(`~/.config/opencode/scripts/curator.py` via `log_promotion` 5-positional;
|
||||
`canary_full_system.py` via `log_promotion` + no-arg `verify_chain()`) consume
|
||||
the HMAC-chained promotion ledger IN-PROCESS — this loader gives them the Rust
|
||||
core through the cdylib with no interpreter startup and no CLI subprocess.
|
||||
|
||||
Contract (frozen `plans/2026-10-03-phase-g-evidence-provenance-contract.md`):
|
||||
* compute_hash(prev_hash, entry_json, key) -> str
|
||||
* append_entry(ledger_path, key, entry_type, entry_id, details_json) -> dict
|
||||
* verify_chain(ledger_path, key) -> dict {valid, entries, reason}
|
||||
* get_entries(ledger_path, limit) -> list[dict]
|
||||
* default_ledger_path() -> str
|
||||
|
||||
Isolation law: both implementations resolve $HOME at runtime — tests MUST pin
|
||||
HOME to an isolated dir (or pass ledger_path explicitly) so cross-implementation
|
||||
probes never touch the live ledger at
|
||||
`~/.config/opencode/memory/promotion_ledger.jsonl`.
|
||||
|
||||
Zero dependencies. The .so path resolves next to this file (cargo build
|
||||
--release output); override with EP_RUST_LIB env var.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_LIB_DIR = Path(__file__).resolve().parent / "target" / "release"
|
||||
_LIB_PATH = Path(os.environ.get("EP_RUST_LIB", _LIB_DIR / "libevidence_provenance.so"))
|
||||
|
||||
_lib = None
|
||||
|
||||
|
||||
def _load() -> ctypes.CDLL:
|
||||
global _lib
|
||||
if _lib is not None:
|
||||
return _lib
|
||||
if not _LIB_PATH.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Rust core not built: {_LIB_PATH} missing — run "
|
||||
f"`cargo build --release` in {Path(__file__).resolve().parent}"
|
||||
)
|
||||
lib = ctypes.CDLL(str(_LIB_PATH))
|
||||
lib.ep_free_string.argtypes = [ctypes.c_void_p]
|
||||
lib.ep_free_string.restype = None
|
||||
lib.ep_compute_hash.argtypes = [
|
||||
ctypes.c_char_p, # prev_hash
|
||||
ctypes.c_char_p, # entry_json
|
||||
ctypes.c_char_p, # key (nullable)
|
||||
]
|
||||
lib.ep_compute_hash.restype = ctypes.c_void_p # char*
|
||||
lib.ep_append_entry.argtypes = [
|
||||
ctypes.c_char_p, # ledger_path (nullable)
|
||||
ctypes.c_char_p, # key (nullable)
|
||||
ctypes.c_char_p, # entry_type
|
||||
ctypes.c_int64, # entry_id
|
||||
ctypes.c_char_p, # details_json
|
||||
]
|
||||
lib.ep_append_entry.restype = ctypes.c_void_p # char* (entry JSON)
|
||||
lib.ep_verify_chain.argtypes = [
|
||||
ctypes.c_char_p, # ledger_path (nullable)
|
||||
ctypes.c_char_p, # key (nullable)
|
||||
]
|
||||
lib.ep_verify_chain.restype = ctypes.c_void_p # char* (result JSON)
|
||||
lib.ep_get_entries.argtypes = [
|
||||
ctypes.c_char_p, # ledger_path (nullable)
|
||||
ctypes.c_int, # limit
|
||||
]
|
||||
lib.ep_get_entries.restype = ctypes.c_void_p # char* (JSON array)
|
||||
lib.ep_default_ledger_path.restype = ctypes.c_void_p # char*
|
||||
lib.ep_verify.argtypes = [
|
||||
ctypes.c_char_p, # result_json
|
||||
ctypes.c_char_p, # verifier_type
|
||||
]
|
||||
lib.ep_verify.restype = ctypes.c_void_p # char* (to_dict JSON)
|
||||
lib.ep_validate_probe.argtypes = [
|
||||
ctypes.c_char_p, # artifact_json
|
||||
]
|
||||
lib.ep_validate_probe.restype = ctypes.c_void_p # char* (errors JSON array)
|
||||
lib.ep_count_probes.argtypes = [
|
||||
ctypes.c_char_p, # dir
|
||||
]
|
||||
lib.ep_count_probes.restype = ctypes.c_int # count
|
||||
lib.ep_bl_define.argtypes = [
|
||||
ctypes.c_char_p, # doc_json
|
||||
ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p,
|
||||
ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p,
|
||||
ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p,
|
||||
]
|
||||
lib.ep_bl_define.restype = ctypes.c_void_p
|
||||
lib.ep_bl_record_auto.argtypes = [
|
||||
ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int,
|
||||
ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p,
|
||||
]
|
||||
lib.ep_bl_record_auto.restype = ctypes.c_void_p
|
||||
lib.ep_bl_record_real_world.argtypes = [
|
||||
ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int,
|
||||
ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p,
|
||||
]
|
||||
lib.ep_bl_record_real_world.restype = ctypes.c_void_p
|
||||
lib.ep_bl_accept_limitation.argtypes = [
|
||||
ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p,
|
||||
ctypes.c_char_p, ctypes.c_char_p,
|
||||
]
|
||||
lib.ep_bl_accept_limitation.restype = ctypes.c_void_p
|
||||
lib.ep_bl_invalidate.argtypes = [
|
||||
ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p,
|
||||
ctypes.c_char_p, ctypes.c_char_p,
|
||||
]
|
||||
lib.ep_bl_invalidate.restype = ctypes.c_void_p
|
||||
lib.ep_bl_get.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
lib.ep_bl_get.restype = ctypes.c_void_p
|
||||
lib.ep_bl_retest_history.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
lib.ep_bl_retest_history.restype = ctypes.c_void_p
|
||||
lib.ep_bl_outstanding.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p]
|
||||
lib.ep_bl_outstanding.restype = ctypes.c_void_p
|
||||
lib.ep_bl_summary.argtypes = [ctypes.c_char_p]
|
||||
lib.ep_bl_summary.restype = ctypes.c_void_p
|
||||
lib.ep_habit_reinforce.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
lib.ep_habit_reinforce.restype = ctypes.c_void_p
|
||||
lib.ep_correction_reinforce.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
lib.ep_correction_reinforce.restype = ctypes.c_void_p
|
||||
lib.ep_ca_verify_gate.argtypes = [
|
||||
ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int,
|
||||
]
|
||||
lib.ep_ca_verify_gate.restype = ctypes.c_void_p
|
||||
lib.ep_ca_audit_clean.argtypes = [ctypes.c_char_p]
|
||||
lib.ep_ca_audit_clean.restype = ctypes.c_int
|
||||
lib.ep_ca_report_aggregate.argtypes = [ctypes.c_char_p]
|
||||
lib.ep_ca_report_aggregate.restype = ctypes.c_void_p
|
||||
lib.ep_ca_evidence_terms.argtypes = [ctypes.c_char_p]
|
||||
lib.ep_ca_evidence_terms.restype = ctypes.c_void_p
|
||||
_lib = lib
|
||||
return lib
|
||||
|
||||
|
||||
def _take_string(ptr: int) -> str:
|
||||
"""Own a char* returned by the lib and free it."""
|
||||
lib = _load()
|
||||
try:
|
||||
raw = ctypes.string_at(ptr)
|
||||
return raw.decode("utf-8")
|
||||
finally:
|
||||
lib.ep_free_string(ctypes.c_void_p(ptr))
|
||||
|
||||
|
||||
def compute_hash(prev_hash: str, entry_json: str, key: str | None) -> str:
|
||||
"""Chain hash for one entry. Rust strips a `hash` key exactly as verify does."""
|
||||
ptr = _load().ep_compute_hash(
|
||||
prev_hash.encode("utf-8"),
|
||||
entry_json.encode("utf-8"),
|
||||
key.encode("utf-8") if key is not None else None,
|
||||
)
|
||||
if not ptr:
|
||||
raise RuntimeError("ep_compute_hash returned null (bad JSON or args)")
|
||||
return _take_string(ptr)
|
||||
|
||||
|
||||
def append_entry(
|
||||
ledger_path: str,
|
||||
key: str | None,
|
||||
entry_type: str,
|
||||
entry_id: int,
|
||||
details_json: str,
|
||||
) -> dict:
|
||||
"""Append one entry; returns the full entry dict (file line, minus newline)."""
|
||||
ptr = _load().ep_append_entry(
|
||||
ledger_path.encode("utf-8"),
|
||||
key.encode("utf-8") if key is not None else None,
|
||||
entry_type.encode("utf-8"),
|
||||
int(entry_id),
|
||||
details_json.encode("utf-8"),
|
||||
)
|
||||
if not ptr:
|
||||
raise RuntimeError("ep_append_entry returned null (bad args or IO error)")
|
||||
return json.loads(_take_string(ptr))
|
||||
|
||||
|
||||
def verify_chain(ledger_path: str, key: str | None) -> dict:
|
||||
"""Verify the whole chain; returns {valid, entries, reason}."""
|
||||
ptr = _load().ep_verify_chain(
|
||||
ledger_path.encode("utf-8"),
|
||||
key.encode("utf-8") if key is not None else None,
|
||||
)
|
||||
if not ptr:
|
||||
raise RuntimeError("ep_verify_chain returned null")
|
||||
return json.loads(_take_string(ptr))
|
||||
|
||||
|
||||
def get_entries(ledger_path: str, limit: int) -> list:
|
||||
"""Recent entries; limit <= 0 mirrors Python's lines[-limit:] (all lines)."""
|
||||
ptr = _load().ep_get_entries(
|
||||
ledger_path.encode("utf-8"),
|
||||
int(limit),
|
||||
)
|
||||
if not ptr:
|
||||
raise RuntimeError("ep_get_entries returned null")
|
||||
return json.loads(_take_string(ptr))
|
||||
|
||||
|
||||
def default_ledger_path() -> str:
|
||||
"""Deployed-vintage default ledger path (Rust-side resolution)."""
|
||||
ptr = _load().ep_default_ledger_path()
|
||||
if not ptr:
|
||||
raise RuntimeError("ep_default_ledger_path returned null")
|
||||
return _take_string(ptr)
|
||||
|
||||
|
||||
def verify(result_json: str, verifier_type: str) -> str:
|
||||
"""Port 2 (verification_layer): verify a result with the named verifier.
|
||||
|
||||
Returns the VerificationResult to_dict() JSON string (byte-parity with the
|
||||
Python vintage). result_json must parse to a JSON object.
|
||||
"""
|
||||
ptr = _load().ep_verify(
|
||||
result_json.encode("utf-8"),
|
||||
verifier_type.encode("utf-8"),
|
||||
)
|
||||
if not ptr:
|
||||
raise RuntimeError("ep_verify returned null")
|
||||
return _take_string(ptr)
|
||||
|
||||
|
||||
def validate_probe(artifact_json: str) -> list:
|
||||
"""Port 3 (evidence_probe): validate a probe artifact against the 5-key
|
||||
schema; returns the error list (empty = pass)."""
|
||||
ptr = _load().ep_validate_probe(artifact_json.encode("utf-8"))
|
||||
if not ptr:
|
||||
raise RuntimeError("ep_validate_probe returned null")
|
||||
return json.loads(_take_string(ptr))
|
||||
|
||||
|
||||
def count_probes(dir: str) -> int:
|
||||
"""Port 3 (evidence_probe): non-empty-line count of <dir>/probe.jsonl."""
|
||||
return int(_load().ep_count_probes(dir.encode("utf-8")))
|
||||
|
||||
|
||||
# ── port 4 (behaviour_ledger) wrappers ───────────────────────────────────────
|
||||
# Convention: ops take the full ledger doc JSON and return the (doc, rec/recs)
|
||||
# envelope; err != None → ValueError with the exact Python message.
|
||||
|
||||
|
||||
def _bl_call(ptr) -> dict:
|
||||
if not ptr:
|
||||
raise RuntimeError("behaviour_ledger FFI returned null")
|
||||
env = json.loads(_take_string(ptr))
|
||||
if env.get("err") is not None:
|
||||
raise ValueError(env["err"])
|
||||
return env["ok"]
|
||||
|
||||
|
||||
def bl_define(doc: dict, *, behaviour_id: str, owning_project: str,
|
||||
intended_behaviour: str, origin_intent: str = "",
|
||||
pass_criteria: str = "", fail_criteria: str = "",
|
||||
procedure: str = "", owner_component: str = "",
|
||||
dependencies: list | None = None, automated_ref: str = "",
|
||||
environment: str = "", now: str) -> tuple[dict, dict]:
|
||||
"""define(): returns (new_doc, new_record)."""
|
||||
env = _bl_call(_load().ep_bl_define(
|
||||
json.dumps(doc).encode("utf-8"),
|
||||
behaviour_id.encode("utf-8"),
|
||||
owning_project.encode("utf-8"),
|
||||
intended_behaviour.encode("utf-8"),
|
||||
origin_intent.encode("utf-8"),
|
||||
pass_criteria.encode("utf-8"),
|
||||
fail_criteria.encode("utf-8"),
|
||||
procedure.encode("utf-8"),
|
||||
owner_component.encode("utf-8"),
|
||||
json.dumps(list(dependencies or [])).encode("utf-8"),
|
||||
automated_ref.encode("utf-8"),
|
||||
environment.encode("utf-8"),
|
||||
now.encode("utf-8"),
|
||||
))
|
||||
return env["doc"], env["rec"]
|
||||
|
||||
|
||||
def bl_record_auto(doc: dict, behaviour_id: str, *, passed: bool,
|
||||
detail: str = "", generation: str = "", now: str) -> tuple[dict, dict]:
|
||||
"""record_auto_verification(): returns (new_doc, record)."""
|
||||
env = _bl_call(_load().ep_bl_record_auto(
|
||||
json.dumps(doc).encode("utf-8"),
|
||||
behaviour_id.encode("utf-8"),
|
||||
1 if passed else 0,
|
||||
detail.encode("utf-8"),
|
||||
generation.encode("utf-8"),
|
||||
now.encode("utf-8"),
|
||||
))
|
||||
return env["doc"], env["rec"]
|
||||
|
||||
|
||||
def bl_record_real_world(doc: dict, behaviour_id: str, *, passed: bool,
|
||||
detail: str = "", generation: str = "",
|
||||
confirmed_by: str = "user", now: str) -> tuple[dict, dict]:
|
||||
"""record_real_world(): returns (new_doc, record)."""
|
||||
env = _bl_call(_load().ep_bl_record_real_world(
|
||||
json.dumps(doc).encode("utf-8"),
|
||||
behaviour_id.encode("utf-8"),
|
||||
1 if passed else 0,
|
||||
detail.encode("utf-8"),
|
||||
generation.encode("utf-8"),
|
||||
confirmed_by.encode("utf-8"),
|
||||
now.encode("utf-8"),
|
||||
))
|
||||
return env["doc"], env["rec"]
|
||||
|
||||
|
||||
def bl_accept_limitation(doc: dict, behaviour_id: str, *, note: str,
|
||||
approved_by: str = "user", now: str) -> tuple[dict, dict]:
|
||||
"""accept_limitation(): returns (new_doc, record)."""
|
||||
env = _bl_call(_load().ep_bl_accept_limitation(
|
||||
json.dumps(doc).encode("utf-8"),
|
||||
behaviour_id.encode("utf-8"),
|
||||
note.encode("utf-8"),
|
||||
approved_by.encode("utf-8"),
|
||||
now.encode("utf-8"),
|
||||
))
|
||||
return env["doc"], env["rec"]
|
||||
|
||||
|
||||
def bl_invalidate(doc: dict, *, changed_entities: list, generation: str = "",
|
||||
reasons: dict | None = None, now: str) -> tuple[dict, list]:
|
||||
"""invalidate_for_change(): returns (new_doc, invalidated records)."""
|
||||
env = _bl_call(_load().ep_bl_invalidate(
|
||||
json.dumps(doc).encode("utf-8"),
|
||||
json.dumps(list(changed_entities)).encode("utf-8"),
|
||||
generation.encode("utf-8"),
|
||||
json.dumps(reasons or {}).encode("utf-8"),
|
||||
now.encode("utf-8"),
|
||||
))
|
||||
return env["doc"], env["recs"]
|
||||
|
||||
|
||||
def bl_get(doc: dict, behaviour_id: str) -> dict | None:
|
||||
"""get(): record or None (normalized id)."""
|
||||
raw = _take_string(_load().ep_bl_get(
|
||||
json.dumps(doc).encode("utf-8"), behaviour_id.encode("utf-8")))
|
||||
v = json.loads(raw)
|
||||
return v if v is not None else None
|
||||
|
||||
|
||||
def bl_retest_history(doc: dict, behaviour_id: str) -> list:
|
||||
"""retest_history(): events oldest-first (empty for unknown)."""
|
||||
raw = _take_string(_load().ep_bl_retest_history(
|
||||
json.dumps(doc).encode("utf-8"), behaviour_id.encode("utf-8")))
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def bl_outstanding(doc: dict, *, component: str = "", project: str = "") -> list:
|
||||
"""outstanding(): filtered queue, TEST_FAILED first."""
|
||||
raw = _take_string(_load().ep_bl_outstanding(
|
||||
json.dumps(doc).encode("utf-8"),
|
||||
component.encode("utf-8"), project.encode("utf-8")))
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def bl_summary(doc: dict) -> dict:
|
||||
"""summary(): {total, states, outstanding}."""
|
||||
raw = _take_string(_load().ep_bl_summary(json.dumps(doc).encode("utf-8")))
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
# ── port 5 (behavior decision math) wrappers ─────────────────────────────────
|
||||
|
||||
|
||||
def habit_reinforce(row: dict, outcome: str) -> dict:
|
||||
"""Habit reinforce decision math: {activation_count, success_count,
|
||||
failure_count, confidence, state} at the NEW counts."""
|
||||
ptr = _load().ep_habit_reinforce(
|
||||
json.dumps(row).encode("utf-8"), outcome.encode("utf-8"))
|
||||
if not ptr:
|
||||
raise RuntimeError("ep_habit_reinforce returned null")
|
||||
return json.loads(_take_string(ptr))
|
||||
|
||||
|
||||
def correction_reinforce(row: dict, outcome: str) -> dict:
|
||||
"""Correction reinforce decision math: {confidence, evidence_count, state}."""
|
||||
ptr = _load().ep_correction_reinforce(
|
||||
json.dumps(row).encode("utf-8"), outcome.encode("utf-8"))
|
||||
if not ptr:
|
||||
raise RuntimeError("ep_correction_reinforce returned null")
|
||||
return json.loads(_take_string(ptr))
|
||||
|
||||
|
||||
# ── port 6 (claim_audit decision skeleton) wrappers ──────────────────────────
|
||||
|
||||
|
||||
def ca_verify_gate(is_comparative: bool, is_quantified: bool,
|
||||
ledger_hits: int, saw_exceed_word: bool) -> dict:
|
||||
"""verify() verdict tree: {verdict, evidence_hits}."""
|
||||
ptr = _load().ep_ca_verify_gate(
|
||||
1 if is_comparative else 0, 1 if is_quantified else 0,
|
||||
int(ledger_hits), 1 if saw_exceed_word else 0)
|
||||
if not ptr:
|
||||
raise RuntimeError("ep_ca_verify_gate returned null")
|
||||
return json.loads(_take_string(ptr))
|
||||
|
||||
|
||||
def ca_audit_clean(results: list) -> bool:
|
||||
"""The all-PASS fold over audit results."""
|
||||
return bool(_load().ep_ca_audit_clean(json.dumps(list(results)).encode("utf-8")))
|
||||
|
||||
|
||||
def ca_report_aggregate(rows: list) -> dict:
|
||||
"""{total, by_verdict, by_type} over journal rows."""
|
||||
ptr = _load().ep_ca_report_aggregate(json.dumps(list(rows)).encode("utf-8"))
|
||||
if not ptr:
|
||||
raise RuntimeError("ep_ca_report_aggregate returned null")
|
||||
return json.loads(_take_string(ptr))
|
||||
|
||||
|
||||
def ca_evidence_terms(terms: list) -> list:
|
||||
"""Stoplist + length filter over candidate ledger terms."""
|
||||
ptr = _load().ep_ca_evidence_terms(json.dumps(list(terms)).encode("utf-8"))
|
||||
if not ptr:
|
||||
raise RuntimeError("ep_ca_evidence_terms returned null")
|
||||
return json.loads(_take_string(ptr))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Smoke probe: ISOLATED temp ledger (isolation law — never the live chain).
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
ledger = os.path.join(td, "promotion_ledger.jsonl")
|
||||
key = "smoke-key"
|
||||
e1 = append_entry(ledger, key, "promotion", 1, json.dumps({"reason": "smoke"}))
|
||||
e2 = append_entry(ledger, key, "gate", 2, json.dumps({"verdict": "ok", "critics": {}}))
|
||||
assert e1["prev_hash"] == "genesis" and "hash" in e1
|
||||
assert e2["prev_hash"] == e1["hash"]
|
||||
result = verify_chain(ledger, key)
|
||||
assert result == {"valid": True, "entries": 2, "reason": "chain intact"}, result
|
||||
entries = get_entries(ledger, 50)
|
||||
assert len(entries) == 2 and entries[1]["type"] == "gate"
|
||||
h = compute_hash("genesis", json.dumps({"a": 1}), key)
|
||||
assert len(h) == 64
|
||||
print(f"append OK (chain head {e2['hash'][:16]}...)")
|
||||
print(f"verify OK ({result['entries']} entries)")
|
||||
print(f"get_entries OK, compute_hash OK")
|
||||
print("FFI smoke OK")
|
||||
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user