791 lines
56 KiB
Markdown
791 lines
56 KiB
Markdown
# Eureka substrate: map
|
|
|
|
Status: map, Imagination (`imagination-substrate`), 2026-10-01. Campaign
|
|
`eureka-substrate` in Eureka's mind. Questions, cut specs and rulings are
|
|
typed documents there; this page keeps body facts, the model page and
|
|
rationale only. Target: `docs/eureka-substrate-target.md` (`133324a7`).
|
|
|
|
Revised 2026-10-09 (Modeling). The leaf is now Huginn `crates/eureka-pipeline`
|
|
(`afc35f9`, leaf-into-huginn): no Epiphany repo owns or pins it, and
|
|
`leaf-key-bound` is in it (see `docs/eureka-body-map.md` F16). The pinned heads,
|
|
the Epiphany rows and the `lib.rs` line numbers below are as read on 2026-10-01
|
|
and describe the leaf at `epiphany-pipeline/src/lib.rs`.
|
|
|
|
Pinned heads for every fact below:
|
|
|
|
| Repo | Ref | Commit |
|
|
| --- | --- | --- |
|
|
| Huginn | `origin/main` | `133324a7f78047164f0ca02331398066a971ac15` |
|
|
| Huginn | `origin/eureka/memory-organ` (deployed release) | `9dd6d8007ab7de84c926a34edd3947ba15d1f01c` |
|
|
| Epiphany | `origin/main` | `4d1113ef0a4dbcead2e2c3cdc43aa13ef773dbb8` |
|
|
| Epiphany | `origin/codex/eureka-pipeline-state` | `4006b14bf29b49410bd18b43acb4d42c3c4e2443` |
|
|
| Epiphany | leaf pinned by Huginn (`crates/huginn-mind/Cargo.toml:22`) | `73edee5cdaddece8bda9c6c3271e8ddd064337f9` |
|
|
| CultLib | `cultcache-rs` pinned by both | `8fc74c709444b884a2bd339e0c306bf9484d99d2` |
|
|
| gamecult-ops | `main` | `b4eea88d801ad45aad2a49574a9eb2f750c7dd97` |
|
|
| Eureka skill | `main` | `094083566e19cccca026de8cbfc24462f1debe93` |
|
|
|
|
Source reads were made in scratch clones at the pinned commits
|
|
(`scratchpad/imagination-substrate/{epiphany,huginn}`); line numbers are
|
|
against those commits. `lib.rs` below is `epiphany-pipeline/src/lib.rs`.
|
|
|
|
## Body facts
|
|
|
|
### B1. Key grammar (source read, leaf at `73edee5c`)
|
|
|
|
- `LOCAL_MAX = 64` (`lib.rs:877`) bounds every composed local, whole
|
|
(`local`, `lib.rs:892-901`). It is the only bound on a resolution chain's
|
|
depth (comment `lib.rs:917-922`, test `lib.rs:2105-2150`).
|
|
- A resolution's local is `<subject kind>.<subject local>.n<seq>`
|
|
(`lib.rs:923-929`). Overhead over the subject's local: `finding` 11 bytes,
|
|
`follow_up` 13, `question` 12, `ruling` 10, `cut_spec` 12, `stewardship` 15
|
|
at one sequence digit. A withdrawal (resolution of a resolution) adds
|
|
`resolution.` and `.n<seq>`, 14 more.
|
|
- So with subjects bounded at 64 and resolutions at 64, a finding local over
|
|
53, a follow-up label over 51, a question label over 52, a ruling label over
|
|
54 or a cut-spec local over 52 admits and can never be resolved. Self's map
|
|
said follow-up labels over 52; it is 51 (`follow_up` is 9 bytes, plus two
|
|
dots and `n1`).
|
|
- `pipeline_id` (`lib.rs:798-819`) parses every id's local with `dotted_text`
|
|
(`lib.rs:148-158`), whose 64-byte bound is the same one `Slug` uses
|
|
(`lib.rs:251`). Every `PipelineRef` validation goes through it
|
|
(`validate`, `lib.rs:589`; `validate_ref`, `lib.rs:609`), and so do
|
|
Huginn's view (`query.rs:433`), index (`index.rs:120`) and selection keys and
|
|
cites (`rows.rs:306`, `rows.rs:331`). Raising `LOCAL_MAX` alone therefore
|
|
makes a key that no id parser accepts. The parser bound has to move with it.
|
|
- The epoch is `epiphany.pipeline.epoch.v2` (`lib.rs:886`). `value_types!`
|
|
(`lib.rs:400-419`) emits no serde default on any field, so adding a field
|
|
would break the decode of stored documents. No cut in this map added a
|
|
field. `leaf-read-anchors` r2 (admitted 2026-10-09, not yet landed) does add
|
|
fields, through the leaf's `= absent` marker, which defaults on read and omits
|
|
on write when empty, so stored documents still decode and existing golden
|
|
lines do not move.
|
|
- The published schemas (`schemas/cultnet/epiphany.pipeline.*.v2.schema.json`)
|
|
carry no key bound. A resolution's `subject.id` is `Short`, 200 bytes. A key
|
|
is not a field, so no published schema changes when a local bound moves.
|
|
- Storage: `cultcache-rs` stores rows in redb as
|
|
`TableDefinition<&[u8], &[u8]>` (`CultLib packages/cultcache-rs/src/lib.rs:1023`
|
|
at `8fc74c70`), with no key bound of its own. A Qdrant point id is the
|
|
SHA-256 of the document id (`huginn-daemon/src/index/mod.rs:225-231`), so
|
|
key length does not reach the index.
|
|
- Id length ceiling: `<root ≤64>:<kind ≤11>:<local>` must fit `Short` (200)
|
|
wherever an id is cited. That allows a local of up to 123 bytes.
|
|
|
|
### B2. Census of the live mind (probe)
|
|
|
|
- Command: `query` for every question, ruling, cut_spec, target, stewardship
|
|
and follow_up (119 ids), then every finding and resolution (138 ids), header
|
|
projection. The ids were extracted with Python, and the lengths of each
|
|
depth-one resolution local (`n1`) and depth-two withdrawal local were
|
|
computed.
|
|
- Where and when: the eureka-state tools from Starfire, 2026-10-01 about
|
|
08:40 UTC, `as_of` 82, mind status 293 documents, 82 receipts.
|
|
- Result (256 resolvable-kind documents): the longest stored local is 64 and
|
|
the longest id is 90.
|
|
- **Six subjects can have no resolution at all** (n1 local over 64). All
|
|
are findings in force under `idunn-watchdog`:
|
|
`cut-bifrost-retire-alarm.s2.verb-default-accepts-malformed` (69),
|
|
`cut-ops-notice-deploy.s2.pinned-bifrost-predates-retry` (65),
|
|
`cut-bifrost-notice-retry.s1.unknown-test-ignores-backoff` (67),
|
|
`cut-bifrost-notice-retry.s2.closed-unknown-retry-unpinned` (68),
|
|
`cut-bifrost-notice-retry.s2.flapping-clock-retries-every-tick` (72), and
|
|
`cut-idunn-topology-lock.s2.boot-reconcile-skipped-on-contention` (74).
|
|
That matches Self's count. The seventh finding in force,
|
|
`cut-idunn-incident.s3.mutants-share-target-dir`, can be resolved (57),
|
|
but its resolution could never be withdrawn (71).
|
|
- **39 stored resolutions can never be withdrawn** (their withdrawal local
|
|
is 65-78). So their subjects can never be reopened, which is a lifecycle
|
|
the matrix allows (`admission.rs:555-560`, Q17 B). Two follow-ups
|
|
(`gap-substrate-followups-campaign-rooted` under both roots, and
|
|
`projection-mode-branch-vs-private-lock`) and about 40 resolved findings
|
|
have the same latent defect one level up.
|
|
- Conclusion: an admission guard alone cannot satisfy
|
|
`every-subject-resolvable` for stored documents, because they cannot be
|
|
re-keyed (`stored-documents-valid`). The resolution's local bound has to
|
|
widen.
|
|
|
|
### B3. Refusal classification (source read, Huginn at `9dd6d80`)
|
|
|
|
- `Mind::admit` (`huginn-mind/src/admission.rs:73-85`) calls
|
|
`document.validate()` (which returns a typed refusal), then
|
|
`document.prepare(cache)`. `prepare` (`lib.rs:714-721`) runs
|
|
`pipeline_key(self)?` into `anyhow`, and admission maps any `prepare` error
|
|
through `unavailable` (`mind.rs:46-48`) to `MindRefusal::Unavailable`. The
|
|
derived writes take the same path (`admission.rs:136`). A ruling whose
|
|
derived `Answered` resolution cannot be keyed is refused `Unavailable` there.
|
|
- The daemon passes the outcome through unchanged
|
|
(`huginn-daemon/src/daemon.rs:143`). So `Refused(Unavailable{..})` reaches
|
|
the client as an admit answer, not as `isError`.
|
|
- The later A3 step (`stage` through `validate_pipeline_write_envelope`,
|
|
`lib.rs:1003-1015`) returns typed refusals. Only the prepare path is
|
|
misclassified.
|
|
- `Unavailable` is also raised legitimately, for store and decode faults
|
|
(`admission.rs:949`, `admission.rs:2091`, `docs.rs:48`, `mind.rs:140`,
|
|
`receipt.rs:209`). Those stay.
|
|
|
|
### B4. Smaller rules (source read, Huginn at `9dd6d80`)
|
|
|
|
- **Sha identity:** the one place shas are compared is
|
|
`admission.rs:433`, `commit.sha == report.range.head`, a literal string
|
|
equality. `Sha` is 7-40 lowercase hex (`lib.rs:253`). `FullSha` (40) exists
|
|
for `ForeignRef` (`lib.rs:255`, `lib.rs:467`).
|
|
- **Deferred:** the matrix (`admission.rs:562-585`) allows
|
|
`(Finding, Deferred { to })` only when `to.kind == FollowUp`
|
|
(`admission.rs:574`). The leaf's `Deferred { to: PipelineRef }` already takes
|
|
any kind, so this is an admission change only.
|
|
- **Faculty:** `Faculty` lives in Huginn, not in the leaf
|
|
(`huginn-mind/src/receipt.rs:32-40`, variant `MindSteward`). The selection
|
|
alias domain is a second copy (`rows.rs:42`). The eureka-state client takes
|
|
the type from huginn-mind (`eureka-state/src/main.rs:27`, `main.rs:66`), so
|
|
the client must be rebuilt for a new variant.
|
|
- Probe: `query` with `faculty any_of [MindSteward]` matched 0 documents
|
|
(2026-10-01 08:40 UTC). No stored receipt carries the old name.
|
|
- **Stewardship:** admission reads stewardship in one live rule. A campaign
|
|
needs a stewardship in force for each repo (`admission.rs:363-366`,
|
|
`RepoNotStewarded`). The hand-off rule (`admission.rs:501`, `NotStewarded`)
|
|
is parked with Cut 12. Six stewardship documents are stored.
|
|
- **depends_on:** this is `Vec<Short>[8]` (`lib.rs:525`). Huginn's edge list
|
|
says outright that `depends_on` is a label, not an edge
|
|
(`huginn-mind/src/docs.rs:398-404`). Nothing checks it. The only reader is
|
|
the header projection (`query.rs:121`, `query.rs:173`). The
|
|
`idunn-watchdog` specs stored revision ids in it. That came from the skill's
|
|
habit, not from the code's intent.
|
|
- `open_checked` (`mind.rs:180-188`) re-validates epoch, types and identity,
|
|
not keys or bounds, so an opened mind never re-judges a stored key. No
|
|
migration path exists, and none is needed under this map.
|
|
|
|
### B5. Branches and in-flight work (git, 2026-10-01 ~08:35 UTC)
|
|
|
|
- Huginn: `memory-organ` is 107 commits ahead of the merge base `91b7fcf`;
|
|
`main` is 1 ahead (`133324a`, docs only). `git merge-tree --write-tree
|
|
origin/main origin/eureka/memory-organ` is clean, tree `993ab1fd`. Its only
|
|
difference from `9dd6d80` is `docs/eureka-substrate-target.md`.
|
|
- Epiphany: the branch is 174 ahead of the merge base `b5d9d4ff`; `main` is 1
|
|
ahead (`4d1113ef`, the Ghostlight pin). Merge-tree is clean, tree
|
|
`a75aa388`, and **identical** to the branch tip's tree: the branch already
|
|
carries the same Ghostlight pin and lock. The leaf tree `cf84c032` is
|
|
identical at `73edee5c`, at the branch tip and in the merge.
|
|
- Hands worktrees `hands/rs3` (`9673ab8`, idle since 2026-09-29 06:32),
|
|
`hands/cut13a` (`3dc50ee`, 2026-09-30 00:16) and `hands/cut13b` (`cd3dfc0`,
|
|
2026-09-30 04:55) are each 0 commits ahead of `memory-organ`, and clean. RS-3
|
|
and BP-3 have landed (BP-3's deferred bodies are in `huginn-daemon/src/bodies.rs`).
|
|
**Nothing is in flight.** The only other unmerged Huginn branch is
|
|
`codex/windows-atomic-replace-retry` (2026-05-24), which is unrelated.
|
|
- No Epiphany branch has unmerged commits under `epiphany-pipeline/`.
|
|
- `F:\Projects\Huginn`'s main worktree is checked out on `eureka/memory-organ`,
|
|
so the merge has to run in its own worktree.
|
|
|
|
### B6. Deployment (read-only ssh, Yggdrasil, 2026-10-01 08:41 UTC)
|
|
|
|
- `readlink /opt/gamecult/huginn/current` is
|
|
`releases/9dd6d8007ab7de84c926a34edd3947ba15d1f01c`. The previous release
|
|
`0c4e4107…` is kept. `huginn` is active since 2026-09-30 13:21:08 UTC.
|
|
`ExecStart` is as in the runbook (`--state-root /var/lib/gamecult/huginn
|
|
--instance eureka …`).
|
|
- `minds/` holds one mind, `eureka`: `mind.redb` 4,198,400 bytes, mtime
|
|
2026-10-01 08:33, owned `huginn:huginn` 0640. This is the only mind on the
|
|
host.
|
|
- The nightly snapshot `/var/backups/gamecult-state/2026-10-01/` holds
|
|
`./var/lib/gamecult/huginn/minds/eureka/mind.redb` (01:59 UTC), recorded
|
|
`stable-double-read` in `consistency.tsv`. It predates receipts 81-82 (this
|
|
campaign's frame), so the deploy cut takes its own cold copy.
|
|
- Runbook: `gamecult-ops runbooks/huginn-yggdrasil.md:42-72`. Its release
|
|
section builds at a GitHub commit and keeps the previous release. It has no
|
|
pre-upgrade copy step and no read-back proof.
|
|
|
|
## Model page
|
|
|
|
One row per persistent kind or rule that this campaign touches. **Identity**:
|
|
what names it. **Lifecycle**: what happens to it over time. **Authority**:
|
|
who decides.
|
|
|
|
| Kind or rule | Identity | Lifecycle | Authority |
|
|
| --- | --- | --- | --- |
|
|
| Subject keys (`finding`, `follow_up`, `question`, `ruling`, `cut_spec`, `target`, `stewardship`) | `<root>:<kind>:<local>`, with the local bounded at 64 bytes; injective (`composed_keys_cannot_collide`, `lib.rs:1392`). **Unchanged by this campaign**: every stored key stays exactly as it is. | Admitted once; never re-keyed. A revision is a new key. | The leaf derives it (`pipeline_key`); admission refuses a mismatch (A3). Forbidden: any writer composing a key by hand. |
|
|
| Resolution keys | `<subject root>:resolution:<subject kind>.<subject local>.n<seq>`, and one nesting for a withdrawal. **Target:** bounded by its own constant, derived from the deepest legal chain over the longest subject local at `u32::MAX` sequences: 10 + 1 + (11 + 1 + 64 + 2 + 10) + 2 + 10 = **111 bytes**. Ids up to 64 + 12 + 111 = 187 bytes, within `Short`. | n1, n2 … per subject; withdrawn by a depth-two resolution; never deeper (Q19 A, admission). Stored resolutions keep their keys and become withdrawable. | The leaf owns the bound and the id parser; admission owns sequence, matrix and depth. The parser bound (`pipeline_id`) is derived from the kind and is no longer `dotted_text`'s 64. |
|
|
| Every-subject-resolvable guard | **Empty by design** under Q `key-bound` option A: with subjects at 64 and resolutions at 111, every admissible subject is resolvable to depth two at any sequence, so no guard exists to own. Under option B a guard would be admission's. | | |
|
|
| Refusal classes | `MindRefusal::Document(PipelineRefusal)` for shape, bound and key; `Unavailable` only for store, cache and decode faults. | Fixed at admission time per batch. | Huginn admission. The leaf's `prepare` keeps the cache-encode error as `anyhow`. Admission keys the document first, so a key error is typed. Forbidden: `unavailable()` wrapping a leaf refusal. |
|
|
| `depends_on` | Free `Short` text today. **Target:** cut labels in the spec's own campaign (Q `depends-on`). | Stored revision-id values stay as they are (they decode, and nothing re-judges them). New batches follow the target rule. | Under A, admission refuses a new spec whose entries are not labels of cuts with a spec in the campaign. Under B, only the skill convention. |
|
|
| Sha identity | `Sha` 7-40 hex. **Target:** two shas name the same commit when one is a prefix of the other. | Only judged in a new report's batch. | A leaf method on `Sha` (the type owns its equality semantics); admission calls it at `admission.rs:433`. |
|
|
| `Deferred` referent | `PipelineRef`, any kind in the leaf. **Target:** follow_up or cut_spec, for a finding. | When the cited spec is later superseded, the reader follows `superseded_by`; nothing is rewritten. | Huginn's matrix (`admission.rs:574`). |
|
|
| Faculty | `receipt.rs` enum plus the `rows.rs:42` alias domain. **Target:** `Life` replaces `MindSteward`, with `#[serde(alias = "MindSteward")]` so any old receipt decodes. 0 stored receipts carry it (B4). | Attribution on the receipt, never re-written. | huginn-mind. The client picks it up only when rebuilt. |
|
|
| Stewardship admission rule | Rule `RepoNotStewarded` on campaign repos. | Q `stewardship-rule`. Stored stewardship documents stay; under A they decide nothing until Cut 12 (hand-off and leasing) defines a rule again. | Huginn admission; the operator owns whether the rule exists. |
|
|
| The gap home | Root `eureka-substrate`, labels `gap-*` (ruling `gap-home`). | Already moved (19 follow-ups in force here; the 20 `idunn-watchdog` copies resolved). Closed by `Fixed` when a cut lands, by Self after Soul holds. | Self admits; the skill recipe names the root (`campaign-state.md:278-299`, already done at Eureka `0940835`). No code. |
|
|
| The six stuck findings | Unchanged keys under `idunn-watchdog`. | They become resolvable when the leaf cut deploys. Self triages them then, like any finding (Deferred or Recorded). This is Self triage, not a fork. | Self. |
|
|
| Deployed release | `/opt/gamecult/huginn/releases/<full sha>`, `current` symlink. | Built at a main commit, flipped in, the previous release kept, flipped back on failure. | Operator's deployment authority, exercised by Self or Hands (memory `deploy-instruction-is-authorization`). |
|
|
|
|
Cells that could not be filled are stated in the rows above: the guard row
|
|
is empty by design if `key-bound` A is ruled, and the `depends_on` and
|
|
stewardship authorities wait on their questions.
|
|
|
|
## Rationale
|
|
|
|
**Why a per-kind bound and not a bigger `LOCAL_MAX`.** If one number bounds
|
|
every local, then widening it widens the subjects too, and the gap comes back
|
|
one level up: a 100-byte finding label has a 111-byte resolution. Keeping
|
|
subject locals at 64 and deriving the resolution bound from the deepest legal
|
|
chain makes resolvability a property of the grammar, with no admission guard
|
|
to drift. It also re-keys nothing: every stored key was at most 64 and stays
|
|
valid. A guard alone (keeping 64 everywhere) would refuse future offenders.
|
|
It would leave the six stuck findings and the 39 unwithdrawable resolutions
|
|
permanently broken, which violates the target for stored documents.
|
|
|
|
**Why the parser bound moves with it.** Every reader validates ids through
|
|
`pipeline_id`. A resolution key the leaf can derive but `view`, `keys` or
|
|
`cites` refuses would turn an unresolvable subject into an unreadable
|
|
resolution.
|
|
|
|
**Cut order.** The merges come first (ruling `merge-to-main-first`). Then the
|
|
leaf, because Huginn pins it. Then Huginn admission, which bumps the pin. Then
|
|
the client rebuild, because the new `Life` variant crosses the wire in
|
|
receipts. Then the deploy, which proves the read-back. The skill cut goes
|
|
last, because it must not tell agents to admit as `Life` before the client
|
|
and daemon understand it. In cut labels: `merge-epiphany-main`, then
|
|
`merge-huginn-main`, then `leaf-key-bound`, then `huginn-pin-and-refusals`,
|
|
then `huginn-admission-rules`, then `ops-mind-upgrade`, then `skill-recipes`.
|
|
The client rebuild is a step inside `ops-mind-upgrade`. It produces no repo
|
|
commit, and a `cut_report` needs one (`gap-fork-before-commit`). The new
|
|
daemon's added refusal variants and `Life` must not reach an old client for
|
|
long, so the client swap sits next to the daemon flip.
|
|
|
|
**Why Cut 0 does not bump the leaf pin.** After the Epiphany merge, `73edee5c`
|
|
is an ancestor of Epiphany `main`, and the leaf tree is byte-identical. So the
|
|
daemon built from Huginn `main` is built from main code
|
|
(`code-from-main`), with an unchanged `Cargo.lock`, and the deployed release
|
|
stays as it is. The pin later moved to Epiphany `ef956865` (F1 of the body map);
|
|
`leaf-into-huginn` then removed it, since the leaf lives in Huginn.
|
|
|
|
**Proof of `stored-documents-valid`.** Each code cut runs one read-back test
|
|
over a real copy of the mind. Every envelope decodes; `pipeline_key` of the
|
|
decoded document equals the stored key; `validate()` passes; and for every
|
|
resolvable subject the n1 resolution key and its withdrawal key derive and
|
|
parse back. The test reads the path from `HUGINN_MIND_SNAPSHOT` and is
|
|
`#[ignore]` by default, so CI never needs the file. Before code cuts, the
|
|
copy is the 2026-10-01 nightly snapshot. The deploy takes a cold copy with the
|
|
unit stopped.
|
|
|
|
## huginn CLI replaces the eureka-state MCP server (Imagination, 2026-10-10)
|
|
|
|
Follow-up `eureka-substrate:follow_up:huginn-cli-replaces-mcp`. Bases: Huginn
|
|
180b08a, Eureka 278b881, gamecult-ops 1e95674. Live daemon 107552dc.
|
|
|
|
### Body facts (probes, 2026-10-10, Starfire)
|
|
|
|
- `~/.claude.json` top-level `mcpServers.eureka-state`: stdio,
|
|
`C:\Users\Meta\.eureka\bin\eureka-state.exe`, env `EUREKA_INSTANCE=eureka`,
|
|
`HUGINN_ENDPOINT=rudp://10.77.0.1:17872`. No `mcp__eureka-state` entries in
|
|
`~/.claude/settings.json` permissions. That file has an `env` block holding
|
|
one key (`CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL`).
|
|
- `~/.cargo/bin` and `~/.local/bin` are on the Bash tool's PATH.
|
|
`~/.eureka/bin` is not.
|
|
- `py -3` is 3.13.15 on Starfire and `python3` is 3.13.5 on Yggdrasil.
|
|
- Yggdrasil has no eureka-state or huginn client binary installed. The daemon is
|
|
`/opt/gamecult/huginn/current/huginn-daemon`.
|
|
- Huginn: `crates/eureka-state` is `lib.rs` (HuginnClient, 210 lines, transport
|
|
only) plus `main.rs` (the MCP server, 349 lines) and tests (`client.rs` 511,
|
|
`mcp.rs` 564, `common` 185). At main, nothing else in the workspace depends on
|
|
the crate. `eureka-body/mind-launch` (3b15c39) adds
|
|
`eureka-state = { path = "../eureka-state" }` to mind-body and calls
|
|
`HuginnClient` in-process.
|
|
- Eureka: `tools/context-pack.py:115-201` hand-rolls an MCP stdio client that
|
|
reads the binding from `~/.claude.json`. `tools/test-tools.sh:346-381,509-513`
|
|
test it with `fixtures/context-pack/fake-eureka-state.py`. `agents/life.md:4`
|
|
is the only charter whose tool list names `mcp__eureka-state__*`.
|
|
- Wire: `huginn-mind` encodes requests and answers with
|
|
`rmp_serde::to_vec_named` (named maps), so an added field is skipped by an old
|
|
reader. The leaf has no `deny_unknown_fields`.
|
|
`107552dc..main` adds the read anchors (an additive field), `RulingAuthority::Mind`
|
|
(an added variant) and the run kind.
|
|
|
|
### Model page: the CLI (step 0b)
|
|
|
|
| Question | Answer | Why |
|
|
|---|---|---|
|
|
| Binary | `huginn` | It is the mind's door, named for the organ. No name clash: the daemon's binary is `huginn-daemon` and the operator CLI is `mind-control`. |
|
|
| Crate | `crates/eureka-state`, bin `src/bin/huginn.rs`. The crate is not renamed. | mind-launch already depends on the package by name. A rename is pure churn across two campaigns and protects no invariant. |
|
|
| Library | `HuginnClient` (`lib.rs`) stays the one client and stays transport-only. | The CLI and mind-launch are its two consumers. Env parsing lives in the CLI, not the lib, because mind-launch builds clients from its own config. |
|
|
| Install, Starfire | `C:\Users\Meta\.local\bin\huginn.exe` (already on PATH), with `.next` and `.prev` beside it per the runbook. Built at the merged sha with `HUGINN_BUILD_SHA` set. | No PATH or system edit is needed. |
|
|
| Install, Yggdrasil | None until the first Claude session runs there (the Self-run unit). Then it goes in the release dir beside the daemon, linked from `current`, so it flips with the daemon. | Nothing on the host calls the mind through a shell yet. mind-launch links the lib. Follow-up `huginn-cli-on-yggdrasil`. |
|
|
| Finding the mind | `EUREKA_INSTANCE` and `HUGINN_ENDPOINT` from the environment, exactly as the MCP server read them. On Starfire they are set in the `env` block of `~/.claude/settings.json`, which is operator-visible. On Yggdrasil they will go in the unit's `Environment=`. No `--instance` flag and no config file. | This keeps the MCP rule that the instance is the session's configuration, not a call argument. Env is how a systemd unit and a Claude session both configure a child. |
|
|
| Version | `huginn --version` prints the crate version and the build sha. `whoami` prints `client <sha>`. | Lets an operator see skew. It is display-only. |
|
|
| Lifecycle | Each call is a new process. Swapping the exe takes effect on the next call, with no session restart. | This removes runbook step 8's restart-every-session. |
|
|
| Skew rule | After every Committed or AlreadyAdmitted batch, the CLI reads the batch back with one `keys` query (projection document) and compares each stored document with what it sent. A difference exits 5 and names the ids and field paths. No version negotiation is added. | This is the end-to-end argument (Saltzer, Reed and Clark 1984): only the end check covers every intermediate drop, whether it came from an old leaf at the CultNet door, a serde default or a future skew. A whoami version field would need a daemon deploy (blocked on the upgrade rerun), would cover only the skew it was taught, and would leave 107552dc unprotected. Cost: one extra call per admit. Limit: the document is already committed, so the agent supersedes it. That is loud, not silent. |
|
|
| Authority | The CLI owns no state. It parses arguments, strictly decodes agent-written input into leaf types, names the recipe selections, renders text and derives the exit code. Admission and status stay Huginn's, and the transport stays HuginnClient's. | This is Brokkr's brokkr-command shape (A3, b451cdd): a caller whose exit code is derived from the answer and never from "the send succeeded". |
|
|
| Input door | Agent-written JSON (question `huginn-cli-document-format`), decoded strictly. A key path that is present with a non-empty value and absent from the typed re-serialisation is refused as exit 2, naming paths, never values. | Closes `gap-admit-drops-unknown-keys` at the door. The store decode stays lenient for rollback (map B15). |
|
|
| Recipes | Named subcommands in the CLI, each the exact selection campaign-state.md defines at Eureka 278b881. After cut skill-huginn-cli, the page names them and `--help` prints each selection. | One copy of the progress view, in code an agent runs, not JSON an agent retypes. |
|
|
| Exit codes | 0 answered. 1 the mind said no (Refused, Conflict, view absent). 2 the call is wrong (usage, InvalidInput, TooLarge, Misconfigured). 3 Unavailable (retry once). 4 no answer and a retry cannot help (Rejected, Unencodable, Internal). 5 committed but the read-back differs. | These map one to one onto the skill's organ rule. |
|
|
|
|
### Cut order
|
|
|
|
huginn-cli (after clippy-pin) -> skill-huginn-cli, context-pack-huginn-cli,
|
|
ops-huginn-cli (in parallel) -> delete-eureka-state-mcp.
|
|
|
|
The deletion comes last, and it is the only cut that removes a door. Sessions
|
|
in flight hold MCP server processes, and both doors reach one daemon, so the
|
|
order lets every consumer move before the old door goes.
|
|
|
|
Life's no-admit rule loses its tool-list guard, because Life keeps Bash. Its
|
|
`mcp__eureka-state__*` read-only tool list was already soft: Bash could drive
|
|
the stdio server, as context-pack.py does. That fork is question
|
|
`life-mind-write-guard`.
|
|
|
|
Rejected: renaming the crate to `huginn-client`, for the churn above. A whoami
|
|
version field, for the reasons in the skew rule row. A config file for the
|
|
endpoint, because env already serves both hosts. TOML or YAML as the input
|
|
door: see the question.
|
|
|
|
## A campaign's repo set changes by target revision (Imagination, 2026-10-10)
|
|
|
|
Felt gap: `eureka-body:follow_up:body-campaign-repos`. eureka-body must change
|
|
GameCult/Idunn and the connector kit repo (ruling
|
|
`eureka-body:ruling:connector-kit-home`, not yet created), but `repos` lives on
|
|
`<c>:campaign:self`, a fixed key with no revision and no resolution, and
|
|
admission refuses a cut_spec whose repo is outside it (`admission.rs:405-415`,
|
|
`RepoNotInCampaign`). The only workarounds were a sibling campaign (done once:
|
|
`eureka-substrate:follow_up:epiphany-cuts-need-a-campaign`, closed by opening
|
|
eureka-body) or a cross-campaign `first` line. Pinned at Huginn a7cc2ff.
|
|
|
|
### Model page: the repo set (step 0b)
|
|
|
|
| Question | Answer | Why |
|
|
|---|---|---|
|
|
| What names it | `target.repos: Vec<OrgRepo>[16] = absent` on `<c>:target:r<n>`. Empty means "no target in this chain has named repos": the campaign's own `repos` stands. Non-empty means this revision's list is the whole set. | Reuses the one revisable campaign-scoped record. The target already carries scope (`not_in_scope`, `canonical_implementations`); which repos a campaign may cut is scope. No new kind, key grammar, alias or refusal variant. |
|
|
| Who owns it | The in-force target when it names repos; otherwise the campaign document. `campaign.repos` is demoted to the opening set: it decides only until the first target revision that names repos. | One owner at any moment, derived in one function (`Docs::repos_in_force`), read by the one rule that consumes it. |
|
|
| Add or remove | Self admits target `r<n+1>` with the new whole list and the `Superseded` resolution of `r<n>`, in one batch (the existing revision rule). The target doc cited by `doc` says why. | Whole-list replacement, as Kubernetes replaces a whole spec per generation; no per-member add/remove events to fold. |
|
|
| Silent revert | A target revision with empty `repos` whose predecessor named repos is refused `EmptyRepos { campaign: <target id> }`. | Otherwise omitting the field on an unrelated invariant edit would revert to the opening set. Reuses the existing variant: an empty repo set where one is required. |
|
|
| Bound | 16 on the target; the campaign's 8 is unchanged. | eureka-body reaches 8 with the two repos it needs. 16 matches `canonical_implementations`. The campaign bound need not widen, since later sets live on targets. |
|
|
| Specs on a removed repo | Membership is checked when a cut_spec (new or a revision) is admitted, against the set in force then. A spec already in force stays in force; its reports admit (they check the spec, not the campaign); it can be Withdrawn or Superseded as before. A new revision on the removed repo is refused `RepoNotInCampaign`. | Admission rules apply to new batches only (target invariant `stored-documents-valid`). Refusing the removal while specs name the repo would need a landed/open distinction the mind does not have (`gap-landing-untyped`), and would force withdrawing landed specs, which misstates them. Question `campaign-repo-removal`. |
|
|
| Rename or transfer | A new `Org/Repo` spelling is a new repo: Self revises the target replacing the name. Old specs keep the old name as history; a spec revision may name the new repo (the revision rule does not compare `repo`). Case changes are no-ops (`OrgRepo` equality is by identity). The host (forge or GitHub, `body-repos-on-forge`) is not in `OrgRepo` and stays out of scope. | |
|
|
| Who decides | Self admits targets. Admission checks the revision rule, distinctness by identity (the campaign's hook, shared), the bound, and the revert rule. It checks no stewardship (ruling `stewardship-rule`). | |
|
|
| Epoch | Stays `epiphany.pipeline.epoch.v2`. The field is `= absent`: stored targets decode with it empty and re-encode byte for byte; one golden line is appended. | Precedent: cut leaf-read-anchors (`reads`, `symbol`). An old daemon reading a target that names repos drops them (serde ignores unknown keys), so after a repo revision is admitted a rollback narrows the set to the opening one; it does not fail to read. This lands with the upgrade rerun waiting on `snapshot-test-stale-and-quadratic`, daemon-first (`huginn-upgrade-daemon-first`). |
|
|
|
|
### Authority map
|
|
|
|
- Owner: Huginn admission (`check`, CutSpec arm) through `Docs::repos_in_force`.
|
|
- Inputs: the campaign document; the campaign's targets and their resolutions (image and batch).
|
|
- Outputs: `RepoNotInCampaign` or acceptance for a cut_spec; `EmptyRepos` for a reverting target.
|
|
- Derived state: `campaign.repos` after a repo-naming target is display-only history (the campaign header's `repos` fact and its `repo` alias answer "opened with"). The skill's recipe reads the set in force from the target.
|
|
- Forbidden writers: any second membership check (a `campaign.repos.contains` outside `repos_in_force`); editing or re-admitting `campaign:self`.
|
|
- Shared paths: MCP admit, the CultNet wire and the stored payload decode `repos` through the same derived impls.
|
|
- Deletion line: the direct `campaign.repos.contains(&spec.repo)` at `admission.rs:413`.
|
|
|
|
### Rejected
|
|
|
|
- A per-repo record kind (`<c>:campaign_repo:<repo>.n<seq>`, Withdrawn to remove), the stewardship pattern: a new kind, schema, key grammar, alias and refusals, and its key would inherit the opaque repo encoding of `gap-stewardship-id-opaque`.
|
|
- A campaign revision (`<c>:campaign:r<n>`): re-keys every stored campaign or adds a second key grammar for one kind, and every `source` citing `<c>:campaign:self` would have to follow it.
|
|
- Status quo (sibling campaigns): splits one target's work across two targets and two invariant sets.
|
|
|
|
### Cut order
|
|
|
|
campaign-repos (Huginn; independent of clippy-pin; textual overlaps with
|
|
mind-launch in admission.rs, docs.rs and the response schema, so whichever
|
|
merges second rebases and regenerates the schema) -> skill-campaign-repos
|
|
(Eureka campaign-state.md). huginn-cli's campaigns recipe, if it prints repos,
|
|
prints the opening set until it reads the target too.
|
|
|
|
## Recall at admission (Imagination, 2026-10-23)
|
|
|
|
Serves `eureka-substrate:ruling:recall-triggers-rescoped` (order of work) and
|
|
`eureka-substrate:ruling:admit-near-duplicate-deny-once` (the design, ruled).
|
|
Spec: `eureka-substrate:cut_spec:cut-recall-admit.r1`, GameCult/Eureka at
|
|
`614caf8` (main). Prior-art facts: Eureka `docs/research/recall-triggers-prior-art.md`
|
|
on branch `eureka-substrate/recall-triggers-research` (PR #28); the claims this
|
|
cut rests on were re-checked below rather than taken from it.
|
|
|
|
### Body facts (probes, 2026-10-23, Starfire)
|
|
|
|
- R-B1. Harness version. `claude --version` on PATH (npm) is 2.1.268; the
|
|
harness running these sessions is the desktop's bundled claude-code 2.1.308
|
|
(`CLAUDE_CODE_EXECPATH`). Hooks in `~/.claude/settings.json` are read by
|
|
2.1.308. Facts about hook behaviour must name 2.1.308, not 2.1.268.
|
|
- R-B2. Headless probe blocked. `claude -p` from a scratch project with a
|
|
throwaway `.claude/settings.json` failed on both binaries: "Failed to
|
|
authenticate: OAuth session expired and could not be refreshed". No live hook
|
|
run was possible; the facts below are source reads of the 2.1.308 bundle
|
|
(strings in `claude.exe`) plus the docs. The live checks moved to the spec's
|
|
`verification.operator`.
|
|
- R-B3. Hook input (source read, 2.1.308, the common-input builder). Fields:
|
|
`session_id` = the session's id (the same value inside subagents: the
|
|
subagent's tool context carries the parent session), `transcript_path`, `cwd`,
|
|
`scratchpad_dir` (present when the scratchpad feature is on; derived from the
|
|
session id, so shared by root and subagents), `prompt_id`, `permission_mode`,
|
|
`agent_id` and `agent_type` (the field's own description: present only when
|
|
the hook fires from within a subagent), `effort`. PreToolUse adds
|
|
`tool_name`, `tool_input`, `tool_use_id`, and `mcp_server` for MCP tools.
|
|
- R-B4. Deny reaches the model (source read, 2.1.308): a PreToolUse
|
|
`permissionDecision: "deny"` becomes the call's block message,
|
|
`permissionDecisionReason ?? "Blocked by hook"`. Docs agree ("For deny, shown
|
|
to Claude"). Not observed live (R-B2).
|
|
- R-B5. Subagents (docs, verbatim in the PR #28 facts; source agrees by the
|
|
`agent_id` description): settings-file tool hooks fire for a subagent's tool
|
|
calls. So one user-settings entry covers Self, Hands, Soul and Imagination
|
|
admits. Not observed live (R-B2).
|
|
- R-B6. Matcher: `mcp__eureka-state__admit` is all letters, digits, `_` and
|
|
`-`, so it is an exact match (docs' matcher rule); `mcp__eureka-state__.*` is
|
|
a regex and would also fire on view and query.
|
|
- R-B7. Digest stability: the hook sees `tool_input` as parsed JSON, so a
|
|
digest over sorted-key canonical JSON is independent of key order and
|
|
whitespace in the model's call. An "identical retry" is identical in value,
|
|
which is what the ruling asks of the agent.
|
|
- R-B8. Latency (live mind, through `tools/mind.py` at `614caf8`): whoami 0.14 s
|
|
(spawn and handshake are cheap); one semantic query 2.17-2.27 s, flat from a
|
|
45-byte to a 1000-byte text; three queries in one binding session 6.49 s (no
|
|
saving: the cost is server-side); three concurrent `mind.call`s 4.62 s wall,
|
|
six 8.26 s (partly serialised, about 1.2 s per extra query). Bound: at most 3
|
|
checked documents, concurrent, deadline 10 s, hook timeout 15 s.
|
|
- R-B9. For the next cut (not specified): whether PreToolUse `updatedInput` on
|
|
the Agent tool reaches the subagent's prompt on 2.1.308 is unproven. The
|
|
bundle validates `updatedInput` generically; no Agent-specific handling was
|
|
found by string search, which proves nothing either way. GitHub #39814
|
|
(ignored on 2.1.85+, closed not planned) stands unrefuted. SubagentStart's
|
|
input carries `agent_id` and `agent_type` and no prompt (source read); its
|
|
`additionalContext` reaching the subagent is documented, not observed.
|
|
- R-B10 (Self, 2026-10-23, live, after the operator re-authenticated the CLI):
|
|
three `claude -p --model haiku` runs on the desktop's claude-code 2.1.308
|
|
(`...\Claude-Data\...\claude-code\2.1.308\...\claude.exe`), from the scratch
|
|
project `scratchpad\imagination-recall-admit\hookprobe` (its project
|
|
`.claude/settings.json`, hook `hook.py` that denies every admit).
|
|
(a) Root admit: the deny reached the model as the tool's error text,
|
|
`PreToolUse:mcp__eureka-state__admit hook error: <permissionDecisionReason>`.
|
|
The prefix says "hook error", so the reason's first words must make clear the
|
|
hold is not a failure. Hook input keys: cwd, effort, hook_event_name,
|
|
mcp_server, permission_mode, prompt_id, scratchpad_dir, session_id,
|
|
tool_input, tool_name, tool_use_id, transcript_path (no agent_id at root).
|
|
(b) Subagent: SubagentStart fired with agent_id and agent_type, and its
|
|
`additionalContext` reached the subagent (it quoted the injected word). The
|
|
subagent's admit fired the same project-settings PreToolUse hook with
|
|
agent_id and agent_type, and the deny reached it.
|
|
(c) Agent `updatedInput`: a PreToolUse hook on `Agent` returning
|
|
`permissionDecision: allow` with `updatedInput` (prompt replaced) changed
|
|
what the subagent received: it answered the injected prompt, not the
|
|
original. GitHub #39814 does not hold on 2.1.308. Caveat: `allow` also
|
|
auto-approves the spawn, so a dispatch hook must decide whether it may
|
|
return allow (see cut 2).
|
|
|
|
### Model page: the held-once record
|
|
|
|
| Question | Answer |
|
|
|---|---|
|
|
| What names it | A digest: sha256 of canonical `tool_input` plus `agent_id` (empty at root). Injective up to hash collision; two agents sending the same batch get different digests, so each is shown the neighbours once. |
|
|
| Where it lives | `<scratchpad_dir>/recall-admit-digests` when `scratchpad_dir` is a directory, else a temp file keyed by a hash of `session_id`. Session-scoped; it dies with the scratchpad. |
|
|
| Lifecycle | Appended when a batch is held; read on the next admit; never edited or replayed. A batch changed after the hold has a new digest and is held again. |
|
|
| Who decides | `tools/recall.py admit` alone. It never allows or asks (that would bypass the operator's permission rules); acceptance stays Huginn admission's. If it cannot record the digest it does not hold the call, because a hold it cannot remember would hold every retry. |
|
|
|
|
### Rationale
|
|
|
|
- Why every batch with a ruling or question is held once: the semantic page
|
|
carries no score (`gap-semantic-page-no-score`), so "near" cannot be
|
|
measured, and nearest-neighbour search always returns k results. The ruled
|
|
design accepts one round trip per such batch and leaves the judgement to the
|
|
agent. Exclusions (own id, batch ids, the answered question, superseded
|
|
subjects) keep the list from naming what the batch already handles.
|
|
- Query text is the document's title: focused one-topic texts retrieve well and
|
|
long multi-topic ones do not (`gap-semantic-one-text-per-query`).
|
|
- Fail open: an admit hook that holds when Huginn is down would hold every
|
|
admission; and the admit itself fails on Unavailable anyway. A failure prints
|
|
one fixed sentence as `additionalContext` (it lands next to the tool result)
|
|
so the agent knows the batch went unchecked; it never echoes an input.
|
|
- The hook is bound to the MCP tool name. An admit path that bypasses it (the
|
|
`huginn` CLI cuts) must carry the check: `eureka-substrate:follow_up:admit-check-follows-admit-path`.
|
|
|
|
### Cut sequence
|
|
|
|
1. recall-admit (Eureka): `cut-recall-admit.r1`.
|
|
2. recall-dispatch (Eureka, or brief text): the in-force records nearest a
|
|
subagent brief reach the subagent. R-B10(c): a PreToolUse `Agent` hook's
|
|
`updatedInput` reaches the subagent's prompt on 2.1.308, and SubagentStart
|
|
context reaches it too (R-B10(b)). Open for the map: whether returning
|
|
`allow` (required for `updatedInput`) is acceptable when it bypasses the
|
|
spawn's permission prompt, or whether `ask`, or a SubagentStart pairing,
|
|
is used instead.
|
|
3. recall-session-start (Eureka): a SessionStart hook recalls against the
|
|
area's handoff and open campaigns.
|
|
4. The prompt hook (`recall.py prompt`, landed, uninstalled) is installed last,
|
|
if at all. The retrieval-quality gaps (`gap-semantic-page-no-score`,
|
|
`gap-semantic-one-text-per-query`, `gap-persona-state-not-indexed`) come
|
|
before cuts 2-4. They are mapped in "Semantic retrieval quality" below.
|
|
|
|
## Semantic retrieval quality (Imagination, 2026-10-25)
|
|
|
|
Serves `eureka-substrate:ruling:recall-triggers-rescoped`, which puts the
|
|
retrieval-quality gaps before any further recall trigger. Gaps:
|
|
`gap-semantic-topk-before-filter` and `gap-semantic-page-no-score` are one
|
|
cut, `cut-semantic-members-scores`. `gap-semantic-one-text-per-query` is next
|
|
in the sequence. Huginn is at `0b8ae4f` (origin/main, checkout clean on main).
|
|
|
|
### Body facts (probes and source reads, 2026-10-25)
|
|
|
|
- S-B1 (source read, `0b8ae4f`). The search path. `Daemon::begin_search`
|
|
(`crates/huginn-daemon/src/daemon.rs:176-185`) runs `Mind::check_semantic`,
|
|
then calls `IndexSink::search(text, top_k)`. The worker's `Projector::search`
|
|
(`crates/huginn-daemon/src/index/mod.rs:576-606`) embeds the text and asks
|
|
`VectorIndex::search` for `min(top_k * 4, OVERSAMPLE_MAX = 200)` points with no
|
|
filter (`:595`). `QdrantIndex::search` (`index/qdrant.rs:141-157`) posts
|
|
`points/search` with `vector`, `limit` and `with_payload: [doc_id, kind]`, and
|
|
no `filter` or `params`. `Daemon::finish` (`daemon.rs:206-221`) hands the hits to
|
|
`Mind::rank` (`crates/huginn-mind/src/query.rs:536-594`). `rank` drops the
|
|
hits the mind does not hold, cuts to the best 200 by score, narrows the
|
|
selection's `keys` to the candidates, evaluates the selection at head, sorts
|
|
by score and cuts to `min(limit, top_k)`. The selection's filters (kind,
|
|
root, `in_force`, hops) therefore apply only to the oversampled window: the
|
|
window is cut before membership is decided. The scores live in a local map
|
|
(`:558-575`), and `Self::page` (`:597-620`) builds `PipelineSelectionPage`
|
|
(`:274-284`: `matched, as_of, next, items, edges`) without them.
|
|
- S-B2 (live probe through the eureka-state MCP tool, as_of 3919). Root
|
|
`eureka-substrate` has 39 in-force rulings. A semantic query over them
|
|
returned 3 rows at top_k 15 and 0 rows at top_k 4. Over its 77 in-force
|
|
questions and follow-ups, top_k 15 returned 4. Over the 1,686 in-force
|
|
rulings, questions and follow-ups of every root, top_k 10 returned 10. The 4x
|
|
over-fetch is enough only when members make up more than about a quarter of
|
|
the nearest points.
|
|
- S-B3 (live probe, `tools/mind.py` from Starfire). A semantic query takes 2.5
|
|
to 2.7 s end to end, whatever the selection (narrow, mid, no filter) and
|
|
top_k (4 to 200). `whoami` takes 0.14 s. Embedding dominates the time.
|
|
- S-B4 (live probe, Yggdrasil `127.0.0.1:6333`). Qdrant 1.17.1, collection
|
|
`huginn_mind_eureka`: 11,210 points (the mind holds 11,218 documents), 1,024
|
|
dimensions, Cosine, `on_disk` vectors. `indexed_vectors_count` is 0 over 8
|
|
segments, and `hnsw_config.full_scan_threshold` and `indexing_threshold` are
|
|
both 10000 (KB). No segment has reached the indexing threshold, so no HNSW
|
|
graph exists and every search is a brute-force scan. An unfiltered limit-200
|
|
search returned the same ids in the same order with and without
|
|
`params.exact`. The payload schema is empty, so there is no payload index.
|
|
- S-B5 (live probe, same collection, median of 5 calls against one stored
|
|
vector):
|
|
|
|
| Search | ms | request bytes | response bytes |
|
|
|---|---|---|---|
|
|
| unfiltered, limit 200 | 3.3-3.6 | 14k | 41k |
|
|
| unfiltered, limit 11,210 (every point) | 42-45 | 14k | 2.07M |
|
|
| `has_id` 200 ids, limit 50 | 1.3 | 22k | 10k |
|
|
| `has_id` 3,000 ids, limit 50 | 3.7 | 134k | 10k |
|
|
| `has_id` 11,210 ids, limit 50 (exact: 9.5) | 9.0-10.5 | 462k | 10k |
|
|
|
|
Each filtered top 50 was compared with a brute-force full ranking restricted
|
|
to the same ids. The score lists were equal in every case, and the id sets
|
|
were equal except at tied scores (`0.58452` twice, `0.67256` twice), where
|
|
identical texts traded places at the window's edge. A `has_id` filter is
|
|
therefore exact up to ties.
|
|
- S-B6 (source read, CultLib `8fc74c7`, the `cultnet-rs` that Huginn pins).
|
|
`select` (`packages/cultnet-rs/src/selection.rs:1742`) evaluates schemas,
|
|
keys, fields and both hops over the full row set. It returns at most
|
|
`LIMIT_MAX` = 200 rows per call, paged by cursor (`:1814-1830`). `matches`
|
|
(`:1630`) is the single-row fast path and asserts that the selection has no
|
|
hop. No entry point returns every match unpaged. `Reader::at`
|
|
(`query.rs:364-375`) reads the mind as of any receipt, so a cursor walk at
|
|
one `as_of` is exact.
|
|
- S-B7 (source read). Consumers of the page shape:
|
|
- `huginn-mind` `wire.rs`: `HuginnMindResponse::Query` with
|
|
`derive(... Eq ...)` at `:122`, and a round-trip fixture at `:273-279`.
|
|
- The published `schemas/cultnet/huginn.mind_response.v1.schema.json`. Its
|
|
`PipelineSelectionPage` is at `:3665-3703`, pinned byte for byte by
|
|
`published_wire_schemas_match_derivation` (`wire.rs:332`).
|
|
- The eureka-state MCP server (`crates/eureka-state/src/main.rs:283-293`),
|
|
which passes the page through as JSON.
|
|
- The `huginn` CLI (`src/bin/huginn/main.rs:228-269`), which prints headers
|
|
and `matched`, and its tests' page literals (`tests/cli.rs:167-168`,
|
|
`:521-527`).
|
|
- `mind-body` `queue.rs:66`, which makes plain queries only.
|
|
- In the Eureka repo (`6d7cf60`), `tools/recall.py`. It reads only
|
|
`answer["items"]["Documents"]` (`:148-150`, `:174-176`) and ignores
|
|
other keys.
|
|
Both doors ignore unknown keys (gamecult-ops `runbooks/huginn-yggdrasil.md`,
|
|
Rollback), and serde reads a missing `Option` field as `None`.
|
|
- S-B8 (live probe, ssh Yggdrasil; gamecult-ops `b9e0109`). The live release is
|
|
`68c6dd7`, six first-parent merges behind `0b8ae4f`, among them `35dda44`
|
|
(mind-self-run) and `f8eaf50` (campaign-repos). Huginn on Yggdrasil is an
|
|
interim hand-installed systemd unit, not an Idunn target: there is no
|
|
recipe, binding or brake. The deploy owner is the runbook's Upgrade
|
|
procedure, steps 1-9, daemon first and then client. Its Verify section has no
|
|
semantic-search check. No `huginn.exe` is installed on Starfire. The live
|
|
client is `C:\Users\Meta\.eureka\bin\eureka-state.exe`.
|
|
|
|
### Model page
|
|
|
|
No persistent kind changes. The page is a wire answer and is never stored, so
|
|
`stored-documents-valid` is untouched. The Qdrant collection keeps its points,
|
|
payload and metadata. Only the search request changes.
|
|
|
|
### Rationale
|
|
|
|
- **One cut, not two.** Both gaps belong to the semantic page, and one function
|
|
decides both: `Mind::rank` and the page it builds. The membership fix changes
|
|
what `rank` receives and what `matched` means. The score is one more column
|
|
of the rows `rank` already orders by score. Two cuts would edit the same
|
|
function, page struct, derive line and schema file twice in a row.
|
|
`gap-semantic-one-text-per-query` is a different decision (how a query
|
|
becomes vectors, and fusion). It edits `SemanticQuery`, `Projector::search`
|
|
and the `IndexSink` signature, all of which this cut also changes, so it is
|
|
the next cut rather than a sibling.
|
|
- **The mind owns membership and the index only scores.** Before anything is
|
|
embedded, the mind computes the selection's whole match set (`members`) at
|
|
one snapshot. The index scores exactly those points and returns the nearest
|
|
`top_k`. `rank` reads the same snapshot, so the page is exact as of one
|
|
`as_of`, as a cursor walk is. This is pre-filtering. The established systems
|
|
have converged on it because post-filtering fails the way S-B2 shows. The
|
|
sources below are from their documentation as known in June 2026 and were
|
|
not re-fetched for this map. Confidence is high on the mechanisms and medium
|
|
on the exact defaults.
|
|
- pgvector: with an HNSW or IVFFlat index, `WHERE` filters after the index
|
|
scan, so a filtered query returns fewer than `LIMIT` rows. 0.8.0 (2024)
|
|
added iterative index scans (`hnsw.iterative_scan` = `strict_order` or
|
|
`relaxed_order`, bounded by `hnsw.max_scan_tuples`, default 20,000), which
|
|
keep scanning until enough rows pass. Without an index, a scan with `WHERE`
|
|
is exact.
|
|
- Qdrant: filtering happens during search, not after it. The query planner
|
|
estimates a filter's cardinality from payload indexes. Below
|
|
`full_scan_threshold` it scores the filtered points exactly. Above it, it
|
|
uses filterable HNSW, which adds extra edges per indexed payload value.
|
|
`has_id` filters on the primary id, and `params.exact` forces a full scan.
|
|
- Weaviate: the filter builds an allow-list before the vector search. Below
|
|
`flatSearchCutoff` (default 40,000) it brute-forces the allow-list. Since
|
|
1.27 it can use ACORN on HNSW for restrictive filters.
|
|
- Milvus: a scalar filter becomes a bitset that the ANN search consults
|
|
while it walks the index.
|
|
- Lucene 9.1+ (and Elasticsearch and OpenSearch kNN `filter`): the filter is
|
|
evaluated first. If it matches fewer documents than the graph search would
|
|
visit, the search is exact over the matches. Otherwise HNSW is explored
|
|
accepting only filtered documents, so k results come back whenever k
|
|
exist.
|
|
|
|
The shared lesson: build the allow-list first, and brute-force it when it is
|
|
small. Here the whole corpus is 11k points, already scanned by brute force
|
|
(S-B4) and below every system's cutoff. The simplest exact mechanism is
|
|
therefore the right one: send the member ids as a `has_id` filter with
|
|
`params.exact: true` and `limit = top_k`. At today's size this costs at most
|
|
about 10 ms (S-B5), against a 2.5 s query (S-B3).
|
|
- **Rejected alternatives.**
|
|
- Iterative over-fetch (the pgvector 0.8 shape). It is still approximate
|
|
under its scan bound, and it needs a loop that crosses between the worker
|
|
and the mind several times per query. It also leaves the index deciding
|
|
which candidates the mind ever sees.
|
|
- Scoring every point (limit = collection size). This is exact, but it
|
|
sends 2 MB per query (S-B5), and `rank` would still have to page past 200
|
|
candidates.
|
|
- Qdrant payload filters on `kind` and `root`. `in_force` and the hops cannot
|
|
be expressed there, because the payload carries no status by design
|
|
(`index/mod.rs:57-61`). Payload filters would be a second, partial judge
|
|
of membership.
|
|
- The `matches` fast path for hop-free selections (S-B6), rejected for now.
|
|
It would be a second membership path beside `select`. Keep one walk.
|
|
- **Membership comes from a cursor walk.** It is enumerated by walking `select`
|
|
at one `as_of`, because the substrate pages at 200 (S-B6). That costs one
|
|
`select` per 200 members, at most 57 at today's size. If the live timing in
|
|
the spec's verification shows the walk matters, the fix is an unpaged
|
|
evaluation entry in CultLib's `cultnet-rs`, not a second judge here.
|
|
- **`matched` counts members.** On a semantic page `matched` counts the
|
|
selection's matches at `as_of`, as it does on a plain page, so a caller sees
|
|
"39 matched, 15 shown". Before this cut it counted the candidates that
|
|
passed.
|
|
- **The score is a parallel field.** It is `scores: Option<Vec<f32>>` on
|
|
`PipelineSelectionPage`: one score per item, in order, present exactly when
|
|
the answer is semantic.
|
|
- This follows the page's `edges` convention ("present exactly when the
|
|
selection follows a hop"). It also keeps the score off
|
|
`PipelineDocumentView`, which `view` returns too.
|
|
- The value is Qdrant's cosine similarity as returned: higher is nearer, at
|
|
most 1.
|
|
- The field is additive and never stored. An old client ignores it, and a
|
|
new client reads `None` from an old daemon. Rolling back by flipping
|
|
`current` stays safe (runbook Rollback, the additive-field class).
|
|
- `f32` is not `Eq`, so `Eq` leaves the page and response derives.
|
|
- **Known limit: ties at the window's edge.** Members with tied scores (from
|
|
identical texts, S-B5) are cut in the index's order, not by the mind's
|
|
ordinal tie-break, because the mind sees only the window. Accepted, because
|
|
ties need identical text.
|
|
- **Known limit: exact search is linear.** `params.exact` makes every search
|
|
linear in the number of members: about 10 ms at 11k points. Revisit when the
|
|
collection nears 100k points or a filtered search passes about 50 ms. Then
|
|
Qdrant's own planner is the next mechanism.
|
|
- **`recall.py`'s compensation becomes correct.** Once this cut is deployed,
|
|
`+len(excluded)` compensates the right set. The ids it excludes (the answered
|
|
question, superseded subjects) are in-force members, so asking for
|
|
`k + len(excluded)` members leaves at least `k` after exclusion whenever
|
|
that many exist. Before the cut it compensated for exclusions while the
|
|
shortfall came from non-members.
|
|
|
|
### Cut sequence
|
|
|
|
1. semantic-members-scores (Huginn): `cut-semantic-members-scores.r2`.
|
|
2. ops-semantic-verify (gamecult-ops, lands with the deploy of 1): the
|
|
runbook's Verify gains a semantic check, that a narrow selection returns
|
|
`min(top_k, matched)` rows, each with a score. It waits on 1's verdict,
|
|
because it states 1's behaviour.
|
|
3. semantic-many-texts (Huginn): `SemanticQuery` takes several texts, embeds
|
|
them in one batch call and fuses them by reciprocal rank inside the organ
|
|
(`gap-semantic-one-text-per-query`). It waits on 1, because it edits the
|
|
same `SemanticQuery`, `Projector::search` and `IndexSink::search`.
|
|
4. recall-score-threshold (Eureka): `recall.py` drops rows below a score floor
|
|
instead of keeping a fixed top_k, and the `huginn` CLI prints each row's
|
|
score. It waits on 1 being deployed.
|
|
5. `gap-persona-state-not-indexed` stays with the persona-state upgrade
|
|
(`huginn-upgrade-daemon-first`).
|
|
|
|
## Upgrade read-back gate (Imagination, 2026-10-26)
|
|
|
|
Ruling `eureka-substrate:ruling:readback-gate-and-materialized-image` part (1)
|
|
and follow-up `eureka-substrate:follow_up:upgrade-readback-unbounded`. Pinned:
|
|
Huginn `origin/main` `dd07485b4e69e6743825fec2f19825761ea446a6`, gamecult-ops
|
|
`origin/main` `b9e0109773eccea3215ed53c1af5ecccae37b543`.
|
|
|
|
### Body facts (probe and source reads, 2026-10-26)
|
|
|
|
- Probe, 2026-10-26 ~12:52 UTC, Yggdrasil, `ygg-verify.sh` `RUN_ON_HOST=1`
|
|
(image `eureka-verify-rust:b99d30ec441e`, 4 CPUs, 6 GiB, nice 10). A scratch
|
|
clone at `dd07485` plus one probe test in `query.rs`'s tests (scratch commit
|
|
`d93b400`, pushed only to the verify mirror) ran against a copy of the cold
|
|
copy `/var/backups/huginn-upgrade/dd07485b…/mind.redb` (sha256
|
|
`814947b9…c5e0`, 245,370,880 bytes) inside the job's work dir; the copy was
|
|
deleted afterwards. 15,291 envelopes, 11,328 pipeline documents.
|
|
|
|
| Step | debug | release |
|
|
| --- | --- | --- |
|
|
| `Mind::open` | 3.16 s | 0.58 s |
|
|
| decode, validate, re-key, re-prepare to stored bytes (all 11,328) | 1.14 s | 0.23 s |
|
|
| one `Mind::view` (one `Reader::at`) | 19.6 s | 1.05 s |
|
|
| one `Reader::at` | 20.4 s | 0.97 s |
|
|
| `held` + `view_of` for all 11,328 from that one `Reader` | 4.88 s | 0.39 s |
|
|
| whole test binary | 49.3 s | 3.35 s |
|
|
|
|
So the deleted organ test's loop (one `Mind::view` per document) costs about
|
|
11,328 x 20 s = 63 h in debug and 3.3 h in release: it never finishes, which
|
|
matches the 2 h hang. The one-snapshot gate costs open + byte check +
|
|
one `Reader::at` + the view loop: about 30 s debug, 2.5 s release at this
|
|
size. The runbook's deadline is 600 s.
|
|
- The view loop from one `Reader` is not strictly linear: `Reader::held` is a
|
|
linear find and `Docs::closing_resolution` scans every resolution, so it is
|
|
O(N x R) comparisons. It is the same cost `Reader::rows` pays on every query
|
|
today, measured at 4.9 s debug. The gate does not fix it; part (2) of the
|
|
ruling (the materialized image) owns it.
|
|
- `mind::tests::snapshot_admits_and_reads_back` (`mind.rs:789-878`) also
|
|
resolves six hard-coded idunn-watchdog findings and withdraws resolutions
|
|
whose withdrawal keys exceed 64 bytes: the 2026-10-01 key-bound migration,
|
|
done once. `eureka-pipeline` `tests::stored_documents_read_back`
|
|
(`lib.rs:2750-2822`) checks decode, validate, re-key and stored bytes, plus
|
|
resolvability loops and the same six findings ("still stuck").
|
|
|
|
### Rationale
|
|
|
|
One gate, in `huginn-mind`. The leaf cannot hold it: `eureka-pipeline` has no
|
|
dependency on `huginn-mind` (the reverse holds), so it cannot see `view`. Two
|
|
gates overlapped on decode, validate and re-key; the leaf's byte check costs
|
|
1.1 s debug and moves into the organ's gate beside the view check. The leaf's
|
|
resolvability loops are pinned on synthetic keys by its bound tests (ruling
|
|
`key-bound`), and its six-finding list is history.
|
|
|
|
The gate goes through the read path, not a copy of it: `Mind::view` is split
|
|
into `Reader::at` plus a new `Reader::view` (lookup, then `view_of`), and the
|
|
gate builds one `Reader` and calls `Reader::view` per document. A test that
|
|
re-spelled `held` and `view_of` would agree with its own copy. That split is
|
|
the only production edit; `Reader::at`, `held`, `view_of`, `rows`, `query`
|
|
and `rank` are unchanged, and the read path's cost stays with part (2).
|
|
|
|
### Cut sequence
|
|
|
|
1. readback-gate (Huginn): `cut-readback-gate.r1`.
|
|
2. ops-readback-deadline (gamecult-ops): `cut-ops-readback-deadline.r1`,
|
|
beside 1. Step 5 runs the one gate detached in container
|
|
`huginn-readback-$sha`, deadline 600 s, kill by name, old daemon restarted on
|
|
expiry or failure. It names 1's test, so an upgrade with it targets Huginn
|
|
main at or after 1's merge.
|
|
3. materialized-image (Huginn): a decoded, indexed image the daemon keeps and
|
|
advances per admission (ruling part 2). Mapped after prior art; not mapped
|
|
here.
|