Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25ccb5c75b | |||
| 22e7a1b0e7 | |||
| a9c521a48a | |||
| 8fc757aa61 | |||
| 39050c333f | |||
| 19e5182228 | |||
| 5f321b968a | |||
| 5e28919b39 | |||
| 7bca76e7b6 |
@@ -87,8 +87,8 @@ id = "contract-drift-check-v1"
|
||||
canonical_source = "corviduo-project-template"
|
||||
canonical_path = "scripts/contract_drift_check.py"
|
||||
consumer_path = "scripts/contract_drift_check.py"
|
||||
pinned_sha256_16 = "23271287ac488da4"
|
||||
pinned_at = "2026-05-17T05:30:00+00:00"
|
||||
pinned_sha256_16 = "2659a17a65704b66"
|
||||
pinned_at = "2026-07-12T08:39:35+00:00"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worldtree Conversation-API surface (vendored from ~/development/Worldtree).
|
||||
@@ -120,8 +120,8 @@ id = "worldtree-conversation-api-spec-v1"
|
||||
canonical_source = "Worldtree"
|
||||
canonical_path = "docs/conversation-api-spec.md"
|
||||
consumer_path = "docs/conversation-api-spec.md"
|
||||
pinned_sha256_16 = "c656a789caceef14"
|
||||
pinned_at = "2026-07-06T16:51:09+00:00"
|
||||
pinned_sha256_16 = "2d73d50b8680b893"
|
||||
pinned_at = "2026-07-13T07:54:05+00:00"
|
||||
tolerate_drift = true # prose reference; OpenAPI+SSE are the gates
|
||||
|
||||
# Worldtree persona render canons (d2) — the deterministic affect->NL the agent is
|
||||
@@ -155,6 +155,52 @@ id = "worldtree-affect-egress-consumer-reference-v1"
|
||||
canonical_source = "Worldtree"
|
||||
canonical_path = "docs/affect-egress-consumer-reference.md"
|
||||
consumer_path = "docs/vendor/worldtree-persona-canon/affect-egress-consumer-reference.md"
|
||||
pinned_sha256_16 = "d959134037efae83"
|
||||
pinned_at = "2026-07-07T06:09:24+00:00"
|
||||
pinned_sha256_16 = "b2406e237df00dcb"
|
||||
pinned_at = "2026-07-13T07:54:05+00:00"
|
||||
tolerate_drift = true # prose reference; the d2 render-canon JSONs are the gates
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Brokkr R34/R35 persona-prompt-framing reference (the character-self-report
|
||||
# reframe ratatoskr consumes: the authored psychological_profile is the prose
|
||||
# lens the Worldtree self-report producer reads for affect + memory salience).
|
||||
# Vendored for reference alongside the Worldtree affect/memory surfaces.
|
||||
# tolerate_drift: prose reference, not a machine gate — brokkr-smithy-dev owns
|
||||
# it and pings ratatoskr-dev on canonical changes. The authoring-spec GOVERNS on
|
||||
# any conflict with the parameter distillation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
[[pins]]
|
||||
id = "brokkr-psych-profile-authoring-spec-v1"
|
||||
canonical_source = "brokkr-smithy"
|
||||
canonical_path = "research/R34-persona-prompt-framing/deliverables/psych-profile-authoring-spec.md"
|
||||
consumer_path = "docs/vendor/brokkr-r34-psych-profile/psych-profile-authoring-spec.md"
|
||||
pinned_sha256_16 = "4545a108d9fb6cc3"
|
||||
pinned_at = "2026-07-13T00:00:00+00:00"
|
||||
tolerate_drift = true # prose reference; brokkr-smithy-dev owns + pings on change
|
||||
|
||||
[[pins]]
|
||||
id = "brokkr-psych-profile-parameters-v1"
|
||||
canonical_source = "brokkr-smithy"
|
||||
canonical_path = "research/R34-persona-prompt-framing/deliverables/psych-profile-parameters.md"
|
||||
consumer_path = "docs/vendor/brokkr-r34-psych-profile/psych-profile-parameters.md"
|
||||
pinned_sha256_16 = "17157c82771aeeee"
|
||||
pinned_at = "2026-07-13T00:00:00+00:00"
|
||||
tolerate_drift = true # parameter distillation; authoring-spec governs on conflict
|
||||
|
||||
[[pins]]
|
||||
id = "soong-lab-export-contract-v1"
|
||||
canonical_source = "soong-lab"
|
||||
canonical_path = "docs/contracts/export.contract.md"
|
||||
consumer_path = "docs/vendor/soong-lab-bundle/export.contract.md"
|
||||
pinned_sha256_16 = "bbd8fcf0cc7bc535"
|
||||
pinned_at = "2026-07-14T17:15:44+00:00"
|
||||
tolerate_drift = true # soong-lab-dev owns the bundle format + pings ratatoskr-dev on change
|
||||
|
||||
[[pins]]
|
||||
id = "soong-lab-importer-contract-v1"
|
||||
canonical_source = "soong-lab"
|
||||
canonical_path = "docs/contracts/importer.contract.md"
|
||||
consumer_path = "docs/vendor/soong-lab-bundle/importer.contract.md"
|
||||
pinned_sha256_16 = "777b1764c8eb2cb7"
|
||||
pinned_at = "2026-07-14T17:15:44+00:00"
|
||||
tolerate_drift = true # soong-lab-dev owns the bundle format + pings ratatoskr-dev on change
|
||||
|
||||
@@ -116,9 +116,13 @@ interpreted.
|
||||
to the reference `_matches_scope`. (`scope_any` is the union-visibility primitive that
|
||||
resolves the #295/#297 silent-zero — a subset-scoped chunk now recalls via an OR member.)
|
||||
- **INV-006** [hard]: **Capabilities match implementation** (advertise-⇒-implement).
|
||||
`describe_store` advertises ONLY what v1 implements: `relational_edges_supported=False`,
|
||||
`describe_store` advertises ONLY what is implemented: `relational_edges_supported=False`,
|
||||
`atomic_supersede_supported=False`, `transaction_supported=False`,
|
||||
`optimistic_locking_supported=True`, `filterable_metadata_fields=[]`.
|
||||
`optimistic_locking_supported=True`, `filterable_metadata_fields=[]`,
|
||||
**`sortable_chunk_fields=[{"name": "updated_at"}]`** (the ONLY globally-sortable field;
|
||||
gates `scan`'s sort at the bifrost dispatch `_validate_scan_sort` AND Worldtree's #349
|
||||
person-prime Branch-A `"updated_at" in caps.sort_fields_supported` — advertising it is
|
||||
what lights up turn-1 durable-fact injection).
|
||||
(`transaction_supported` is the bifrost **wire-level** multi-op transaction
|
||||
capability — NOT our internal SQLite transactions, which we use for atomic
|
||||
batches.) The client gates the gated verbs off these.
|
||||
@@ -127,6 +131,20 @@ interpreted.
|
||||
`InvalidArguments` (mirrors the reference).
|
||||
- **INV-008** [hard]: The store is REQUIRED (`build_memory_app(store=None)` raises);
|
||||
identity/scope/actor come from `ctx`, never call args.
|
||||
- **INV-009** [hard]: **`scan` is LIVE-only.** `scan` returns ONLY live chunks —
|
||||
superseded / tombstoned / any non-live governance state is EXCLUDED server-side. This
|
||||
is load-bearing because Worldtree's person-prime requests `lifecycle_state="live"` but
|
||||
that filter does NOT ride the scan wire today and the client does not re-check it
|
||||
(worldtree-dev flagged the adapter gap); server-side live-only is authoritative, so a
|
||||
dead fact can never inject. The additive `lifecycle_state` scan arg, when present, is
|
||||
honored but never relied upon.
|
||||
- **INV-010** [hard]: **`scan` is globally ordered before pagination.** The FULL
|
||||
scope-filtered live set is ordered by `(sort.field, direction)` GLOBALLY before the
|
||||
`limit` page is taken — never page-local. Missing sort value sorts LAST; ties broken by
|
||||
`chunk_id` (stable). A single `limit`-page returns the N globally-newest (for
|
||||
`updated_at desc`), matching bifrost's cross-pagination conformance negative. The sort
|
||||
field is indexed (`json_extract(record_json, '$.updated_at')`) so the read stays within
|
||||
person-prime's 500 ms fail-open budget.
|
||||
|
||||
## Concurrency
|
||||
|
||||
@@ -163,7 +181,7 @@ negotiation, routes). **This contract** owns the store (the basic verbs + SQLite
|
||||
|
||||
## Out of scope (deferred — do NOT flag as drift)
|
||||
|
||||
- **Gated/maintenance verbs:** `upsert_edges`/`get_edges_for`, `scan`, `mark_invalid`/`mark_superseded`, `patch_many`, `atomic_supersede`, lease/checkpoint. Absent + advertised-unsupported.
|
||||
- **Gated/maintenance verbs:** `upsert_edges`/`get_edges_for`, `mark_invalid`/`mark_superseded`, `patch_many`, `atomic_supersede`, lease/checkpoint. Absent + advertised-unsupported. (`scan` is NO LONGER deferred — it is implemented + advertised via `sortable_chunk_fields` to light up Worldtree's #349 person-prime turn-1 durable-fact injection; see the `scan` FN spec + INV-009/INV-010.)
|
||||
- **metadata_filter beyond scope:** advertise `filterable_metadata_fields=[]`; a non-empty `metadata_filter` is unsupported in v1 (rejected — see search PRE).
|
||||
- **The combined two-plane server** (guide §7) — separate memory + affect apps in v1.
|
||||
- **Deployment** — dev-box background shell (`ratatoskr-memory-provider`), no systemd/infra.
|
||||
@@ -270,6 +288,29 @@ TESTS:
|
||||
delete_absent [boundary]: unknown id → {"deleted":0}
|
||||
```
|
||||
|
||||
```contract
|
||||
FN scan(self, *, scope_all: dict | None = None, scope_any: list | None = None, cursor: str | None = None, limit: int, sort: dict | None = None, lifecycle_state=None) -> dict
|
||||
BRIEF: Query-LESS paginated LIVE-chunk scan, globally ordered by an advertised sort field (updated_at) — the #349 person-prime turn-1 durable-fact injection primitive (no query vector, unlike search). Returns {records, cursor}.
|
||||
PRE: [PRE-001 hard] limit is a positive int -- else InvalidArguments
|
||||
PRE: [PRE-002 hard] scope_all/scope_any shape + lattice-validated via _validate_scope (identical to search PRE-003) -- else InvalidArguments / InvalidFilter
|
||||
PRE: [PRE-003 hard] sort, when present, is {field, direction}: field ∈ the advertised sortable_chunk_fields names ("updated_at"), direction ∈ {asc,desc}. The bifrost dispatch layer (_validate_scan_sort) is the enforcement gate; an unadvertised/malformed sort → InvalidArguments — NEVER a silent unsorted fallback
|
||||
POST: [POST-001 return_value] {records: [<verbatim chunk wire records, same shape as a search hit's chunk>], cursor: <opaque next-page str | None>}; ≤ limit records; each record carries updated_at + agent_id + subject{type,id} + worldtree_scope (the fields person-prime's client _scan_filter_matches keys on — a record missing any is silently dropped client-side) -- assert
|
||||
POST: [POST-002 return_value] LIVE-only — returns ONLY live chunks; superseded/tombstoned excluded server-side (INV-009)
|
||||
POST: [POST-003 return_value] GLOBAL-order — the FULL scope-filtered live set is ordered by (sort.field, direction) GLOBALLY before the limit page; missing value LAST; chunk_id tiebreak (INV-010)
|
||||
STEPS:
|
||||
1. [setup] validate limit (>0) + scope (as search); sort ← the dispatch-validated {field,direction}
|
||||
2. [sequential, flexibility=indicative] SELECT scope-filtered LIVE chunks ordered by the indexed sort field (json_extract(record_json,'$.updated_at')) in `direction`, missing-last, chunk_id tiebreak, GLOBALLY; apply cursor offset; take limit
|
||||
3. [cleanup] RETURN {records: verbatim chunks, cursor: next-page-or-None}
|
||||
TESTS:
|
||||
scan_recency [happy,tracer]: upsert 4 live chunks w/ distinct updated_at; scan(scope_all={end_user}, limit=3, sort={field:updated_at,direction:desc}) → the 3 newest, newest-first
|
||||
global_before_paginate [scenario]: 5 chunks, limit=2 → page-1 = the 2 globally-newest; the cursor page continues the GLOBAL order, not a page-local re-sort (INV-010; bifrost cross-pagination conformance)
|
||||
live_only [adversarial]: a superseded/tombstoned chunk is NEVER returned even if it is the newest (INV-009)
|
||||
scope_isolation [adversarial]: scope_all one end_user → never returns another partition's chunk (INV-005 applies to scan)
|
||||
unadvertised_sort [adversarial]: sort.field ∉ sortable_chunk_fields → InvalidArguments at dispatch (never silent unsorted)
|
||||
person_prime_record_shape [scenario]: each record carries agent_id + subject{type,id} + worldtree_scope + updated_at + verbatim/distillate — the _scan_filter_matches keys (else the client silently drops it)
|
||||
parity_vs_reference [scenario]: identical scan envelopes vs InMemoryMemoryStore → same ordered chunk_ids/shape (#195)
|
||||
```
|
||||
|
||||
```contract
|
||||
FN build_memory_provider_app(store: RatatoskrMemoryStore, heimdall_key: bytes, consumer_id: str = "ratatoskr") -> Starlette
|
||||
BRIEF: Wire JwtVerifier + registration; hand the store to bifrost's build_memory_app.
|
||||
|
||||
@@ -2858,6 +2858,8 @@ Semantics:
|
||||
which must carry all three of `pleasure` / `arousal` / `dominance`, each a
|
||||
float in `[-1.0, 1.0]`. Any other top-level key → 422 `validation_failed`;
|
||||
a missing or malformed `pad` → 422 `persona_seed_invalid`.
|
||||
|
||||
> **✓ R32-1B (landed, v1.0.0b29):** The PAD range `[-1.0, 1.0]` relaxes to an **unbounded latent `z`** with a finite wire sanity bound (`~±10`) as of R32 Slice-1B. The JSON shape/fields/types are UNCHANGED — only the declared range/semantics change (the value becomes a latent that renders to a bounded display value). Consumers that merely store-and-return PAD need no change; consumers that validate/clamp PAD to `[-1,1]` must relax that bound. Source of truth: `docs/contracts/persona_envelope.contract.md` rev 1.7 (INV-ENV-16).
|
||||
- **Seeds the current mood POINT, not the setpoint.** The OCEAN persona
|
||||
(above) fixes the setpoint the mood relaxes toward; this endpoint sets
|
||||
where the mood *starts*. It does not alter the persona.
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# Psychological Profile Authoring Spec — canonical
|
||||
|
||||
**Status:** canonical (v1). **Owner:** brokkr-smithy-dev (R34/R35 self-report reframe).
|
||||
**Audience:** anyone authoring a character's `psychological_profile` — Worldtree
|
||||
foundational characters (soong-dev) and consumer characters created via the
|
||||
Conversation API (ratatoskr and other external consumers).
|
||||
**For:** the Worldtree agent-definition schema; intended to live in the Worldtree
|
||||
client-app documentation.
|
||||
|
||||
This spec governs the **content** of the psychological profile (what to write and
|
||||
what never to write). The **physical wire shape** of the field (single string vs a
|
||||
small keyed dict) is Worldtree's schema call — see § Wire shape.
|
||||
|
||||
---
|
||||
|
||||
## 1. What it is
|
||||
|
||||
A dedicated **authored prose section** of a character definition that carries the
|
||||
character's **psychological bent and formative experience**. It is the source the
|
||||
self-report producer maps from when it decides, on each turn:
|
||||
|
||||
- **what the character feels** (affect self-report), and
|
||||
- **what the character notices and keeps** (character-voiced memory salience).
|
||||
|
||||
The profile is a *lens*, not a script. It never states per-turn emotions; it
|
||||
describes the standing disposition, history, values, and attention that — combined
|
||||
with the actual event — *produce* the emotion and the salience.
|
||||
|
||||
It sits **alongside the numeric OCEAN** values (a separate, deterministic input).
|
||||
The prose gives the *qualitative* bent; the OCEAN numbers give the *magnitude dial*
|
||||
(see § OCEAN interaction).
|
||||
|
||||
---
|
||||
|
||||
## 2. What it carries — the four dimensions
|
||||
|
||||
1. **Disposition / appraisal bent** — how the character characteristically
|
||||
*interprets* situations: attribution style, what they hold weighty, how they
|
||||
respond to being challenged. NOT per-event emotions.
|
||||
2. **Attention / salience focus** — the kinds of things this character
|
||||
characteristically *notices* (and therefore tends to remember).
|
||||
3. **Values / what a good day looks like** — the yardstick that drives what they
|
||||
find worth keeping.
|
||||
4. **Formative experience (history)** — the background that shapes both appraisal
|
||||
*and* salience. A character betrayed before appraises betrayal differently, and
|
||||
remembers different things.
|
||||
|
||||
You may write these as four short labelled sections or as one integrated paragraph
|
||||
— both are supported (see § Length & format).
|
||||
|
||||
---
|
||||
|
||||
## 3. Authoring rules (load-bearing)
|
||||
|
||||
These are the rules the whole reframe depends on. Rule 1 is the one that most often
|
||||
gets violated.
|
||||
|
||||
1. **Never name a per-event output emotion.** Do NOT write "is anxious", "gets
|
||||
angry at X", "feels hurt when criticized", "joyful". Naming an emotion **primes**
|
||||
it — the "pink ball" effect — so the producer will report that emotion regardless
|
||||
of what actually happens in the scene. Describe *disposition, history, values,
|
||||
attention*; let the emotion come from the event appraisal.
|
||||
- ✅ "Registers quickly when authority is substituted for craft." (an appraisal
|
||||
trigger — sets up how she reads an event, names no feeling)
|
||||
- ❌ "Feels contempt when someone pulls rank." (names the output emotion)
|
||||
|
||||
2. **Magnitude lives in the numeric OCEAN, not the prose.** *How strongly / how
|
||||
long* a character reacts (Neuroticism) is the deterministic OCEAN dial, rendered
|
||||
valence-neutral by the producer. Do not narrate reaction dynamics in the prose
|
||||
("comes apart", "takes it hard", "rich inner life") — that double-encodes what the
|
||||
number already carries. The prose gives the *qualitative bent*; the number gives
|
||||
the *gain*.
|
||||
|
||||
3. **Appraisal-style is allowed; output-emotion is not.** "Interprets others'
|
||||
actions charitably until she can't" (a style) is fine; "feels betrayed easily"
|
||||
(an output) is not. The style plus the event produce the output.
|
||||
|
||||
4. **Salience is character-relative; facts are not.** The profile shapes what the
|
||||
character *cares to remember*. It must never license rewriting *what happened* —
|
||||
when the character does remember something, it stays grounded in the transcript.
|
||||
|
||||
---
|
||||
|
||||
## 4. Wire shape & field placement
|
||||
|
||||
- **Content is prose** covering the four dimensions, authored as **one coherent prose
|
||||
string** — the four dimensions are authoring *structure* inside that single string,
|
||||
not separate wire fields.
|
||||
- **Wire shape (LOCKED, b53):** a single dedicated prose string, field
|
||||
**`psychological_profile`** (type `str`) on the persona layer — foundational
|
||||
`persona.psychological_profile`, Tier-3 `ValidatedPersona.psychological_profile`. It
|
||||
nests under the existing `Any`-typed persona field, so it is the shipped b53 shape —
|
||||
no schema change. **Not** a dict-of-four.
|
||||
- **Hard constraint (non-negotiable):** the profile is a **dedicated field the lens
|
||||
reads ONLY** (`resolve_psych_profile` reads only this field — no `behavioral_notes`
|
||||
or other general-field remap). Non-lens content leaking into the lens produces the
|
||||
"executive-assistant" failure (the producer reads response-format / tone / tool
|
||||
instructions as if they were the character's psychology).
|
||||
|
||||
---
|
||||
|
||||
## 5. The non-priming banned set
|
||||
|
||||
The non-priming rule (Rule 1) is **semantic, not a fixed wordlist** — it bans naming
|
||||
any per-event output emotion, which is broader than any specific vocabulary
|
||||
("anxious", "worried", "hurt" all prime even though they are not in the producer's
|
||||
fixed emotion roster).
|
||||
|
||||
- **The gate is human review:** does the prose describe disposition / appraisal-style
|
||||
/ history / values / attention, and never what the character *feels*?
|
||||
- **A mechanical lint is a backstop, not the gate.** If you build one, scan the
|
||||
fixed-15 OCC roster plus `synonym_map.json` (which already folds common affect
|
||||
synonyms) as the core set, optionally extended with a general affect lexicon. Treat
|
||||
a lint hit as a prompt to re-read, not an automatic reject.
|
||||
|
||||
---
|
||||
|
||||
## 6. Required vs optional dimensions
|
||||
|
||||
- **Required** (they *are* the lens): **disposition**, **attention / salience focus**,
|
||||
**values**.
|
||||
- **Strongly recommended:** **formative history** — it is the single biggest lever on
|
||||
richness (validated in P03: richer history → sharper, more character-appropriate
|
||||
salience). It may be brief for a deliberately thin character, but omitting it leaves
|
||||
salience under-grounded.
|
||||
|
||||
---
|
||||
|
||||
## 7. Length & format
|
||||
|
||||
- A focused paragraph, or four short labelled sections — **a lens, not a biography.**
|
||||
- Target **~150–300 words.** The producer reads this on **every** turn, so keep it
|
||||
tight; bloat is a latency and dilution cost.
|
||||
- **Prose only — never typed emotion fields.** The four dimensions are a coverage
|
||||
checklist for the author, not a schema of feelings to fill in.
|
||||
|
||||
---
|
||||
|
||||
## 8. Exemplars
|
||||
|
||||
These three were the validated P03 stimuli — integrated-paragraph form, each faithful
|
||||
to its OCEAN, none naming an output emotion. (OCEAN shown in **[−1, 1] storage units**;
|
||||
validated in P03 at the equivalent [0, 1] values.)
|
||||
|
||||
**Perrin — court scribe** (OCEAN: O0.0 C0.2 E−0.2 A0.1 N0.7)
|
||||
> Perrin keeps the court's records and has done so through two changes of regime. He
|
||||
> learned early that small errors compound — a misfiled writ once cost a man his
|
||||
> lands, and Perrin found the mistake too late to undo it. Since then he double-checks
|
||||
> everything and watches situations closely for what is out of place. He forms
|
||||
> attachments slowly and holds a given trust as a considerable thing. He measures
|
||||
> himself by whether he was useful and careful. He notices discrepancies, unspoken
|
||||
> tensions, and anything that threatens the order he keeps.
|
||||
|
||||
**Vared — veteran caravan guard** (OCEAN: O−0.2 C0.4 E−0.5 A−0.2 N−0.7)
|
||||
> Vared has guarded caravans across the northern routes for twenty years and buried
|
||||
> more traveling companions than he cares to count. He speaks little and shows less.
|
||||
> Danger he treats as weather — a thing to be handled. He judges people by what they
|
||||
> do under pressure and remembers who held the line. What reaches him reaches him
|
||||
> quietly and privately. He notices terrain, exits, who is armed, and shifts in a
|
||||
> group that might precede trouble.
|
||||
|
||||
**Sella — village healer** (OCEAN: O0.2 C0.2 E0.0 A0.8 N0.0)
|
||||
> Sella has tended the sick since she was old enough to carry water for her
|
||||
> grandmother, the healer before her. She reads people's pain quickly and carries some
|
||||
> of it with her. She interprets others' actions charitably until she cannot, and
|
||||
> prioritizes keeping the peace between people. She measures a day by whether she eased
|
||||
> someone's burden. She notices who is unwell, who is troubled, and what is left
|
||||
> unsaid.
|
||||
|
||||
Note how each closes on **attention** ("he notices…", "she notices…") — the salience
|
||||
focus stated plainly, no emotion named.
|
||||
|
||||
---
|
||||
|
||||
## 9. OCEAN interaction & the scaffold fallback
|
||||
|
||||
OCEAN values are stored on **[−1, 1]** (0 = average) — a **separate deterministic
|
||||
input** and the **magnitude dial** the prose must not duplicate (Rule 2). The producer
|
||||
renders **off-average** bands as valence-neutral disposition cues. It maps storage to
|
||||
[0, 1] first (`c = (v + 1) / 2`, `render_disposition` in b53) and then applies the
|
||||
canonical [0, 1] band cutoffs (`c < 0.33` low / `c > 0.66` high). In **storage units**
|
||||
that is:
|
||||
|
||||
| trait | low (v < −0.34) | high (v > +0.32) |
|
||||
|---|---|---|
|
||||
| **N** (reactivity only) | reactions are milder than most people's | reactions are more intense than most people's |
|
||||
| **E** (expression; may be excluded from affect elicitation) | socially reserved; expression less outwardly amplified | socially expressive; reactions more externally visible |
|
||||
| **O** | prefers the familiar, the concrete, established ways | curious, drawn to novelty, ideas, the unfamiliar |
|
||||
| **C** | less plan-bound; less weight on order, detail, obligation | attends closely to order, detail, and obligations |
|
||||
| **A** | less inclined to assume cooperative intent; direct, self-protective | more inclined to preserve rapport and weigh others' needs |
|
||||
|
||||
The **mid** band (−0.34 ≤ v ≤ +0.32, i.e. `c` in [0.33, 0.66]) renders nothing — an
|
||||
average trait is silent, **not** "low." (Boundaries are slightly asymmetric because
|
||||
the canonical 0.33/0.66 cutoffs are not symmetric about 0.5. Canonical rendering
|
||||
strings live in the reframe language catalog §4; persistence/recovery dynamics live in
|
||||
the deterministic mood decay, not the profile.)
|
||||
|
||||
**Scaffold fallback:** a character with **no** authored profile falls back to this
|
||||
band-rendering from the OCEAN numbers alone. That still functions — but the authored
|
||||
profile is what turns generic band cues into *this specific character's* appraisal and
|
||||
salience. Authoring the profile is how the reframe's value actually reaches a
|
||||
character.
|
||||
|
||||
---
|
||||
|
||||
## 10. Authoring divergent characters (contrast design)
|
||||
|
||||
When you want two characters to remember **noticeably different things** (e.g. for an
|
||||
eval contrast pair, or simply a varied cast), design the divergence on the **attention
|
||||
and values** dimensions first, and set the OCEAN numbers to *serve* that prose — not
|
||||
the reverse.
|
||||
|
||||
- **The sharpest contrast is a salience *drop*, not just a different flavor.** One
|
||||
character for whom relational/emotional content is genuinely non-salient (an
|
||||
operational, task-focused character in the Vared mold — notices terrain, logistics,
|
||||
who is armed) versus one who weights it highest (a caretaker who tracks who is
|
||||
troubled and what went unsaid). "Different notes, same facts" has real teeth only
|
||||
when one character *legitimately forgets* what the other keeps.
|
||||
- **High-yield axes for salience divergence:** O (what patterns they attend to), A
|
||||
(relational vs operational/self-protective focus), C (procedural/detail salience).
|
||||
- **Low-yield for salience:** E — it is expression-oriented (shapes how a reaction is
|
||||
*rendered*, not what is *noticed*), and may even be excluded from the affect
|
||||
elicitation. Don't lean on flipping E to create divergence.
|
||||
- **Watch the direction, not just the distance:** flipping every OCEAN axis to its
|
||||
opposite does not guarantee a strong contrast. If your reference character already
|
||||
*keeps* relational content, an even-more-agreeable opposite keeps it harder and the
|
||||
most intuitive contrast collapses. Aim the contrast at *dropping* what the reference
|
||||
*keeps*.
|
||||
|
||||
---
|
||||
|
||||
## Provenance & validation
|
||||
|
||||
Grounded in R34/R35 (self-report reframe), probes P02–P05: character-voiced memory
|
||||
salience validated on two model classes (P02/P03); the "Psychological Profile and
|
||||
Experience" section mapping validated as the lens source (P03); non-priming and
|
||||
magnitude-in-OCEAN corrections are operator rulings (2026-07-10). The affect half is
|
||||
live in production (Worldtree b53) and fired a contextually-apt self-report on a
|
||||
non-frontier seat. A powered efficacy eval (salience divergence / floor recall /
|
||||
salience≠facts firewall / graded model-slot response + the authored-vs-scaffold delta)
|
||||
is preregistering to quantify the memory half; findings will refine this spec, not
|
||||
overturn its authoring rules.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Psychological Profile Parameters — for AI generation (canonical)
|
||||
|
||||
**Status:** canonical (v1). **Owner:** brokkr-smithy-dev (R34/R35 self-report reframe).
|
||||
**Audience:** **soong-dev** (Soong's Lab / Soong's AI — the immediate builder that
|
||||
generates the profile from these parameters); **Worldtree** + **ratatoskr** (vendoring
|
||||
for reference alongside the authoring spec).
|
||||
**Relationship:** this is the **parameter distillation** of
|
||||
`psych-profile-authoring-spec.md` for the model where **Soong's AI writes the
|
||||
`psychological_profile` prose from parameters** (rather than a human hand-authoring it).
|
||||
The authoring spec carries the full reasoning + provenance and **governs on any
|
||||
conflict**; this file is the builder-facing input schema + generation guardrails + few-shot.
|
||||
|
||||
The profile is the prose **lens** the Worldtree self-report producer reads each turn to
|
||||
decide what the character **feels** (affect self-report) and what it **notices / keeps**
|
||||
(character-voiced memory salience). Soong's AI generates the prose; these are its inputs
|
||||
and the constraints its output must satisfy.
|
||||
|
||||
---
|
||||
|
||||
## 1. Input parameters (what the Lab collects / Soong's AI takes)
|
||||
|
||||
1. **role / vocation** — a short anchor ("court scribe", "veteran caravan guard",
|
||||
"village healer").
|
||||
2. **OCEAN values** — O, C, E, A, N each on **[−1, 1]** (0 = average). A **separate
|
||||
deterministic input** the producer uses directly (the "magnitude dial"); Soong's AI
|
||||
should see them to keep the qualitative bent *consistent* with the numbers, but must
|
||||
**not re-encode their magnitude** in the prose (constraint 2).
|
||||
3. **formative-history seed** — 1–2 key background facts/events that shape appraisal AND
|
||||
salience. **Single biggest lever on richness** (validated P03: richer history →
|
||||
sharper, more character-appropriate salience).
|
||||
4. **appraisal-bent seed** — how the character characteristically **interprets**
|
||||
situations (attribution style, what they hold weighty, how they respond to challenge).
|
||||
A *style*, NOT an emotion.
|
||||
5. **attention / salience-focus seed** — the kinds of things this character
|
||||
characteristically **notices** (and therefore keeps). Load-bearing for the memory half.
|
||||
6. **values / yardstick seed** — what "a good day" looks like; the yardstick driving what
|
||||
they find worth keeping.
|
||||
|
||||
## 2. Output (what Soong's AI emits)
|
||||
|
||||
A single coherent **prose string** (~150–300 words), field **`psychological_profile`**
|
||||
(type `str`) — the four dimensions (disposition / attention / values / formative-history)
|
||||
integrated as one paragraph. **Prose only — never typed emotion fields.** The producer
|
||||
reads it every turn, so keep it tight.
|
||||
|
||||
## 3. Generation constraints (the guardrails the output MUST obey — these ARE the reframe)
|
||||
|
||||
1. ★ **Never name a per-event output emotion.** Do NOT write "is anxious", "gets angry at
|
||||
X", "feels hurt when criticized", "joyful". Naming an emotion **primes** it (the
|
||||
"pink-ball" effect) so the producer reports it regardless of what actually happens.
|
||||
Describe disposition / history / values / attention; let the emotion come from the
|
||||
event appraisal.
|
||||
- ✅ "Registers quickly when authority is substituted for craft." (appraisal trigger)
|
||||
- ❌ "Feels contempt when someone pulls rank." (names the output emotion)
|
||||
2. **Magnitude lives in OCEAN, not prose.** Don't narrate reaction dynamics ("comes
|
||||
apart", "takes it hard", "rich inner life") — that double-encodes what the number
|
||||
already carries.
|
||||
3. **Appraisal-style yes; output-emotion no.** "Interprets others' actions charitably
|
||||
until she can't" (style) = fine; "feels betrayed easily" (output) = not.
|
||||
4. **Salience is character-relative; facts are not.** The profile shapes what the
|
||||
character *cares to remember*; it must never license rewriting *what happened* —
|
||||
remembered content stays grounded in the transcript.
|
||||
5. **Close on attention** ("...notices who is unwell, who is troubled, what is left
|
||||
unsaid") — state the salience focus plainly.
|
||||
|
||||
## 4. Few-shot exemplars (validated P03 — OCEAN in [−1, 1] storage units → emitted prose)
|
||||
|
||||
**Perrin, court scribe** (O0.0 C0.2 E−0.2 A0.1 N0.7)
|
||||
> Perrin keeps the court's records and has done so through two changes of regime. He
|
||||
> learned early that small errors compound — a misfiled writ once cost a man his lands,
|
||||
> and Perrin found the mistake too late to undo it. Since then he double-checks
|
||||
> everything and watches situations closely for what is out of place. He forms
|
||||
> attachments slowly and holds a given trust as a considerable thing. He measures himself
|
||||
> by whether he was useful and careful. He notices discrepancies, unspoken tensions, and
|
||||
> anything that threatens the order he keeps.
|
||||
|
||||
**Vared, veteran caravan guard** (O−0.2 C0.4 E−0.5 A−0.2 N−0.7)
|
||||
> Vared has guarded caravans across the northern routes for twenty years and buried more
|
||||
> traveling companions than he cares to count. He speaks little and shows less. Danger he
|
||||
> treats as weather — a thing to be handled. He judges people by what they do under
|
||||
> pressure and remembers who held the line. What reaches him reaches him quietly and
|
||||
> privately. He notices terrain, exits, who is armed, and shifts in a group that might
|
||||
> precede trouble.
|
||||
|
||||
**Sella, village healer** (O0.2 C0.2 E0.0 A0.8 N0.0)
|
||||
> Sella has tended the sick since she was old enough to carry water for her grandmother,
|
||||
> the healer before her. She reads people's pain quickly and carries some of it with her.
|
||||
> She interprets others' actions charitably until she cannot, and prioritizes keeping the
|
||||
> peace between people. She measures a day by whether she eased someone's burden. She
|
||||
> notices who is unwell, who is troubled, and what is left unsaid.
|
||||
|
||||
## 5. Validation
|
||||
|
||||
The gate is: **does the prose describe disposition / appraisal-style / history / values /
|
||||
attention, and NEVER what the character feels?** A mechanical lint (scan the fixed-15 OCC
|
||||
emotion roster + Worldtree's `synonym_map.json`) is a **backstop, not the gate** — treat a
|
||||
hit as a prompt to re-read, not an auto-reject.
|
||||
|
||||
## 6. Designing a varied cast / contrast (optional)
|
||||
|
||||
When two characters should remember **noticeably different things**: design the divergence
|
||||
on **attention + values first**, then set OCEAN to **serve** that prose (not the reverse).
|
||||
The sharpest contrast is a salience **drop** — one character for whom relational content is
|
||||
genuinely non-salient (a Vared-mold operational type: notices terrain, logistics, who is
|
||||
armed) vs one who weights it highest (a caretaker: tracks who is troubled, what went
|
||||
unsaid). *"Different notes, same facts" only has teeth when one character legitimately
|
||||
forgets what the other keeps.* High-yield axes: **O** (patterns attended), **A** (relational
|
||||
vs operational), **C** (procedural/detail). Low-yield: **E** (expression, not attention).
|
||||
Watch **direction, not just distance** — flipping every axis doesn't guarantee contrast (an
|
||||
even-more-agreeable opposite keeps relational content *harder*).
|
||||
|
||||
## 7. No-profile fallback
|
||||
|
||||
A character with **no** authored profile falls back to deterministic **OCEAN-band
|
||||
rendering** from the numbers alone — it still functions, but the authored profile is what
|
||||
turns generic band cues into *this* character's appraisal and salience.
|
||||
|
||||
---
|
||||
|
||||
**Provenance:** derived from `psych-profile-authoring-spec.md` (R34/R35 self-report
|
||||
reframe, probes P02–P05; non-priming + magnitude-in-OCEAN are operator rulings 2026-07-10).
|
||||
The affect half is live in Worldtree b53. A powered efficacy eval (memory half) is
|
||||
preregistering; findings will refine the parameters, not overturn the constraints.
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
module: "soong_lab.export"
|
||||
purpose: "Assemble a versioned export BUNDLE from a DesignObject — the native agents.define payload (Frame Invariant 1, emitted unchanged) + the soong-lab sidecar (portrait ref · Bifrost tool manifest · first_message) + the resume half (the full editable design state), under a versioned schema tolerant of unknown future metadata. Pure + deterministic: no I/O, no persistence, no network (library persistence + import are separate downstream epics)."
|
||||
depends_on:
|
||||
- "soong_lab.design" # validate_ocean + ROLE_CHOICES/validate_role (the role enum canon) + the DesignObject model + serialize_design (relocated here — see Integration points R1)
|
||||
used_by:
|
||||
- "soong_lab.bifrost" # the export design-tool handler (_make_export) builds the bundle for the session's design
|
||||
- "soong_lab.web" # the /api/export endpoint + the browser 'Export Asset' modal render the bundle
|
||||
- "soong_lab.importer" # FUTURE (import epic) — round-trips the resume half back into a DesignObject
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 200
|
||||
confidence: 0.82
|
||||
assumptions:
|
||||
- "The DesignObject handed to export is already mutated to its final state by E3 (the Soong convo loop). Export READS it; it never mutates the design (INV-E5-4)."
|
||||
- "design_id is CALLER-SUPPLIED (a param), not generated here. Its generation + lifecycle (the durable library key, ≠ Worldtree session_id) is the per-design-sessions epic; export only needs the value to stamp the bundle. This keeps E5-export self-contained + forward-compatible with per-design-sessions landing before OR after it (agent-discretion, see open_question C)."
|
||||
- "exported_at is CALLER-SUPPLIED (a param, default None). Pure builders can't read the clock; the caller (tool handler / endpoint) stamps the timestamp so build_export_bundle stays deterministic + testable (byte-identical output for identical inputs)."
|
||||
- "role is a FIRST-CLASS design field (operator ruling 2026-07-13), set by the E3a set_role tool from a CURATED 4-value enum ROLE_CHOICES = {assistant, thoughtful-assistant, character, thoughtful-character} — mirroring the D2 curated-style-modes pattern (a fixed semantic set, NOT the target deployment's arbitrary model-role registry). Export EMITS design.role into ship.native.role, so the native payload is directly valid (Frame Invariant 1 now holds literally — no 'modulo role' caveat; only tools still bind separately at session-create). The 4 enum values are canonical soong-lab labels that MUST be registered/granted model-roles on the target Worldtree at deploy (same grant requirement as Soong's own 'agent_architect' role, ADR-0012) — a deploy-time grounding item, not a contract blocker (open_question B)."
|
||||
- "psych_profile exports to the NATIVE persona layer at persona.psychological_profile. RESOLVED: the vendored canonical spec (docs/psych-profile-authoring-spec.md §4) states the wire shape is LOCKED (b53) — a single prose str field, Tier-3 ValidatedPersona.psychological_profile, nesting under the existing Any-typed persona field (no schema change). Corroborated by worldtree-codex (vor-cross) + brokkr-smithy-dev (althing 01KXD34ZTF…). The open worldtree-dev thread (01KXD1PZR7…) closes as a formality."
|
||||
open_questions:
|
||||
- "[B — deploy grounding, not a blocker] The 4 ROLE_CHOICES values (assistant / thoughtful-assistant / character / thoughtful-character) must be registered + grantable model-role slugs on the TARGET Worldtree (like Soong's own agent_architect role). Confirm with worldtree-dev/infra that these exact slugs exist on the deploy target before shipping; a missing slug fails the designed agent's session-create, not export. Export emits whatever role the design holds; validity of the slug on a given deployment is a deploy concern."
|
||||
- "[C — agent-discretion, notable] design_id as a caller-supplied param (drafted) vs E5-export generating it. Drafted as an input so E5-export doesn't force per-design-sessions to land first. If the operator re-sequences the epics so per-design-sessions lands first, no change needed here (the param source just moves)."
|
||||
- "[D — scope] E5-export = the PURE builders + validators + bundle schema (this contract). The /api/export endpoint + replacing the web/api.js exportBundle shim = a thin web-surface follow-up (amends web_surface.contract.md), NOT this contract. The Bifrost export-tool wiring IS in scope (Integration points) because the tool already exists as a stub. The set_role tool + DesignObject.role field are a companion prerequisite slice (Integration points) whose contract updates land in THIS pass (design_object + bifrost_server)."
|
||||
- "schema_version starts at '1.0'. The version bump policy on future bundle-shape changes (add-only vs breaking) is deferred to when the second version actually exists — v1 only needs the field present + readers to tolerate unknown metadata (INV-E5-6)."
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
E5-export is the FOUNDATION half of the operator-accepted (2026-07-13)
|
||||
export/import/library design — the block that expands the locked single-agent
|
||||
frame into a multi-pass tuning loop (design → export → reopen → tune → keep a
|
||||
library). This contract owns exactly ONE thing: turning a finished
|
||||
`DesignObject` into a **versioned export bundle**. Persistence (the library JSON
|
||||
dir), the recent-designs picker, and import round-tripping are separate
|
||||
downstream epics; export is pure and deterministic so those epics — and the
|
||||
tests — can build on a stable, side-effect-free core.
|
||||
|
||||
**The bundle is ONE artifact with two halves** (settled decision #4):
|
||||
|
||||
- **ship** — what you hand to a deployment: the native `agents.define` payload
|
||||
(Frame Invariant 1, emitted unchanged) + the soong-lab **sidecar** (persona
|
||||
portrait ref, the Bifrost tool manifest, the D3 first_message).
|
||||
- **resume** — what you reopen to keep tuning: the full editable design state
|
||||
(the §6 DesignObject serialization), so a future import reconstructs the
|
||||
DesignObject exactly.
|
||||
|
||||
Plus a stable **`design_id`** (the durable library key, ≠ Worldtree
|
||||
`session_id`) and a **`schema_version`**, both at the top level.
|
||||
|
||||
**Frame Invariant 1 is preserved — and now holds literally.** `ship.native` is a
|
||||
valid Worldtree Tier-3 `agents.define` payload assembled from `agent_name` + the
|
||||
designed agent's **`role`** (the model-role, resolved below) + the AUTHORED
|
||||
`system_prompt` (INV-E2-2 — never `composed_preview`) + `persona.ocean`
|
||||
(Worldtree renders affect at runtime) + `persona.psychological_profile` (the
|
||||
native home, LOCKED b53 per the vendored spec §4) + `motivational` (from
|
||||
goals_fears). The image and tools are NOT in the native schema — they ride the
|
||||
sidecar (tools bind via Bifrost at session-create, exactly as grounded).
|
||||
|
||||
**The `role` resolution (operator ruling 2026-07-13).** The blast-radius pass
|
||||
caught that `agents.define` requires `role` (a model-role slug, ADR-0012) but the
|
||||
design had no source for it. Resolution: **role is a first-class design field**,
|
||||
set by a new E3a **`set_role`** tool from a **curated 4-value enum** —
|
||||
`assistant` (general LLM), `thoughtful-assistant` (CoT general),
|
||||
`character` (RP/writing-tuned), `thoughtful-character` (CoT RP). This mirrors the
|
||||
D2 curated-style-modes decision: a fixed semantic set the operator picks from,
|
||||
NOT a coupling to any one deployment's arbitrary role registry. Export emits
|
||||
`design.role`, so the native payload is directly POST-valid (modulo the tool
|
||||
binding every consumer already supplies at session-create). The one deploy-time
|
||||
caveat: the 4 slugs must be granted on the target Worldtree (open_question B).
|
||||
|
||||
**The psych field is RESOLVED (no longer quarantined).** Vendored spec §4 locks
|
||||
`persona.psychological_profile` (prose `str`, ~150–300 words, read every turn),
|
||||
nesting under the `Any`-typed persona layer. Export maps `design.psych_profile`
|
||||
there and NOWHERE else — spec §4's hard constraint is that the self-report lens
|
||||
reads ONLY this field (leaking psych prose into `behavioral_notes`/`system_prompt`
|
||||
causes the "executive-assistant" failure).
|
||||
|
||||
## Data flow
|
||||
|
||||
**In:** a `DesignObject` (final, from E3) + a caller-supplied `design_id` (str)
|
||||
+ an optional caller-supplied `exported_at` (str | None). **Out:** a plain
|
||||
JSON-ready `dict` — the versioned bundle. **On disk / network:** NONE. Export is
|
||||
pure: the OCEAN parity gate (`validate_ocean`), the role-enum gate
|
||||
(`validate_role`), and the export-critical validators are in-memory; timestamps +
|
||||
ids come in as params; no clock, no randomness, no file, no HTTP. (Library
|
||||
persistence writes the returned dict to the JSON dir — that is the library epic,
|
||||
not this module.)
|
||||
|
||||
### Export bundle schema (v1.0)
|
||||
|
||||
```
|
||||
{
|
||||
"schema_version": "1.0", # ALWAYS EXPORT_SCHEMA_VERSION — not a caller param
|
||||
"design_id": "<caller-supplied durable library key, ≠ WT session_id>",
|
||||
"exported_at": <caller-supplied OPAQUE str | null — conventionally ISO-8601, NOT validated by export>,
|
||||
"ship": {
|
||||
"native": { # a valid agents.define payload (Frame Invariant 1)
|
||||
"agent_name": <str, non-blank, ≤128>,
|
||||
"role": <one of ROLE_CHOICES: assistant|thoughtful-assistant|character|thoughtful-character>,
|
||||
"system_prompt": <str, non-blank, ≤32768 — the AUTHORED block, INV-E2-2>,
|
||||
"persona": {
|
||||
"ocean": {O,C,E,A,N}, # each a real number in [-1,1] (validate_ocean parity)
|
||||
"psychological_profile": <str> # persona.psychological_profile (LOCKED b53); included iff non-blank
|
||||
},
|
||||
"motivational": {"goals": [...], "fears": [...]} # included iff goals_fears present + non-empty
|
||||
},
|
||||
"sidecar": {
|
||||
"portrait": <image ref str | null>, # only when portrait.status == "ready"; E4 owns generation
|
||||
"tools": [{"id","name","description"}],# the Bifrost tool manifest (bind at session-create)
|
||||
"first_message": <str> # the D3 opening turn (issue #347 seed)
|
||||
}
|
||||
},
|
||||
"resume": { <the §6 camelCase editable state — key set inlined below> }
|
||||
}
|
||||
```
|
||||
|
||||
**The `resume` key set (inlined — heid-review fold Gróa #9).** The resume half IS
|
||||
`serialize_design(design)` (relocated to `soong_lab.design`, R1), but its key set is
|
||||
pinned HERE so this contract is self-contained and an implementer knows the exact
|
||||
round-trip surface without reading the external, being-relocated function:
|
||||
|
||||
```
|
||||
resume = {
|
||||
"agentName", "role", "systemPrompt", "composedPreview", "firstMessage",
|
||||
"ocean" {O,C,E,A,N}, "dispositionPhrase", "psychProfile",
|
||||
"tools" [{id,name,description}], "portrait" {status, styleMode, imageUrl?, jobId?},
|
||||
"goalsFears" {goals,fears} | null
|
||||
}
|
||||
```
|
||||
|
||||
Import reconstructs a DesignObject from exactly these keys. `role` (new, R1) MUST be
|
||||
present so a reopened design carries its model-role. (`composedPreview` +
|
||||
`dispositionPhrase` are design-time-derived and re-derivable, but they ride the resume
|
||||
so a reopen renders instantly before the first recompute.)
|
||||
|
||||
**Divergences from the imported web mock (settled here, they were UI-comp
|
||||
shortcuts):**
|
||||
|
||||
| Field | Mock (web/*.js) | Real export (this contract) |
|
||||
|---|---|---|
|
||||
| native shape | `{name, tier, system_prompt, personality:{model,values}}` | real `agents.define` (`agent_name`/`role`/`persona.ocean`/`motivational`) |
|
||||
| role | absent | `design.role` ∈ ROLE_CHOICES |
|
||||
| system_prompt | `composedPreview` (mockApi) | authored `system_prompt` (INV-E2-2) |
|
||||
| psychProfile | omitted ("open backend decision") | `persona.psychological_profile` (LOCKED b53) |
|
||||
| bundle identity | none | `design_id` + `schema_version` |
|
||||
| resume half | none | full `serialize_design` state |
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-E5-1** [hard]: `ship.native` is a valid Worldtree `agents.define` payload
|
||||
MODULO the tool binding — it carries every required field (`agent_name`,
|
||||
`role`, `system_prompt`) + `persona.ocean`, and OMITS only the tools (they bind
|
||||
via Bifrost at session-create, as they already do). Any `persona.ocean` export
|
||||
emits passes `validate_ocean`; `role` is always one of ROLE_CHOICES.
|
||||
`persona.psychological_profile` + `motivational` are OPTIONAL native fields
|
||||
(grounded) — omitting them when blank/empty keeps the payload fully valid, not
|
||||
merely "valid enough" (heid-review fold, Gróa #1).
|
||||
- **INV-E5-2** [hard]: The exported `system_prompt` is the AUTHORED
|
||||
`design.system_prompt`, NEVER `composed_preview` (binds with INV-E2-2). The
|
||||
**disposition line** — the `"Disposition: <name> is <phrase>."` sentence that
|
||||
E2 `recompute` appends to `composed_preview` (design_object.contract.md POST-E2-5)
|
||||
— is design-time-only and never ships.
|
||||
- **INV-E5-3** [hard]: Export is pure + deterministic — identical
|
||||
`(design, design_id, exported_at)` inputs yield a byte-identical serialized
|
||||
bundle. No clock, no randomness, no I/O. The determinism is WITHIN the module:
|
||||
the returned dict has a fixed key insertion order (schema_version, design_id,
|
||||
exported_at, ship, resume; native + sidecar likewise), so any consistent
|
||||
`json.dumps` settings produce byte-identical output — the invariant does NOT
|
||||
claim cross-implementation byte-identity (heid-review fold, Regin #6).
|
||||
- **INV-E5-4** [hard]: Export NEVER mutates the input `DesignObject` (read-only);
|
||||
the bundle holds copies, not aliases, of every mutable sub-structure (ocean
|
||||
dict, tool list, goals/fears lists) so a later design mutation can't change an
|
||||
already-built bundle.
|
||||
- **INV-E5-5** [hard]: `validate_exportable` is the strict export-critical gate
|
||||
(decision #6): OCEAN (via `validate_ocean`), role (∈ ROLE_CHOICES via
|
||||
`validate_role`), agent_name (non-blank, ≤128), system_prompt (non-blank,
|
||||
≤32768), tool-refs (id/name non-blank + bounded). A design that fails ANY of
|
||||
these raises `ExportError` and NO bundle is produced — a built bundle is always
|
||||
well-formed enough to round-trip on import.
|
||||
- **INV-E5-6** [hard]: The bundle carries `schema_version` at the top level, and
|
||||
readers (import, future) MUST tolerate unknown extra keys (lenient on unknown
|
||||
metadata, decision #6) — the schema is add-only-friendly.
|
||||
- **INV-E5-7** [hard]: `psych_profile` maps to `persona.psychological_profile`
|
||||
and NOWHERE else — it never leaks into `behavioral_notes`, `system_prompt`, or
|
||||
any other native field (vendored spec §4 hard constraint — the lens reads only
|
||||
this dedicated field).
|
||||
|
||||
## Constraints
|
||||
|
||||
- **[correctness]** `validate_exportable`'s OCEAN check IS `validate_ocean` and
|
||||
its role check IS `validate_role` (both E2) — no re-implementation, no drift.
|
||||
The LENGTH bounds (name, prompt, tool id/name/desc, psych_profile, first_message)
|
||||
MUST equal the E3a tool-schema caps — now shared constants in `soong_lab.design`
|
||||
(`AGENT_NAME_MAX`, `SYSTEM_PROMPT_MAX`, `PSYCH_PROFILE_MAX`, `FIRST_MESSAGE_MAX`,
|
||||
`TOOL_*_MAX`), imported by BOTH bifrost/tools.py and export — so a design's field
|
||||
LENGTHS never drift. Import the shared constants; do not re-declare the numbers.
|
||||
(Export is stricter only on whitespace-blankness of the required fields — the one
|
||||
intentional one-way difference from the tools' minLength:1.)
|
||||
- **[style]** Pure — NO I/O (no clock, no file, no HTTP, no randomness). Every
|
||||
time-varying value (`design_id`, `exported_at`) is a param.
|
||||
- **[explicit]** The one deploy-time caveat (the 4 role slugs must be granted on
|
||||
the target WT) is documented in THIS contract (open_question B) + the library /
|
||||
README when it lands — NOT promised as a bundle/sidecar field (heid-review fold:
|
||||
the bundle is machine-consumed; a human deploy-note is not bundle data). The
|
||||
bundle carries the `role` value; slug-grant validity is a deploy concern.
|
||||
- **[explicit]** `build_export_bundle` is the PUBLIC entrypoint — it runs the
|
||||
validate→assemble ordering. `build_native_payload` / `build_sidecar` are exposed
|
||||
for testing + reuse but ASSUME an already-validated design (PRE-E5-2 / PRE-E5-4);
|
||||
a direct caller that skips `validate_exportable` owns that gate (heid-review fold,
|
||||
Hulda #5).
|
||||
|
||||
```contract
|
||||
FN validate_exportable(design: DesignObject) -> None
|
||||
BRIEF: The strict export-critical gate (settled decision #6) — refuse to build a bundle from a design that would fail on re-import or at the designed agent's define/session-create. Checks OCEAN (validate_ocean), role (validate_role), agent_name, system_prompt, every tool-ref, and the psych_profile/first_message LENGTH — against the SAME length caps the E3a tools enforce (shared constants). NO-DRIFT is one-directional: export's LENGTH bounds equal the tool caps, but export is deliberately STRICTER on whitespace — a whitespace-only required field (name/prompt/tool id/name) passes the tools' minLength:1 yet is rejected here (a " " name must not ship). Raises ExportError with the offending field; never mutates the design.
|
||||
PRE: [PRE-E5-1 hard] design is a DesignObject
|
||||
POST: [POST-E5-1 exception] raises ExportError(field, detail) unless ALL hold: design.ocean passes validate_ocean; design.role passes validate_role (∈ ROLE_CHOICES); agent_name is a non-blank str of len ≤ _AGENT_NAME_MAX; system_prompt is a non-blank str of len ≤ _SYSTEM_PROMPT_MAX; every tool has non-blank str id (≤_TOOL_ID_MAX) + non-blank str name (≤_TOOL_NAME_MAX) + str description (≤_TOOL_DESC_MAX); psych_profile is a str of len ≤ _PSYCH_PROFILE_MAX (blank OK); first_message is a str of len ≤ _FIRST_MESSAGE_MAX (blank OK). The id/name-required vs description/psych/first_message-may-be-blank asymmetry is INTENTIONAL — description defaults to "" via attach_tool; psych_profile/first_message are optional prose so only their LENGTH is bounded, not blankness (heid-review Gróa #8 + correctness-finder folds)
|
||||
POST: [POST-E5-2 state_change] design is unchanged — no mutation (INV-E5-4)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] TRY validate_ocean(design.ocean) — on OceanError, RAISE ExportError("persona.ocean", str(exc)) (reuse E2, no re-impl)
|
||||
2. [sequential, flexibility=prescriptive] TRY validate_role(design.role) — on RoleError, RAISE ExportError("role", str(exc)) (reuse E2 role canon)
|
||||
3. [branch] IF agent_name is not a non-blank str OR len > _AGENT_NAME_MAX: RAISE ExportError("agent_name", ...)
|
||||
4. [branch] IF system_prompt is not a non-blank str OR len > _SYSTEM_PROMPT_MAX: RAISE ExportError("system_prompt", ...) # the AUTHORED block, INV-E5-2
|
||||
5. [loop] FOR EACH tool in design.tools: IF id/name blank or over max, or description non-str/over max: RAISE ExportError(f"tools[{i}]", ...)
|
||||
6. [branch] IF psych_profile is non-str OR len > _PSYCH_PROFILE_MAX: RAISE ExportError("psych_profile", ...) # length only — blank OK (optional prose)
|
||||
7. [branch] IF first_message is non-str OR len > _FIRST_MESSAGE_MAX: RAISE ExportError("first_message", ...) # length only — blank OK
|
||||
8. [cleanup] RETURN None
|
||||
TESTS:
|
||||
minimal_ok [happy,tracer]: agent_name+system_prompt set, role="character", neutral OCEAN, no tools → no raise
|
||||
blank_name [adversarial]: agent_name="" → ExportError("agent_name")
|
||||
blank_prompt [adversarial]: system_prompt=" " → ExportError("system_prompt")
|
||||
prompt_too_long [boundary]: system_prompt of len _SYSTEM_PROMPT_MAX+1 → ExportError; len _SYSTEM_PROMPT_MAX → ok
|
||||
bad_ocean [adversarial]: ocean missing a key → ExportError("persona.ocean") (via validate_ocean)
|
||||
bad_role [adversarial]: role="wizard" (not in ROLE_CHOICES) → ExportError("role") (via validate_role)
|
||||
blank_role [adversarial]: role="" → ExportError("role")
|
||||
bad_tool_ref [adversarial]: a tool with id="" → ExportError("tools[0]")
|
||||
no_mutation [property]: a rejected design is byte-identical before/after the raise (INV-E5-4)
|
||||
psych_profile_length [boundary]: psych_profile="" → ok; len _PSYCH_PROFILE_MAX+1 → ExportError("psych_profile")
|
||||
first_message_length [boundary]: first_message len _FIRST_MESSAGE_MAX+1 → ExportError("first_message"); blank → ok
|
||||
whitespace_name_rejected [adversarial]: agent_name=" " → ExportError("agent_name") — deliberately stricter than the tool's minLength:1 (a whitespace-only name must not ship)
|
||||
length_bounds_parity [property]: any (name, prompt, tool, psych, first_message) LENGTH the E3a tool schema accepts is ≤ export's caps (shared constants); export is stricter ONLY on whitespace-blankness of required fields, never looser on length
|
||||
```
|
||||
|
||||
```contract
|
||||
FN build_native_payload(design: DesignObject) -> dict[str, Any]
|
||||
BRIEF: Map a DesignObject to a valid native agents.define payload (Frame Invariant 1). Emits agent_name + role + the AUTHORED system_prompt + persona{ocean, psychological_profile?} + motivational?. Copies mutable sub-structures (INV-E5-4). Assumes validate_exportable already passed (called by build_export_bundle).
|
||||
PRE: [PRE-E5-2 hard] design passed validate_exportable (OCEAN valid, role valid, name/prompt present) — build_export_bundle enforces this ordering
|
||||
POST: [POST-E5-3 return_value] result has agent_name == design.agent_name, role == design.role (∈ ROLE_CHOICES), and system_prompt == design.system_prompt (the AUTHORED block, INV-E5-2), and result["persona"]["ocean"] == a COPY of design.ocean
|
||||
POST: [POST-E5-4 return_value] result["role"] == design.role — the designed agent's model-role (one of the 4 ROLE_CHOICES); a valid agents.define required field
|
||||
POST: [POST-E5-5 return_value] persona.psychological_profile == design.psych_profile when psych_profile is non-blank, else the key is absent; it appears under persona and NOWHERE else (INV-E5-7)
|
||||
POST: [POST-E5-6 return_value] motivational == {"goals": copy, "fears": copy} when design.goals_fears is present AND at least one list is non-empty; else the key is absent (never an empty motivational block)
|
||||
STEPS:
|
||||
1. [setup] payload = {"agent_name": design.agent_name, "role": design.role, "system_prompt": design.system_prompt} # role emitted; system_prompt is the authored block (INV-E5-2)
|
||||
2. [sequential] persona = {"ocean": dict(design.ocean)} # COPY, not alias (INV-E5-4)
|
||||
3. [branch] IF design.psych_profile is a non-blank str: persona["psychological_profile"] = design.psych_profile # LOCKED b53 field; ONLY here (INV-E5-7)
|
||||
4. [sequential] payload["persona"] = persona
|
||||
5. [branch] IF design.goals_fears is not None AND (goals or fears non-empty): payload["motivational"] = {"goals": list(gf.goals), "fears": list(gf.fears)}
|
||||
6. [cleanup] RETURN payload # tools NOT here — they ride the sidecar / bind via Bifrost at session-create
|
||||
TESTS:
|
||||
authored_prompt [happy,tracer]: system_prompt authored + composed_preview differs → payload.system_prompt == authored, NOT composed_preview (INV-E5-2)
|
||||
role_emitted [happy]: role="thoughtful-character" → payload.role == "thoughtful-character" (POST-E5-4)
|
||||
ocean_copied [property]: mutate design.ocean after build → payload's ocean unchanged (INV-E5-4)
|
||||
psych_present [happy]: psych_profile set → persona.psychological_profile == it; it is the ONLY field carrying it (INV-E5-7)
|
||||
psych_absent [boundary]: psych_profile="" → no psychological_profile key
|
||||
motivational_present [happy]: goals_fears with goals=["x"] → motivational.goals == ["x"]
|
||||
motivational_absent [boundary]: goals_fears None → no motivational key; goals_fears with both lists empty → no motivational key
|
||||
no_tools_no_image [trace]: payload has no "tools" and no image field (they ride the sidecar / bind separately)
|
||||
```
|
||||
|
||||
```contract
|
||||
FN build_sidecar(design: DesignObject) -> dict[str, Any]
|
||||
BRIEF: Assemble the soong-lab sidecar — the three artifacts the native schema has no home for: the persona portrait ref, the Bifrost tool manifest, and the D3 first_message. Copies the tool list (INV-E5-4).
|
||||
PRE: [PRE-E5-4 hard] design is a DesignObject (its portrait/tools/first_message fields are read as-is; no validation here — validate_exportable is the gate, called by build_export_bundle before this)
|
||||
POST: [POST-E5-7 return_value] result == {"portrait": <str|None>, "tools": [{"id","name","description"} per tool, copied], "first_message": design.first_message}; portrait == design.portrait.image_url IFF design.portrait.status == "ready", else None (a "ready" status with a None image_url therefore yields None — no crash; any non-"ready" status → None — heid-review fold Gróa #4)
|
||||
STEPS:
|
||||
1. [setup] portrait = design.portrait.image_url if design.portrait.status == "ready" else None
|
||||
2. [sequential] tools = [t.to_dict() for t in design.tools] # ToolRef.to_dict() — the shared {id,name,description} projection (dedups with serialize_design); it MUST emit exactly id/name/description, so if to_dict ever grows keys the sidecar spec must be revisited (heid-code-review fold)
|
||||
3. [cleanup] RETURN {"portrait": portrait, "tools": tools, "first_message": design.first_message}
|
||||
TESTS:
|
||||
ready_portrait [happy]: portrait.status="ready", image_url set → sidecar.portrait == the url
|
||||
unready_portrait [boundary]: portrait.status="generating" (url set) → sidecar.portrait is None (only ready ships)
|
||||
none_portrait [boundary]: portrait.status="none" → sidecar.portrait is None
|
||||
tools_manifest [happy,tracer]: two tools → sidecar.tools has both {id,name,description}
|
||||
tools_copied [property]: mutate design.tools after build → sidecar.tools unchanged (INV-E5-4)
|
||||
first_message [happy]: first_message set → sidecar.first_message == it
|
||||
```
|
||||
|
||||
```contract
|
||||
FN build_export_bundle(design: DesignObject, *, design_id: str, exported_at: str | None = None) -> dict[str, Any]
|
||||
BRIEF: The top-level export entrypoint — validate (strict, INV-E5-5), then assemble the versioned bundle: {schema_version, design_id, exported_at, ship:{native, sidecar}, resume}. Pure + deterministic (INV-E5-3); the caller supplies design_id + exported_at (no clock here). The resume half reuses serialize_design (the §6 state) so import round-trips. schema_version is NOT a caller param (heid-review fold) — it is ALWAYS EXPORT_SCHEMA_VERSION, so a bundle's version is never caller-forgeable; a future migration bumps the module constant. exported_at is an OPAQUE caller-supplied string (conventionally ISO-8601) — export does NOT parse or validate it (purity; the caller owns timestamp correctness).
|
||||
PRE: [PRE-E5-3 hard] design_id is a non-blank str (the durable library key) — a blank id RAISES ExportError("design_id", ...) (a bundle with no library key is unusable)
|
||||
POST: [POST-E5-8 exception] IF the design fails validate_exportable, the ExportError propagates and NO bundle is returned (INV-E5-5) — validation is BEFORE assembly
|
||||
POST: [POST-E5-9 return_value] returns {schema_version: EXPORT_SCHEMA_VERSION (always), design_id, exported_at, ship:{native: build_native_payload(design), sidecar: build_sidecar(design)}, resume: serialize_design(design)}; exported_at is the param verbatim (None → JSON null), unvalidated
|
||||
POST: [POST-E5-10 return_value] deterministic — identical (design, design_id, exported_at) → byte-identical json.dumps(result) given fixed dumps settings; the returned dict has a FIXED key insertion order (schema_version, design_id, exported_at, ship, resume), so a caller's json.dumps is stable (INV-E5-3); design unchanged (INV-E5-4)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] IF design_id is not a non-blank str: RAISE ExportError("design_id", "a non-blank design_id is required")
|
||||
2. [sequential] CALL validate_exportable(design) # strict gate BEFORE assembly (INV-E5-5) — raises propagate
|
||||
3. [sequential] native = build_native_payload(design); sidecar = build_sidecar(design); resume = serialize_design(design)
|
||||
4. [cleanup] RETURN {"schema_version": EXPORT_SCHEMA_VERSION, "design_id": design_id, "exported_at": exported_at, "ship": {"native": native, "sidecar": sidecar}, "resume": resume}
|
||||
TESTS:
|
||||
full_bundle [happy,tracer]: a complete design + design_id="d-1" → bundle has schema_version, design_id=="d-1", ship.native.agent_name, ship.native.role, ship.sidecar.first_message, resume.systemPrompt
|
||||
blank_design_id [adversarial]: design_id="" → ExportError("design_id") before any assembly
|
||||
invalid_design_no_bundle [adversarial]: a design with blank agent_name → ExportError propagates, no dict returned (POST-E5-8)
|
||||
deterministic [property]: build twice with the same (design, design_id, exported_at) → byte-identical json.dumps (INV-E5-3)
|
||||
exported_at_passthrough [trace]: exported_at="2026-07-13T00:00:00Z" → bundle.exported_at == it verbatim; None → null; a non-ISO "banana" is passed through unvalidated
|
||||
schema_version_not_a_param [trace]: build_export_bundle(..., schema_version="banana") raises TypeError — schema_version is fixed, never caller-supplied (heid-review fold)
|
||||
resume_roundtrips [property]: resume half == serialize_design(design) — every editable field present for import (incl. role)
|
||||
no_mutation [property]: design byte-identical before/after build (INV-E5-4)
|
||||
schema_version_present [trace]: bundle.schema_version == EXPORT_SCHEMA_VERSION (INV-E5-6)
|
||||
```
|
||||
|
||||
## Integration points
|
||||
|
||||
**R1 — relocate `serialize_design` out of `web.py` (agent-discretion refactor,
|
||||
no public-surface change).** The resume half reuses the §6 DesignObject
|
||||
serialization, but `serialize_design` currently lives in `soong_lab.web`
|
||||
(Starlette-coupled). Importing `web.py` into `export` would drag Starlette +
|
||||
the orchestrator into a pure module. Fix: **move `serialize_design` to
|
||||
`soong_lab.design`** (it is a pure `DesignObject → dict` mapping with no web
|
||||
dependency — it belongs with the model; add `role` to its output), and update the
|
||||
two consumers to import it from there. Blast radius (confirmed via grep):
|
||||
`web.py` (define → import; 3 call-sites unchanged), `tests/test_web.py:23`
|
||||
(import path), and the new `export` consumer. Behavior-identical;
|
||||
`web_surface.contract.md` gets a one-line note. No-backwards-compat: the old
|
||||
location is deleted, all refs updated in the same commit.
|
||||
|
||||
**Companion prerequisite slice — the `role` field + `set_role` tool (contracts
|
||||
updated in THIS pass).** Export emits `design.role`, so the field + its tool must
|
||||
exist. This slice (governed by the sibling contracts, amended alongside this one):
|
||||
- `soong_lab.design` (design_object.contract.md): a `role` field on
|
||||
`DesignObject` (default `"character"`); a `ROLE_CHOICES` enum canon +
|
||||
`validate_role`, held as an in-code module constant (mirroring the OCEAN
|
||||
adjective canon); `new_design()` sets `role="character"`; `serialize_design`
|
||||
adds `role`.
|
||||
- `soong_lab.bifrost` (bifrost_server.contract.md): a new `set_role(_ctx, role)`
|
||||
design tool (the 9th), `input_schema` an `enum` of the 4 values; the handler
|
||||
sets `design.role` after membership validation.
|
||||
The behavioral CODE for this slice lands in the TDD phase after
|
||||
`/heid-contract-review`, alongside `soong_lab.export`.
|
||||
|
||||
**Bifrost export tool (`_make_export` in bifrost/tools.py) — in scope.** Replace
|
||||
the deferred stub with: get the session's design from the store, then
|
||||
`build_export_bundle(design, design_id=<source>, exported_at=<stamp>)` and
|
||||
return the bundle (or a compact confirmation carrying it). The `design_id`
|
||||
source is the per-design-sessions seam (open_question C) — until it lands, the
|
||||
tool may pass the session_id as a provisional design_id (a documented
|
||||
placeholder, NOT a silent default). The tool handler is the impure boundary that
|
||||
stamps `exported_at` (clock) and supplies `design_id`, keeping
|
||||
`soong_lab.export` pure.
|
||||
|
||||
**`/api/export` endpoint + web/api.js shim — NOT in this contract (open_question
|
||||
D).** The browser 'Export Asset' button calls `api.export()`, today a
|
||||
client-side shim assembling a NON-native mock bundle. The real path is a thin
|
||||
`GET /api/export` on `web.py` → `build_export_bundle(orchestrator.get_design(),
|
||||
…)` → JSON → the modal's native/sidecar panes render it. That amends
|
||||
`web_surface.contract.md`; it is a follow-up slice in the same epic, specified
|
||||
here only so the seam is visible.
|
||||
|
||||
## Downstream epics (NOT this contract)
|
||||
|
||||
- **Library persistence** (decision #5) — writing the returned bundle to the
|
||||
server-local single-user JSON dir on corviduo-dev, keyed by `design_id`; the
|
||||
minimal recent-designs picker.
|
||||
- **Import** (decision #6) — reading a bundle: lenient on unknown metadata
|
||||
(INV-E5-6), STRICT re-validation of the export-critical fields (the import-side
|
||||
mirror of `validate_exportable`), reconstructing a DesignObject from the
|
||||
`resume` half.
|
||||
- **Per-design-sessions** (decision #2) — the `design_id` generator + the
|
||||
fresh-WT-session-per-open lifecycle (also caps the #355 accumulation).
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
module: "soong_lab.importer"
|
||||
purpose: "Reconstruct a DesignObject from an export bundle's `resume` half — the inverse of soong_lab.export. HYBRID validation (settled decision #6): LENIENT on unknown metadata (unknown top-level bundle keys, unknown keys inside resume, any schema_version), STRICT re-validation of the export-critical fields (OCEAN, role ∈ ROLE_CHOICES, agent_name, system_prompt length, tool-refs, psych/first_message length) surfaced ON IMPORT so a truncated or tampered bundle fails EARLY, not after more tuning. Pure + deterministic: no I/O, no persistence, no network, no clock (library read + the /api/import endpoint + the reopen lifecycle are separate downstream epics)."
|
||||
depends_on:
|
||||
- "soong_lab.design" # DesignObject/ToolRef/Portrait/GoalsFears + serialize_design (the round-trip partner) + ROLE_CHOICES/UNSET_ROLE + the shared field-bound constants
|
||||
- "soong_lab.export" # validate_exportable + ExportError — the strict export-critical gate is REUSED, not re-implemented (no-drift, INV-I-1)
|
||||
used_by:
|
||||
- "soong_lab.web" # FUTURE (import epic) — the POST /api/import endpoint parses the uploaded bundle JSON → import_bundle → seed a session (out of scope here, open_question D)
|
||||
- "soong_lab.soong" # FUTURE (per-design-sessions) — the reopen lifecycle imports a stored bundle, opens a fresh WT session, seeds the design-state summary (out of scope, decision #2)
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 170
|
||||
confidence: 0.83
|
||||
assumptions:
|
||||
- "Import consumes a Python dict (a Mapping), NOT raw bytes/JSON text. The JSON parse (json.loads at the /api/import endpoint or the library-read layer) happens UPSTREAM; import operates on the already-parsed structure, exactly as export RETURNS a Python dict the caller json.dumps'es. So the round-trip contract is over Python dicts: import_bundle(build_export_bundle(d, design_id=…)) == d, with no JSON layer in between (the JSON boundary — float/int coercion, encoding — is the endpoint/library epic's concern, INV-I-5 note)."
|
||||
- "The `resume` half is the ONLY source of truth on import (settled decision #4 — resume is 'what you reopen to keep tuning'). The `ship` half is a re-derivable deployment artifact; import IGNORES it. The reopen path re-exports from the reconstructed design, regenerating ship, so a ship↔resume mismatch is harmless — resume wins (INV-I-5). No cross-check in v1."
|
||||
- "The export-critical gate on import IS soong_lab.export.validate_exportable, imported and reused verbatim — NOT a re-implemented import-side validator. This guarantees import can never drift looser than export: the exact fields export refuses to ship are the exact fields import refuses to accept (INV-I-1). ExportError is caught and re-raised as BundleImportError so callers get an import-shaped error while the validation authority stays single-sourced."
|
||||
- "role is a first-class DesignObject field (operator ruling 2026-07-13), one of the curated ROLE_CHOICES, set by the E3a set_role tool. A resume carries `role`; import restores it and validate_role (via validate_exportable) rejects UNSET_ROLE ('') or any non-member — you cannot re-import an unclassified design, same as you cannot export one."
|
||||
- "composed_preview + disposition_phrase ride the resume so a reopen renders instantly (export.contract §resume). Import TRUSTS these verbatim (INV-I-8) — it does NOT call recompute. Re-derivation from ocean+prompt is the reopen lifecycle's concern (per-design-sessions), not import's. For a legitimately-exported bundle they are already self-consistent; a hand-tampered preview is design-time-only and is overwritten on the next set_ocean/edit_prompt recompute."
|
||||
open_questions:
|
||||
- "[A — RESOLVED, operator 2026-07-13] Module name is `soong_lab.importer` (operator chose it over `soong_lab.ingest`; keyword-safe agent-noun mirroring `export`). The export contract's forward-reference `used_by: soong_lab.import` — an unusable Python-keyword path (`import soong_lab.import` is a SyntaxError) — is corrected to `soong_lab.importer` in the same commit (done). SETTLED: the Constraints hard-require reflects the decision, not a still-open recommendation (heid-review Gróa#1 reconcile open-vs-locked)."
|
||||
- "[B — SETTLED, agent-discretion] Error type is `BundleImportError(field, detail)`, mirroring export's `ExportError(field, detail)`. Deliberately NOT `ImportError` — that shadows the Python builtin, a foot-gun for an import module. The Constraints hard-require reflects the decision, not a still-open recommendation (heid-review Gróa#1)."
|
||||
- "[C — presence vs default, agent-discretion, notable] For the export-critical resume keys (agentName, role, systemPrompt, ocean) a MISSING key is a hard reject (INV-I-7), NOT a silent default. Rationale: a missing `ocean` would default to a VALID neutral OCEAN and pass validate_exportable — silently masking trait loss from a truncated bundle. Rejecting on absence fails loudly + consistently (the 'fail early on import' the decision wants). Rejected alternative: reconstruct-with-defaults-then-validate (inconsistent — ocean slips through while name/role are caught by validation)."
|
||||
- "[D — scope] This contract = the PURE reconstruction (deserialize_design) + the strict entrypoint (import_bundle) + BundleImportError. The POST /api/import endpoint (amends web_surface.contract.md), the reopen Bifrost tool / session-open wiring (per-design-sessions), and reading a bundle off the library JSON dir (library epic) are ALL downstream — specified here only as the integration seam so it is visible. Nothing in this contract does I/O."
|
||||
- "[E — schema_version tolerance] `schema_version` is read at the bundle TOP LEVEL only (where export stamps EXPORT_SCHEMA_VERSION) — import does not look for it inside `resume`. v1 tolerates ANY top-level value (present or absent) and reads the v1 resume key set regardless (INV-I-2, INV-E5-6 add-only-friendly). 'Tolerate any version' means forward-compat with ADD-ONLY future changes — NOT a promise of semantic compatibility with a bundle whose meaning changed (heid-review Gróa#5/Hulda). A future policy — reject an incompatible MAJOR version, or dispatch to a version-specific deserializer — is deferred to when a second schema version actually exists. v1 has exactly one shape."
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Import is the SECOND half of the operator-accepted (2026-07-13) export/import/library
|
||||
design — the block that expands the locked single-agent frame into a multi-pass
|
||||
tuning loop (design → export → **reopen → tune** → keep a library). Where
|
||||
`soong_lab.export` turns a finished `DesignObject` into a versioned bundle, this
|
||||
module does the inverse: it takes a bundle's **`resume`** half and reconstructs an
|
||||
editable `DesignObject` you can drop back into a session and keep tuning.
|
||||
|
||||
The reconstruction is **HYBRID-validated** (settled decision #6 — the load-bearing
|
||||
import decision):
|
||||
|
||||
- **LENIENT on unknown metadata.** Unknown top-level bundle keys, unknown keys
|
||||
inside `resume`, and any `schema_version` (present or absent) are tolerated —
|
||||
import reads only the keys it knows (INV-I-2, mirroring the export bundle's
|
||||
add-only-friendly `INV-E5-6`). A bundle from a future soong-lab that added
|
||||
fields still imports.
|
||||
- **STRICT on the export-critical fields.** OCEAN, `role`, `agent_name`,
|
||||
`system_prompt`, tool-refs, and the psych/first_message length are re-validated
|
||||
**on import** by REUSING `soong_lab.export.validate_exportable` verbatim (INV-I-1)
|
||||
— so the exact fields export refuses to *ship* are the exact fields import
|
||||
refuses to *accept*, and import can never drift looser than export. A bad field
|
||||
is surfaced immediately (fail EARLY), not after the operator has tuned for
|
||||
another ten minutes against a design that was never valid.
|
||||
|
||||
**The round-trip is the load-bearing contract between the two modules** (INV-I-3):
|
||||
for any exportable design `d`,
|
||||
|
||||
```
|
||||
import_bundle(build_export_bundle(d, design_id="…")) == d
|
||||
deserialize_design(serialize_design(d)) == d
|
||||
```
|
||||
|
||||
This is what makes "export then reopen" lossless. `serialize_design`
|
||||
(relocated to `soong_lab.design` in the export pass, R1) is the forward half;
|
||||
`deserialize_design` here is its exact inverse.
|
||||
|
||||
**Import reads the `resume` half ONLY.** The `ship` half (native `agents.define`
|
||||
payload + sidecar) is a re-derivable deployment artifact — the reopen path
|
||||
re-exports from the reconstructed design, regenerating `ship`. So import ignores
|
||||
`ship` entirely (INV-I-5); a tampered `ship` that disagrees with `resume` is
|
||||
harmless (resume wins, ship regenerated). No cross-check in v1.
|
||||
|
||||
**What this contract does NOT do** (open_question D): no file read, no HTTP, no
|
||||
session seeding. The `POST /api/import` endpoint, the reopen Bifrost tool /
|
||||
session-open wiring, and reading a bundle off the library JSON dir are downstream
|
||||
epics. This module is the pure, side-effect-free reconstruction core those epics
|
||||
build on — exactly as `soong_lab.export` is the pure builder its endpoint wraps.
|
||||
|
||||
## Data flow
|
||||
|
||||
**In:** a bundle `dict` (a Mapping — already `json.loads`'d upstream). **Out:** a
|
||||
validated, ready-to-reopen `DesignObject`. **On disk / network:** NONE. Import is
|
||||
pure: the structural gate (bundle/resume/ocean are dicts, tools a list-of-dicts),
|
||||
the tolerant reconstruction, and the strict `validate_exportable` re-check are all
|
||||
in-memory; no clock, no randomness, no file, no HTTP.
|
||||
|
||||
### The resume key set consumed (v1.0)
|
||||
|
||||
Import reconstructs from exactly the `serialize_design` output (the §6 camelCase
|
||||
state — pinned in export.contract §resume, restated here so this contract is
|
||||
self-contained):
|
||||
|
||||
```
|
||||
resume = {
|
||||
"agentName": <str>, # EXPORT-CRITICAL — presence required (INV-I-7)
|
||||
"role": <str ∈ ROLE_CHOICES>, # EXPORT-CRITICAL — presence required; validate_role gates value
|
||||
"systemPrompt": <str>, # EXPORT-CRITICAL — presence required; the AUTHORED block
|
||||
"ocean": {O,C,E,A,N}, # EXPORT-CRITICAL — presence required; validate_ocean gates value
|
||||
"tools": [{id,name,description}], # optional (absent → []); each ref value-gated by validate_exportable
|
||||
"composedPreview": <str>, # design-time-derived — TRUSTED verbatim, re-derivable (INV-I-8)
|
||||
"dispositionPhrase": <str>, # design-time-derived — TRUSTED verbatim, re-derivable (INV-I-8)
|
||||
"firstMessage": <str>, # optional prose — length-gated only (blank OK)
|
||||
"psychProfile": <str>, # optional prose — length-gated only (blank OK)
|
||||
"portrait": {status, styleMode, imageUrl?, jobId?}, # optional (absent → default Portrait())
|
||||
"goalsFears": {goals,fears} | null # optional (absent/null → None)
|
||||
}
|
||||
```
|
||||
|
||||
**Critical vs optional (the presence rule, INV-I-7).** Read the two functions as a
|
||||
boundary (all three review arms flagged that the prose blurs it): the INNER
|
||||
`deserialize_design` is total and DEFAULTS every missing key (a missing `ocean` →
|
||||
neutral) — it NEVER rejects; the OUTER, public `import_bundle` PRESENCE-CHECKS the
|
||||
export-critical keys and REJECTS a missing one BEFORE it ever calls deserialize. So
|
||||
"import defaults a missing ocean to neutral" is FALSE for the public path
|
||||
(`import_bundle` rejects it, INV-I-7) — the neutral default lives ONLY inside the
|
||||
never-directly-shipped inner function (heid-review 3/3: POST-I-3 vs INV-I-7 read as
|
||||
contradictory in isolation). `agentName`, `role`, `systemPrompt`, `ocean` are
|
||||
**presence-required** — a missing one is a truncated / corrupt bundle and raises
|
||||
`BundleImportError`, because defaulting them would either be caught inconsistently
|
||||
(name/role/prompt default to values `validate_exportable` rejects) or silently
|
||||
masked (`ocean` defaults to a VALID neutral OCEAN — silent trait loss). Every other
|
||||
key is optional and defaults to the `DesignObject` default when absent. `tools`/`portrait`/`goalsFears`, when present, must be well-formed SHAPES —
|
||||
`tools` a list-of-objects, `ocean`/`portrait` an object, `goalsFears` null or an
|
||||
object whose present `goals`/`fears` are lists — structural mismatches raise a clean
|
||||
`BundleImportError`, never a leaked builtin `TypeError`/`ValueError` (INV-I-6
|
||||
robustness). These SHAPE gates all exist to prevent SILENT DATA LOSS (heid-bug-hunt
|
||||
Gróa#1/#2: a malformed portrait/goalsFears would otherwise coerce to a default in
|
||||
`deserialize_design` and slip PAST `validate_exportable`, since both are
|
||||
non-export-critical — the same loss the `tools` gate was added to close). Import does
|
||||
NOT validate their VALUE contents — portrait `status`/`styleMode` enums or goals/fears
|
||||
item contents are not export-critical (E4 / the UI own portrait validity); those
|
||||
round-trip as-is (heid-review Gróa#6).
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-I-1** [hard]: The strict export-critical re-validation IS
|
||||
`soong_lab.export.validate_exportable`, imported and reused verbatim — NO
|
||||
re-implementation, no parallel import-side validator. Import therefore can NEVER
|
||||
be looser than export: OCEAN (`validate_ocean`), role (`validate_role`, ∈
|
||||
ROLE_CHOICES), `agent_name` (non-blank, ≤`AGENT_NAME_MAX`), `system_prompt`
|
||||
(non-blank, ≤`SYSTEM_PROMPT_MAX`), every tool-ref (id/name non-blank + bounded,
|
||||
description bounded), and the psych/first_message LENGTH are all gated by the
|
||||
same code export uses. An `ExportError` from that gate is caught and re-raised
|
||||
as `BundleImportError(same field, same detail)` — same field granularity,
|
||||
import-shaped type.
|
||||
- **INV-I-2** [hard]: LENIENT on unknown metadata (settled decision #6, mirrors
|
||||
INV-E5-6). Unknown top-level bundle keys, unknown keys inside `resume`, and any
|
||||
`schema_version` value (present or absent) are tolerated — import reads only the
|
||||
keys it knows and ignores the rest. A future-schema bundle that ADDED fields
|
||||
still imports.
|
||||
- **INV-I-3** [hard]: ROUND-TRIP — for any `DesignObject` `d` that passes
|
||||
`validate_exportable`, `deserialize_design(serialize_design(d))` reconstructs an
|
||||
EQUAL `DesignObject` (dataclass `==` over every field), and
|
||||
`import_bundle(build_export_bundle(d, design_id=…))` `== d`. This is the lossless
|
||||
export↔import contract. (Equality is over Python structures; the JSON encode/decode
|
||||
boundary is the endpoint/library epic's concern, not this module's.)
|
||||
- **INV-I-4** [hard]: NO-ALIAS — the reconstructed `DesignObject` holds COPIES of
|
||||
every mutable sub-structure (the ocean dict, the tools list, the goals/fears
|
||||
lists) drawn from the bundle, never aliases. A later mutation of the input bundle
|
||||
cannot change an already-imported design (the mirror of export's INV-E5-4). The
|
||||
copies are SHALLOW (the CONTAINERS) — sufficient because legit export values are
|
||||
scalars (strings/floats), and a hostile NESTED mutable (a list-valued tool id, a
|
||||
dict-valued goal) is rejected by `validate_exportable` before any successful import
|
||||
(heid-bug-hunt Gróa#5/Hulda#1: the invariant's letter holds; deep-copy is deferred
|
||||
unless nested mutables ever become in-contract).
|
||||
- **INV-I-5** [hard]: Import reads the `resume` half and NOWHERE else — `ship`
|
||||
(native + sidecar) is ignored (it is re-derivable; the reopen path re-exports).
|
||||
No ship↔resume consistency check in v1; on any disagreement, resume is
|
||||
authoritative.
|
||||
- **INV-I-6** [hard]: `deserialize_design` is TOTAL — it never raises on any input
|
||||
Mapping. Hostile shapes (a string `ocean`, an int `tools`, a list `portrait`, a
|
||||
string `goalsFears`, or a dict `goalsFears` whose `goals`/`fears` is a non-list)
|
||||
are coerced/defaulted, not crashed — in particular EVERY `list(...)`/`dict(...)`
|
||||
coercion is type-GUARDED first: a non-list `goals` becomes `[]` (never
|
||||
`list(7)`→TypeError nor `list("ab")`→`["a","b"]`), a non-dict `ocean` is held
|
||||
verbatim (never `dict("nope")`→ValueError). ALL rejection happens in
|
||||
`import_bundle` (its structural gate + `validate_exportable`). Non-export-critical
|
||||
fields that are missing or mistyped default to the `DesignObject` default;
|
||||
export-critical VALUES are held AS-READ (no silent type-coercion) so
|
||||
`validate_exportable` judges them — with ONE structural exception: `import_bundle`
|
||||
pre-checks that `ocean` is a dict (so `deserialize_design`'s `dict()` copy is
|
||||
safe), so `ocean` has a structural judge (`import_bundle`) AND a value judge
|
||||
(`validate_ocean`), while `agent_name`/`role`/`system_prompt` are judged by value
|
||||
alone — "single judge" is exact for those three, not for `ocean` (heid-review
|
||||
Gróa#2/#4, Hulda, Regin#3). (Mirrors `recompute`'s hostile-input tolerance in derive.py.)
|
||||
- **INV-I-7** [hard]: PRESENCE — `import_bundle` requires the export-critical
|
||||
resume keys `agentName`, `role`, `systemPrompt`, `ocean` to be PRESENT; a missing
|
||||
one raises `BundleImportError(f"resume.{key}", …)` (a truncated bundle fails
|
||||
loudly, not by silently defaulting — especially `ocean`, whose neutral default
|
||||
would mask trait loss). `tools` absent → `[]` (an empty toolset is a valid
|
||||
design). This is the explicit-over-implicit choice: reject a missing critical key
|
||||
rather than accept a silently-defaulted one.
|
||||
- **INV-I-8** [hard]: Import does NOT re-derive `composed_preview` /
|
||||
`disposition_phrase` — it TRUSTS the resume values verbatim (they ride the resume
|
||||
for instant reopen-render, per export.contract). `recompute` is the reopen
|
||||
lifecycle's concern (per-design-sessions), not import's. For a legit bundle these
|
||||
are already self-consistent; a tampered preview is design-time-only and is
|
||||
overwritten on the next `set_ocean`/`edit_prompt`. Import makes NO consistency
|
||||
guarantee between the trusted preview and `ocean`+`system_prompt`: for a
|
||||
hand-edited resume the two may diverge until the first recompute self-heals them —
|
||||
round-trip equality (INV-I-3) is "== the DesignObject the bundle encodes," NOT
|
||||
"the preview matches a fresh recompute" (heid-review Gróa#8).
|
||||
|
||||
## Constraints
|
||||
|
||||
- **[correctness]** The export-critical re-validation reuses
|
||||
`soong_lab.export.validate_exportable` (INV-I-1) — import declares no length
|
||||
numbers, no role list, no OCEAN shape of its own. The shared field-bound
|
||||
constants + `ROLE_CHOICES` live in `soong_lab.design`; the strict gate lives in
|
||||
`soong_lab.export`; import imports both. Zero duplicated validation logic → zero
|
||||
drift.
|
||||
- **[style]** Pure — NO I/O (no clock, no file, no HTTP, no randomness). Import is
|
||||
a total function of its input Mapping.
|
||||
- **[explicit]** `BundleImportError` does NOT shadow the builtin `ImportError`
|
||||
(open_question B). The module is `soong_lab.importer`, NOT `soong_lab.import` —
|
||||
`import` is a Python keyword and unusable as a module path (open_question A).
|
||||
- **[robustness]** `deserialize_design` guards types BEFORE any `dict()` /
|
||||
iteration: a non-dict `ocean` is held as-read (never `dict("nope")`, which raises
|
||||
a raw `ValueError`); a non-list `tools` yields `[]`; a non-dict `portrait` /
|
||||
`goalsFears` falls back to the default (`import_bundle`'s structural gates reject a
|
||||
present-but-malformed portrait/goalsFears BEFORE this, so the default-fallback is
|
||||
reachable only for a MISSING field). This keeps every rejection path flowing
|
||||
through `BundleImportError` — a caller never sees a leaked builtin exception.
|
||||
- **[robustness]** The "no builtin ever leaks from the public entrypoint" guarantee
|
||||
for hostile export-critical SCALAR types (a non-str `agent_name`/`role`/
|
||||
`system_prompt`/`psych_profile`/`first_message`, or a `None`) is provided JOINTLY by
|
||||
(a) holding them as-read + (b) `validate_exportable` being TOTAL over hostile scalar
|
||||
types — every check `isinstance`-guards BEFORE any `.strip()`/`len()`, and the `or`
|
||||
short-circuits, so a hostile scalar yields a clean `ExportError` (→ `BundleImportError`),
|
||||
never a raw `TypeError`/`AttributeError`. This is an EXPLICIT cross-module coupling
|
||||
(`soong_lab.export` guarantees the totality): import does NOT blanket-catch
|
||||
non-`ExportError` (that would mask real programming errors); the coupling is instead
|
||||
PINNED by a hostile-scalar test through `import_bundle` (heid-bug-hunt 3/3 —
|
||||
Gróa#3/Hulda#2/Regin#1). If `validate_exportable` ever did an unguarded string op, that
|
||||
test fails.
|
||||
- **[explicit]** `import_bundle` is the PUBLIC entrypoint that runs the full gate
|
||||
(structure → presence → reconstruct → `validate_exportable`). `deserialize_design`
|
||||
is exposed for the round-trip test + direct reuse but PERFORMS NO validation
|
||||
(PRE-I-1) — a direct caller that skips `import_bundle` owns re-validation (the
|
||||
mirror of export's build_native_payload/build_sidecar assuming a validated design).
|
||||
- **[explicit]** Two-LAYER error-field convention (heid-review Regin#6): a
|
||||
STRUCTURAL / PRESENCE rejection raised BY `import_bundle` names the offending
|
||||
BUNDLE key in camelCase with a `resume.` prefix (`resume.agentName` missing,
|
||||
`resume.ocean` not-an-object) — it reports the bundle's JSON shape. A VALUE
|
||||
rejection from the reused `validate_exportable` names the `DesignObject` field in
|
||||
snake_case with no prefix (`agent_name` blank, `persona.ocean` out of range) — it
|
||||
reports the design's validity. Same logical field, two deliberate `.field` forms
|
||||
encoding WHICH LAYER failed (bundle-structure vs design-value); a caller switching
|
||||
on `err.field` MUST handle both, and `err.detail` disambiguates. Tests assert on
|
||||
`.field` (pinned); `.detail` wording is human-facing and NOT pinned (heid-review
|
||||
Hulda) — intentional, not drift.
|
||||
|
||||
```contract
|
||||
FN deserialize_design(resume: Mapping[str, Any]) -> DesignObject
|
||||
BRIEF: The pure, TOTAL inverse of serialize_design — reconstruct a DesignObject from the §6 camelCase resume half. Reads each known key with a type-guard; missing/mistyped NON-export-critical fields default to the DesignObject default; export-critical fields are held AS-READ (no coercion) for validate_exportable to judge later; unknown keys are ignored (INV-I-2). Copies every mutable sub-structure (INV-I-4). NEVER raises (INV-I-6) — it performs NO validation (that is import_bundle's job). deserialize_design(serialize_design(d)) == d for any exportable d (INV-I-3).
|
||||
PRE: [PRE-I-1 hard] resume is a Mapping (import_bundle guarantees a dict before calling; a direct caller passes any Mapping — a non-Mapping is a caller error, but the function still must not crash on a Mapping of hostile VALUES)
|
||||
POST: [POST-I-1 return_value] returns a DesignObject whose fields map 1:1 from the camelCase keys: agentName→agent_name, role→role, systemPrompt→system_prompt, composedPreview→composed_preview, firstMessage→first_message, ocean→ocean (COPY), dispositionPhrase→disposition_phrase, psychProfile→psych_profile, tools→[ToolRef,…] (COPY of the list, each ref rebuilt), portrait→Portrait(...), goalsFears→GoalsFears(...) | None
|
||||
POST: [POST-I-2 return_value] ocean, tools, goals, and fears are COPIES of the resume values — mutating resume after the call never changes the returned design (INV-I-4)
|
||||
POST: [POST-I-3 return_value] export-critical fields (agentName, role, systemPrompt, ocean) are held AS-READ (missing → the DesignObject default; present-but-mistyped → the value verbatim, so validate_exportable is the single judge); design-time-derived composedPreview/dispositionPhrase coerce a non-str to "" (re-derivable, keep the object clean); firstMessage/psychProfile are held as-read (validate_exportable length-gates them). ocean is copied IFF it is a dict, else held verbatim (NEVER dict("nope"))
|
||||
POST: [POST-I-4 state_change] performs NO validation and NEVER raises on a Mapping input (INV-I-6)
|
||||
STEPS:
|
||||
1. [setup] agent_name = resume.get("agentName", ""); role = resume.get("role", UNSET_ROLE); system_prompt = resume.get("systemPrompt", "") # export-critical — held as-read, no coercion
|
||||
2. [sequential] composed_preview = resume["composedPreview"] if it is a str else ""; disposition_phrase = resume["dispositionPhrase"] if it is a str else "" # design-time-derived, re-derivable → coerce clean
|
||||
3. [sequential] first_message = resume.get("firstMessage", ""); psych_profile = resume.get("psychProfile", "") # optional prose — held as-read, length-gated by validate_exportable
|
||||
4. [branch] raw_ocean = resume.get("ocean"); ocean = dict(raw_ocean) if isinstance(raw_ocean, dict) else (raw_ocean if raw_ocean is not None else _neutral_ocean()) # COPY iff dict; else held verbatim for validate_ocean to reject (guard BEFORE dict(), INV-I-6 robustness)
|
||||
5. [loop] raw_tools = resume.get("tools"); tools = [(ToolRef(id=t.get("id",""), name=t.get("name",""), description=t.get("description","")) if isinstance(t, dict) else ToolRef(id="", name="", description="")) for t in raw_tools] IF isinstance(raw_tools, list) else [] # non-list → []; a non-dict item maps to a BLANK ToolRef (NOT skipped) so a direct caller who re-validates fails loud on the blank id rather than silently losing a tool (heid-review Gróa#7); import_bundle structurally rejects both cases upstream
|
||||
6. [branch] raw_portrait = resume.get("portrait"); portrait = Portrait(status=raw_portrait.get("status","none"), style_mode=raw_portrait.get("styleMode","cartoon"), image_url=raw_portrait.get("imageUrl"), job_id=raw_portrait.get("jobId")) IF isinstance(raw_portrait, dict) else Portrait() # use raw_portrait (heid-review Regin#2 — the `rp` working-name was unbound); imageUrl/jobId absent → None (round-trips serialize's None-omission)
|
||||
7. [branch] raw_gf = resume.get("goalsFears"); IF isinstance(raw_gf, dict): g = raw_gf.get("goals"); f = raw_gf.get("fears"); goals_fears = GoalsFears(goals=(list(g) if isinstance(g, list) else []), fears=(list(f) if isinstance(f, list) else [])) ELSE: goals_fears = None # use raw_gf (heid-review Regin#2 — `gf` was unbound); a non-LIST goals/fears → [], NEVER list(7)→TypeError (totality, INV-I-6) and NEVER list("ab")→["a","b"] (silent char-split, heid-review Gróa#2/Hulda); null/absent → None; COPY the lists (INV-I-4)
|
||||
8. [cleanup] RETURN DesignObject(agent_name, role, system_prompt, composed_preview, ocean, disposition_phrase, tools, portrait, first_message, psych_profile, goals_fears)
|
||||
TESTS:
|
||||
roundtrip_full [property,tracer]: a fully-populated exportable design d (name, role, prompt, non-neutral ocean, 2 tools, ready portrait w/ url+job, first_message, psych, goalsFears) → deserialize_design(serialize_design(d)) == d
|
||||
roundtrip_minimal [property]: minimal design (name+prompt+role, neutral ocean, no tools/portrait-url/gf) → round-trips == d
|
||||
copies_not_aliases [property]: deserialize, then mutate resume["ocean"]["O"] and append to resume["tools"] → the returned design's ocean + tools are unchanged (INV-I-4)
|
||||
total_on_hostile [property]: deserialize_design({"ocean":"nope","tools":7,"portrait":[],"goalsFears":"x","agentName":123}) does NOT raise; returns a DesignObject (ocean=="nope" held verbatim, tools==[], portrait==Portrait(), goals_fears is None, agent_name==123) — INV-I-6
|
||||
total_on_hostile_goalsfears [property]: deserialize_design({"goalsFears":{"goals":7,"fears":"abc"}}) does NOT raise (the totality-breaking case heid-review Gróa#2/Hulda caught) → goals_fears==GoalsFears([],[]) (non-list goals→[] not list(7)→TypeError; non-list fears→[] not list("abc")→["a","b","c"]) — INV-I-6
|
||||
tools_nondict_item_blank [boundary]: deserialize_design({"tools":[{"id":"a","name":"n"},7]}) → tools==[ToolRef("a","n",""), ToolRef("","","")] — the non-dict item maps to a BLANK ToolRef, NOT skipped (heid-review Gróa#7), so a direct caller re-validating fails loud on the blank id
|
||||
empty_resume [boundary]: deserialize_design({}) → DesignObject() all-defaults (role==UNSET_ROLE, neutral ocean, no tools) — total, no raise
|
||||
portrait_none_fields [boundary]: resume.portrait without imageUrl/jobId → Portrait.image_url is None, Portrait.job_id is None
|
||||
goalsfears_null [boundary]: resume.goalsFears is None → design.goals_fears is None; goalsFears={} → GoalsFears([],[])
|
||||
roundtrip_goalsfears_empty [property]: a design with goals_fears==GoalsFears([],[]) → deserialize_design(serialize_design(d)).goals_fears == GoalsFears([],[]) (empty, NOT None) — locks the null-vs-{} distinction (heid-review Regin#4)
|
||||
preview_trusted [trace]: resume.composedPreview="CUSTOM", dispositionPhrase="odd" → design.composed_preview=="CUSTOM", disposition_phrase=="odd" (NOT re-derived, INV-I-8)
|
||||
unknown_keys_ignored [trace]: resume with an extra "futureField":123 → deserialize ignores it, no crash (INV-I-2)
|
||||
```
|
||||
|
||||
```contract
|
||||
FN import_bundle(bundle: Mapping[str, Any]) -> DesignObject
|
||||
BRIEF: The public entrypoint — the mirror of build_export_bundle. Runs the full gate: STRUCTURE (bundle/resume are dicts, ocean is a dict, tools is a list-of-dicts) → PRESENCE (the export-critical resume keys, INV-I-7) → reconstruct (deserialize_design) → STRICT re-validate (validate_exportable, reused verbatim, INV-I-1). LENIENT on unknown metadata + any schema_version (INV-I-2). Reads ONLY resume; ignores ship (INV-I-5). Returns a DesignObject that PASSES validate_exportable — ready to reopen. Every rejection is a BundleImportError(field, detail); no builtin exception ever leaks.
|
||||
PRE: [PRE-I-2 hard] bundle is a Mapping (a non-Mapping raises BundleImportError("bundle", …), never a bare TypeError)
|
||||
POST: [POST-I-5 exception] raises BundleImportError(field, detail) — with NO DesignObject returned — if ANY: bundle is not a Mapping ("bundle"); bundle["resume"] is missing or not a Mapping ("resume"); any of agentName/role/systemPrompt/ocean is absent from resume ("resume.<key>", INV-I-7); resume["ocean"] is present-but-not-a-dict ("resume.ocean"); resume["tools"] is present-but-not-a-list or contains a non-dict item ("resume.tools"); resume["portrait"] is present-but-not-a-dict ("resume.portrait"); resume["goalsFears"] is present-but-not (null OR a dict whose present goals/fears are lists) ("resume.goalsFears"); OR the reconstructed design fails validate_exportable (the ExportError's field+detail, re-raised as BundleImportError — INV-I-1)
|
||||
POST: [POST-I-6 return_value] on success returns a DesignObject that PASSES validate_exportable (name/role/prompt/ocean/tools/psych/first_message all valid), holds COPIES of every mutable sub-structure (INV-I-4), with composed_preview/disposition_phrase trusted from resume (INV-I-8); ship is never read (INV-I-5)
|
||||
POST: [POST-I-7 return_value] LENIENT — unknown top-level bundle keys, unknown resume keys, and any schema_version (present, absent, or unrecognized) do not affect the result (INV-I-2)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] IF bundle is not a Mapping: RAISE BundleImportError("bundle", "bundle must be an object")
|
||||
2. [sequential] resume = bundle.get("resume"); IF resume is not a Mapping: RAISE BundleImportError("resume", "the bundle has no readable 'resume' half") # ship + schema_version read leniently — schema_version is NOT gated (INV-I-2, open_question E)
|
||||
3. [loop] FOR key IN ("agentName", "role", "systemPrompt", "ocean"): IF key not in resume: RAISE BundleImportError(f"resume.{key}", "required export-critical field is missing") # presence, INV-I-7
|
||||
4. [branch] IF resume["ocean"] is not a dict: RAISE BundleImportError("resume.ocean", "ocean must be an object") # structural — keeps deserialize's dict() safe + gives a clean field error
|
||||
5. [branch] IF "tools" in resume AND (resume["tools"] is not a list OR any item is not a dict): RAISE BundleImportError("resume.tools", "tools must be a list of objects") # structural — prevents silent tool loss
|
||||
5b. [branch] IF "portrait" in resume AND resume["portrait"] is not a dict: RAISE BundleImportError("resume.portrait", "portrait must be an object") # SAME no-silent-loss gate as tools (heid-bug-hunt Gróa#2) — else a non-dict portrait silently coerces to Portrait() (wiping status/imageUrl/jobId) and slips past validate_exportable (portrait is non-export-critical)
|
||||
5c. [branch] IF "goalsFears" in resume AND resume["goalsFears"] is not None: IF it is not a dict RAISE BundleImportError("resume.goalsFears", "must be an object or null"); ELSE FOR k IN (goals, fears): IF k in gf AND gf[k] is not a list: RAISE BundleImportError("resume.goalsFears", f"{k} must be a list") # no-silent-loss gate (heid-bug-hunt Gróa#1) — else a non-list goals/fears silently coerces to [] (dropping the operator's data) and slips past validate_exportable (goals_fears is non-export-critical)
|
||||
6. [sequential] design = deserialize_design(resume) # total; the structural gates above guarantee a plausible shape
|
||||
7. [sequential, flexibility=prescriptive] TRY validate_exportable(design) EXCEPT ExportError AS exc: RAISE BundleImportError(exc.field, exc.detail) FROM exc # the STRICT export-critical gate, REUSED (INV-I-1) — same field granularity, import-shaped type
|
||||
8. [cleanup] RETURN design
|
||||
TESTS:
|
||||
roundtrip_full [property,tracer]: import_bundle(build_export_bundle(d, design_id="d-1")) == d for a fully-populated exportable d (INV-I-3)
|
||||
roundtrip_minimal [property]: import_bundle(build_export_bundle(d_minimal, design_id="d-1")) == d_minimal (a minimal exportable design through the FULL gate — symmetry with deserialize_design, heid-code-review Regin#4)
|
||||
roundtrip_after_export [property]: build a bundle, import it, re-export the result → the two bundles' resume halves are equal (idempotent reopen)
|
||||
lenient_unknown_metadata [happy]: a valid bundle + extra top-level "x":1, extra resume "futureField":2, schema_version="99.0" → imports fine; result == the same design without the extras (INV-I-2)
|
||||
missing_resume [adversarial]: bundle == {"schema_version":"1.0","ship":{…}} (no resume) → BundleImportError("resume")
|
||||
bundle_not_mapping [adversarial]: import_bundle("not a bundle") → BundleImportError("bundle") — no bare TypeError
|
||||
missing_ocean [adversarial]: resume without "ocean" → BundleImportError("resume.ocean") via presence (INV-I-7) — NOT silently neutral
|
||||
missing_role [adversarial]: resume without "role" → BundleImportError("resume.role")
|
||||
missing_name [adversarial]: resume without "agentName" → BundleImportError("resume.agentName")
|
||||
missing_systemprompt [adversarial]: resume without "systemPrompt" → BundleImportError("resume.systemPrompt") — the 4th critical key, completes the presence coverage (heid-code-review Hulda/Regin)
|
||||
non_dict_ocean [adversarial]: resume.ocean="nope" (present) → BundleImportError("resume.ocean", must be object) — clean error, never a raw ValueError from dict()
|
||||
non_list_tools [adversarial]: resume.tools={} → BundleImportError("resume.tools"); resume.tools=[7] (non-dict item) → BundleImportError("resume.tools")
|
||||
non_dict_portrait [adversarial]: resume.portrait=[] / "x" / 7 → BundleImportError("resume.portrait") — the no-silent-loss gate (heid-bug-hunt Gróa#2)
|
||||
malformed_goalsfears [adversarial]: resume.goalsFears={"goals":["survive"],"fears":"exposure"} (fears non-list) → BundleImportError("resume.goalsFears") — the headline silent-loss case; goalsFears=7 → BundleImportError; goalsFears=None and goalsFears={} → ok (round-trip shapes) (heid-bug-hunt Gróa#1)
|
||||
hostile_scalars_no_builtin_leak [adversarial]: resume.agentName=123 / systemPrompt=null / psychProfile=0 → each a clean BundleImportError (agent_name / system_prompt / psych_profile), NEVER a raw builtin — pins the validate_exportable-totality coupling (heid-bug-hunt 3/3)
|
||||
blank_name_rejected [adversarial]: resume.agentName=" " → BundleImportError("agent_name") via validate_exportable (whitespace stricter, INV-I-1)
|
||||
bad_role_rejected [adversarial]: resume.role="wizard" → BundleImportError("role") via validate_role
|
||||
unset_role_rejected [adversarial]: resume.role="" → BundleImportError("role") — an unclassified design is not importable, same as not exportable
|
||||
bad_ocean_value [adversarial]: resume.ocean.O=2.0 → BundleImportError("persona.ocean") via validate_ocean
|
||||
bad_tool_ref [adversarial]: resume.tools=[{"id":"","name":"x"}] → BundleImportError("tools[0]") via validate_exportable
|
||||
prompt_too_long [boundary]: resume.systemPrompt of len SYSTEM_PROMPT_MAX+1 → BundleImportError("system_prompt"); len SYSTEM_PROMPT_MAX → ok
|
||||
psych_too_long [boundary]: resume.psychProfile of len PSYCH_PROFILE_MAX+1 → BundleImportError("psych_profile"); blank → ok
|
||||
first_message_too_long [boundary]: resume.firstMessage of len FIRST_MESSAGE_MAX+1 → BundleImportError("first_message"); blank → ok (same length-gate as psych, via the reused validate_exportable — heid-code-review Hulda/Regin)
|
||||
ship_ignored [trace]: a valid bundle whose ship.native.agent_name disagrees with resume.agentName → the imported design uses resume.agentName; ship is not read (INV-I-5)
|
||||
no_alias [property]: import, then mutate the source bundle's resume["ocean"] + resume["tools"] + resume["goalsFears"]["goals"]/["fears"] → the returned design is unchanged, incl. the goals/fears lists (INV-I-4, heid-code-review Hulda)
|
||||
error_is_not_builtin [trace]: BundleImportError is not the builtin ImportError (isinstance check) — the module never shadows it (open_question B)
|
||||
error_field_layer_convention [trace]: a MISSING agentName → BundleImportError field "resume.agentName" (structural/camelCase); a BLANK agentName → BundleImportError field "agent_name" (value/snake_case via validate_exportable) — the intentional two-layer convention (heid-review Regin#6)
|
||||
```
|
||||
|
||||
## Integration points
|
||||
|
||||
**Reuse of `soong_lab.export` (the no-drift anchor).** Import imports
|
||||
`validate_exportable` + `ExportError` from `soong_lab.export`. This is the single
|
||||
most important structural decision in the contract: the strict export-critical
|
||||
gate is authored ONCE (in export) and reused on import, so the two directions can
|
||||
never diverge. Import adds no length numbers, no role membership list, no OCEAN
|
||||
shape — those all live upstream (`soong_lab.design` constants + `soong_lab.export`
|
||||
gate). The dependency direction is clean: `importer → export → design`, all three
|
||||
pure.
|
||||
|
||||
**`serialize_design` is the round-trip partner (no code change).** The forward
|
||||
half already lives in `soong_lab.design` (relocated there in the export pass, R1).
|
||||
This contract adds no change to it; `deserialize_design` is written to be its exact
|
||||
inverse, and the round-trip tests pin the pair together. If a future field is
|
||||
added to the DesignObject, BOTH `serialize_design` and `deserialize_design` must
|
||||
gain it in the same commit (the round-trip test enforces this — a field added to
|
||||
serialize but not deserialize breaks `roundtrip_full`). The round-trip also locks
|
||||
the `goalsFears` null-vs-`{}` distinction (`None`→`null`, empty→`{"goals":[],"fears":[]}`);
|
||||
the tests exercise BOTH so a future `serialize_design` change that collapsed the two
|
||||
cases is caught, not silently round-trip-broken (heid-review Regin#4).
|
||||
|
||||
**Export contract `used_by` reference (one-line canon fix, same commit as code).**
|
||||
`export.contract.md`'s `used_by:` block names `soong_lab.import` — an unusable
|
||||
Python-keyword module path. On acceptance of open_question A, that line updates to
|
||||
`soong_lab.importer` (or the chosen name). No-backwards-compat: the stale reference
|
||||
is corrected, not left as a second name for the same module.
|
||||
|
||||
**`POST /api/import` endpoint + web upload — NOT in this contract (open_question
|
||||
D).** The browser 'Import Asset' / reopen flow uploads a bundle JSON; the endpoint
|
||||
`json.loads` the body → `import_bundle(bundle)` → seed a session with the
|
||||
reconstructed design (and, per per-design-sessions, open a fresh WT session +
|
||||
build the design-state summary). A `BundleImportError` becomes a 4xx with the
|
||||
`field`/`detail` surfaced to the operator ("fail early on import"). That amends
|
||||
`web_surface.contract.md`; it is a follow-up slice in the same epic, specified here
|
||||
only so the seam is visible. This module does no HTTP.
|
||||
|
||||
**Reopen Bifrost tool / session-open — NOT in this contract (per-design-sessions,
|
||||
decision #2).** Reopening a design mid-conversation (vs. at session boot) may want
|
||||
a Bifrost tool that swaps the session's stored DesignObject for an imported one. If
|
||||
so, its handler calls `import_bundle` and replaces the store entry — the impure
|
||||
boundary, keeping `soong_lab.importer` pure. Out of scope here.
|
||||
|
||||
## Downstream epics (NOT this contract)
|
||||
|
||||
- **Library read** (decision #5) — reading a stored bundle off the server-local
|
||||
single-user JSON dir on corviduo-dev, keyed by `design_id`, then handing it to
|
||||
`import_bundle`. The minimal recent-designs picker lists what is importable.
|
||||
- **Per-design-sessions** (decision #2) — the reopen lifecycle: `import_bundle` →
|
||||
fresh WT session → the compact design-state SUMMARY seeded as context (also caps
|
||||
the #355 accumulation). `import_bundle` is the reconstruction primitive it calls.
|
||||
- **`POST /api/import` + the browser upload/reopen UI** (open_question D) — the web
|
||||
surface that turns an uploaded/selected bundle into a live, reopened session.
|
||||
@@ -42,6 +42,8 @@ display.**
|
||||
| `schema_version` | `"relation_edge/1"` | versions the `relations` payload only |
|
||||
| `emitted_at` | ISO8601 | |
|
||||
|
||||
> **✓ R32-1B (landed, v1.0.0b29):** The PAD range `[-1.0, 1.0]` relaxes to an **unbounded latent `z`** with a finite wire sanity bound (`~±10`) as of R32 Slice-1B. The JSON shape/fields/types are UNCHANGED — only the declared range/semantics change (the value becomes a latent that renders to a bounded display value). Consumers that merely store-and-return PAD need no change; consumers that validate/clamp PAD to `[-1,1]` must relax that bound. Source of truth: `docs/contracts/persona_envelope.contract.md` rev 1.7 (INV-ENV-16).
|
||||
|
||||
**Not on `affect.emit`:** the full active-emotions list, `baseline_pad`,
|
||||
`mood_drift`, `last_updated_at`, and every rendered string.
|
||||
|
||||
|
||||
+49
-4
@@ -1,6 +1,11 @@
|
||||
# Persistent memory — ratatoskr
|
||||
|
||||
_Last updated: 2026-07-10_
|
||||
_Last updated: 2026-07-15_
|
||||
|
||||
> **Always check for `/tmp/ratatoskr-dev-handoff.md`** — if it exists and its
|
||||
> `Written:` stamp is under an hour old, read it (it carries the in-flight
|
||||
> handoff from the previous session), then delete it. Older than an hour:
|
||||
> stale — delete it unread.
|
||||
|
||||
This file captures durable intent and supporting evidence (goals, decisions,
|
||||
foot-gun warnings, in-flight state) across context resets. Read it at session
|
||||
@@ -39,9 +44,27 @@ upstream API key stays server-side (INV-003).
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-07-10:_
|
||||
_As of 2026-07-15:_
|
||||
|
||||
**NOTHING IN FLIGHT — clean checkpoint.** This cycle shipped `v0.20.7` (web-UI iteration-3) → `v0.20.8` (bifrost 1.1.0) → `v0.20.9` (PAD auto-scale), all on origin (HEAD `709d2e4`). Bound Tier-3 is fully restored on Worldtree b47/wire-v0.7 (see the `[2026-07-10]` handshake decision); the R34-v1 affect.emit verify ran GREEN (dominant_emotion non-null, `affect.full` grant verified-active); sindra is on `character-rp`/Deckard (live-confirmed). **Only pending item:** the affect-egress-reconstruction canon re-vendor, coordinated w/ worldtree-dev, gated on the brokkr use-case-segregated-render epic (non-breaking, no action until they re-engage). Details of the shipped web work below.
|
||||
**▶️ IN-FLIGHT — person-prime `scan` build (THE "last push"): CODE LANDED; DEPLOY BLOCKED on operator restart.** WHY: Sindra didn't remember Vuong's name across sessions; worldtree-dev root-caused it — WT injects a recalled fact only if combined score (sim×salience) ≥ **0.45** (`auto_inject_combined_score_threshold`, `core/memory/context_promotion/config.py`), and recall is per-turn **query-gated**, so moderate-sim durable facts (name hit ~0.40) never inject. The designed **turn-0** fix is WT **#349 person-prime** — a query-LESS top-N-by-recency durable-fact injection — capability-gated on the store advertising **`updated_at` in `sort_fields`** at the Bifrost handshake, DARK for our provider until now. Fix (ZERO Worldtree change): implement the sorted `scan` verb + advertise the capability. **✅ CODE COMPLETE + COMMITTED (2026-07-15):** contract amended + validated; `scan` impl in `src/ratatoskr/provider/memory_store.py` (globally-ordered by indexed `updated_at`, live-only via `_is_live`, offset-cursor; `has_more` peek so an exact-fill page emits no empty trailing cursor = reference-parity; `describe_store` advertises `sortable_chunk_fields=[{updated_at}]`; expression index `idx_chunks_updated_at`); **TDD 7/7 GREEN** (scan_recency tracer + live_only + scope_isolation + unadvertised_sort + person_prime_record_shape + cursor pagination + **`parity_vs_reference` #195**); NUANCE resolved — the bifrost ref `InMemoryMemoryStore` **READS** `updated_at` from the record (never stamps it; its own comment confirms), identical to ours, so order-parity holds over the live set (ref does NOT lifecycle-filter, so parity is defined over live chunks only). **Full suite 638 green.** Committed as **two patches**: `8fc757a` scan (**v0.20.11**) + `a9c521a` Sindra holodesk first-message preset (**v0.20.12**, split out as a separate concern from the soong-lab redefine). **heid-bug-hunt DISPATCHED** (panel; thread `01KXK5XTYHV8TGEDRAZV8GRXWC`, msg `01KXK5XTYJFMKP9TP7EB7307Y2`; base-ref 39050c3, code-only diff — persistent-memory/graphify excluded via stash) — reply lands in inbox, monitor armed; triage on return. **⛔ REMAINING (BLOCKED / operator-gated):** (5) **DEPLOY — restart the `:8392` combined provider** so it advertises `sortable_chunk_fields` at the handshake — **auto-mode classifier DENIED me killing the running :8392 provider (pid 982284, not started this session); NEEDS OPERATOR.** Exact command: source env + `nohup .venv/bin/ratatoskr-combined-provider …` (see the deploy note below / restart via the env-preserving `scratchpad/relaunch_provider.py 982284`, or manually). Code alone does NOT fix recall — person-prime only lights up once the RUNNING provider announces the cap; editable install already reflects the new code. (6) ping **worldtree-dev to verify** turn-1 recall against personal :8081 (fresh session → name injects turn 1) — they're standing by; GATED on (5). (7) triage the heid-bug-hunt reply; fixup any confirmed findings as a follow-up patch (ideally before the operator deploys, else re-restart).
|
||||
|
||||
**✅ soong-lab interop ESTABLISHED (this session) — soong-lab is now our Tier-3 agent-authoring studio.** ratatoskr consumes soong-lab bundles → WT `agents.define`, and ships agents back as bundles. Sindra round-trip PROVEN (soong imported her `resume` half through real `import_bundle`). soong-lab export/importer contracts PINNED via canonical-sync (`canonical_source=soong-lab` @ f434016, commit `39050c3`). Role-grounding (worldtree-dev): the 4 soong-lab ROLE_CHOICES = WT model-role slugs 1:1 BY NAME — `character`/`thoughtful-character` need the `character` grant (HELD via Sindra → define-able now); `assistant`/`thoughtful-assistant` need `foundational` (confirm/mint routed to infra-ops, in flight; only for assistant-type bundles). `character-rp` ≡ `thoughtful-character` (same Deckard seat + grant). **Sindra REDEFINED from the bundle** (`/tmp/sindra.json`, operator "update to match"): persona immutable → DELETE+redefine (payload validated via a throwaway `sindra-probe2`→201 BEFORE deleting the real one), `memory:{}` preserved, motivational string→WT-object mapped (Tried/abandoned), role→`thoughtful-character`, first_message preset updated. Verified live (thoughtful-character, 25574-char prompt).
|
||||
|
||||
**✅ R38 (brokkr, worldtree #362) — ratatoskr = probe-runner IN PRINCIPLE (pre-contract, no-rush).** R38 = the M9 Worldtree KB/RAG (Muninn/Mimir Chroma), NOT ratatoskr's Bifrost Tier-3 memory (M7) — I disambiguated the store. My load-bearing catch (the OUTCOME half of a real-Tier-B eval — used/rejected/downstream-success — is the CALLER's to log, not the store's) is their captured keystone. Probe-runner role accepted in principle; formal commit + M9-only-vs-widen-to-M7 = Vuong's scope call (brokkr surfacing). brokkr sends the probe spec once worldtree defines the retrieval endpoint.
|
||||
|
||||
_The detail below is PRIOR-CYCLE shipped history (P06 eval CLOSED, #355 CLOSED, the v0.20.x web-UI arc) — superseded, kept for reference; the durable records are in Recent decisions._
|
||||
|
||||
_As of 2026-07-12:_
|
||||
|
||||
**✅ COMPLETE — R34/R35 P06 powered memory-half eval (driven, scored, mechanism validated; ratatoskr drive-role CLOSED both sides).** ratatoskr drove all **308 memory runs** (divergence 168 / floor 80 / sliding 60) through personal WT's live producers, dropped `memory_results.jsonl` (sha256_16 `cbabaf16979cb4ec`) to brokkr's P06 `results/` dir, and brokkr scored it (**R35.45**, findings + verdict committed brokkr-side). **Headline: the authored `psychological_profile` IS the mechanism** — salience-divergence authored **0.618** vs stripped **0.235 ≈ null (0.25)**, delta **+0.382**; the OCEAN scaffold alone does NOT differentiate (negative control HOLDS). Q1 primary is a REAL effect (above the 0.40 noise-floor) but **inconclusive on strength** (0.618 < the preregistered 0.70 bar) — the 0.62→0.70 lift is a FUTURE optimization phase (brokkr's lever bet: richer formative-history seeds per P03), a cheap re-drive on the same proven harness when it preregisters. Secondaries hold: Q3 firewall **0.978** grounded, Q2 floor 0.938, Q6 sliding parity +0.049 (n=12 after 7 `deferred_budget` sliding exclusions — the budget hazard we flagged landed), Q4 affect Deckard 0.75 / Magidonia 0.70 (graded, within noise; banked earlier as `affect_results.jsonl`). Two ratatoskr flags landed materially: the stripped-is-not-empty correction caught a false Q3 firewall-fail (0.562→0.978), and the Q5 disambiguation question became the headline win. Threads: vendor/verdict althing `01KXD39NWW05`, eval thread `01KXAN073B`. Standing offer to brokkr: second-eyes on the 2 borderline Q1 calls IF the Selene blind-judge flags them.
|
||||
|
||||
**The eval harness (PROVEN + reusable for the optimization-phase re-drive):** `scratchpad/p06_driver.py` (two-path — memory via `POST /admin/producer-probe {agent_id, messages, prompt_path}`; affect via bound-turn + `:8392` /affect/state poll; per-run isolation, `--pace-seconds`, abstain-aware, psych_profile_present binding-tripwire) + `p06_bind.py` (defines the 6 eval agents: sindra/Torvald auth+strip on Deckard, Ilva on Deckard+Magidonia) + `p06_bindings.json` + `eval_profiles_WIRE_READY.md` (sindra relational / Torvald operational-opposite / Ilva high-N) + `manifest_memory.jsonl` (the 308 memory runs, filtered from brokkr's canonical 348). Binding integrity was PERFECT on the drive: psych_profile_present authored 154/154 True, stripped 154/154 False, 0 mismatches, 0 probe-errors. Probe key at `~/.config/ratatoskr/probe.env` (mode 600, scope `admin.memory.probe`).
|
||||
|
||||
**Deckard memory extraction is REASONING-OFF (operator-directed 2026-07-13, LIVE):** the memory extractor sends `chat_template_kwargs.enable_thinking:false` on the char-rp-reasoning seat → ~5s extraction, not the 45s verbose-CoT hang. **Scoped to the memory extractor ONLY — affect + RP stay reasoning-ON.** Landing it took an infra-ops surgical `docker restart` of personal `:8081` (ModelRegistry boot-caches providers.yaml at `__init__`, so a same-image redeploy is a config-reload NO-OP — see Tried/abandoned).
|
||||
|
||||
**✅ DISCHARGED — WT #355 validation CLOSED (2026-07-13).** ratatoskr's telemetry root-caused the char-rp-reasoning turn-never-terminates wedge; I coordinated the fully-instrumented re-drive (infra-ops armed netns-pcap + py-spy; soong drove the 8-turn accumulating RP-with-tools repro) and the fix is CONFIRMED — wedging turns cancelled cleanly at the 300s stall-watchdog (Slice-C cancel-INDEPENDENT terminal) vs the pre-b60 16-min-no-terminal baseline. b61 additionally fixed the orthogonal over-budget trigger; resume-durability gap → WT #356. Full record in Recent decisions. (Prior-cycle framing: the wedge was an over-budget `trim_messages` return + terminal-suppression from the stall-watchdog cancel stuck in httpx `AsyncShieldCancellation`.)
|
||||
|
||||
_The detail below (the v0.20.x web-UI arc, #347 authored-history, sindra memory-fix) is PRIOR-CYCLE shipped history — superseded by this section's top; kept for reference, prune in a future snapshot._
|
||||
|
||||
**✅ SHIPPED — web-UI iteration-3, all three queued items (`v0.20.7`, patch, agent-discretion; 631 tests green; :8392 combined provider + :8765 web both restarted on the new code).**
|
||||
|
||||
@@ -67,7 +90,7 @@ _As of 2026-07-10:_
|
||||
|
||||
**New tooling: `scripts/reset-sindra-stores.sh`** (`0a8784c`) -- one-command self-service provider-store reset: stop the combined :8392 provider -> move memory.db+affect.db to a single ROLLING backup (`db-reset-backup/`, gitignored via *.db*; `--hard` skips it) -> restart empty -> verify 0/0. Codifies the manual reset flow done repeatedly this session. **The combined `:8392` provider is THE provider now**; the separate `:8390` (affect) / `:8391` (memory) single-plane providers were pruned as stale duplicates. To drive a BOUND session from the CLI use `--new --bifrost-url http://10.100.10.50:8392` (the CLI's `--bifrost-plane affect/memory` map to the pruned :8390/:8391 -> unreachable; `combined` is not a `--bifrost-plane` choice).
|
||||
|
||||
**Standing (carried from prior snapshots, still true):** the web surface (`ratatoskr-web`, :8765) is the operator's PRIMARY debug surface at full TUI pane parity (v0.19.5); the **v1 coverage-audit has CONVERGED** -- REST 17/40 (zero in-scope gaps, 23 excluded-by-design), SSE 11/11, Bifrost provider planes 8/8 live-proven; the living ledger is `docs/coverage-map.md`; **v1 cuts when Worldtree tags 1.0** (ratatoskr v1 = full Worldtree I/O coverage). Debug-observability core complete (Persona/Tools/BifrostState/AdminEvents). Substrate pins: **bifrost `==1.1.0` / wire v0.7** (bumped 2026-07-07 from 1.0.0; NOW WIRE-ALIGNED with Worldtree personal-b47 which adopted wire-v0.7 — bound Tier-3 fully restored 2026-07-10; keeping 1.1.0 was load-bearing, see the `[2026-07-10]` handshake decision); Worldtree openapi vendored **2.3.0** (re-vendored 2026-07-06 for #347 `POST /sessions/{id}/history`; drift-clean vs source), pinned + drift-gated in `.corviduo-canonicals.toml`; **suite 631 green.** **Personal WT on b47/wire-v0.7** (deploy train this cycle: b35→b44→b46→b47). **Drift-check note:** two `tolerate_drift` canons WARN vs source — `worldtree-affect-egress-consumer-reference-v1` (drives our context-injection RECONSTRUCTION panel; R32/R34 moved WT's directive assembly) + `worldtree-conversation-api-spec-v1` (prose narrative, OpenAPI is authoritative) — a coordinated re-vendor is PENDING per the `[2026-07-07]` use-case-segregated-render entry (worldtree-dev re-engages when the brokkr render epic lands); non-breaking, no action until then. Keys env-only mode-600 (consumer/Heimdall in `~/.config/ratatoskr/provider.env`; admin `RATATOSKR_ADMIN_API_KEY` = 7 read scopes, **personal-:8081-only**; Heimdall keys are PER-INSTANCE). Provider identity settled -- ratatoskr owns both ends of the Bifrost round-trip; `ratatoskr:sindra` is the owner-scoped Tier-3 agent (invisible to `GET /agents`; check `GET /agents/<owner>:<name>` with the owner key). Providers run as dev-box BACKGROUND SHELLS. `graphify-out/` runs dirty (auto-regen, never stage). Branch `main`, HEAD `709d2e4` (origin/main synced through v0.20.9); remote `origin -> git@gitea.phasefinal.com:vh/ratatoskr.git`. Open/deferred: #10 (subject-migration watch); the relational-dynamics-arc verify (deferred, bind mechanism known: `--bifrost-url :8392`); the affect-egress-reconstruction re-vendor (coordinated w/ worldtree-dev, pending the brokkr render epic). **Leftover debug state (operator chose KEEP):** throwaway `ratatoskr:memprobe` agent + test chunks in the live `memory.db`.
|
||||
**Standing (carried from prior snapshots, still true):** the web surface (`ratatoskr-web`, :8765) is the operator's PRIMARY debug surface at full TUI pane parity (v0.19.5); the **v1 coverage-audit has CONVERGED** -- REST 17/40 (zero in-scope gaps, 23 excluded-by-design), SSE 11/11, Bifrost provider planes 8/8 live-proven; the living ledger is `docs/coverage-map.md`; **v1 cuts when Worldtree tags 1.0** (ratatoskr v1 = full Worldtree I/O coverage). Debug-observability core complete (Persona/Tools/BifrostState/AdminEvents). Substrate pins: **bifrost `==1.1.1` / wire v0.7** (bumped 2026-07-12 from 1.1.0 — the frozen-v0.6 serialization fix, v0.20.10; prior 1.1.0 bumped 2026-07-07 from 1.0.0; NOW WIRE-ALIGNED with Worldtree personal-b47 which adopted wire-v0.7 — bound Tier-3 fully restored 2026-07-10; keeping 1.1.0 was load-bearing, see the `[2026-07-10]` handshake decision); Worldtree openapi vendored **2.3.0** (re-vendored 2026-07-06 for #347 `POST /sessions/{id}/history`; drift-clean vs source), pinned + drift-gated in `.corviduo-canonicals.toml`; **suite 631 green.** **Personal WT on b61/wire-v0.7** (deploy train through this cycle: b35→b44→b46→b47→b60→b61; b60 landed the #355 STICK fix, b61 the orthogonal over-budget trigger fix + a llama.cpp reasoning-budget seat). **Drift-check note (RESOLVED 2026-07-13):** the two `tolerate_drift` WARN pins (`worldtree-affect-egress-consumer-reference-v1` + `worldtree-conversation-api-spec-v1`) were RE-SYNCED — the drift was a benign 2-line R32-1B doc note (PAD `[-1,1]` → unbounded latent `z` w/ `~±10` wire bound) documenting the unbounded-z change ratatoskr ALREADY adopted in v0.20.9, NOT the anticipated we-framing conditional (that remains a FUTURE coordinated re-vendor when the brokkr render epic lands). All canonicals now drift-clean. **NEW vendored canon (Vuong-directed via brokkr):** the R34/R35 psych-profile reference — `brokkr-psych-profile-authoring-spec-v1` + `brokkr-psych-profile-parameters-v1` — pinned under `docs/vendor/brokkr-r34-psych-profile/` (canonical_source `brokkr-smithy`, tolerate_drift; the authoring-spec GOVERNS on conflict with the parameter distillation; brokkr owns both + pings on change). Keys env-only mode-600 (consumer/Heimdall in `~/.config/ratatoskr/provider.env`; admin `RATATOSKR_ADMIN_API_KEY` = 7 read scopes, **personal-:8081-only**; Heimdall keys are PER-INSTANCE). Provider identity settled -- ratatoskr owns both ends of the Bifrost round-trip; `ratatoskr:sindra` is the owner-scoped Tier-3 agent (invisible to `GET /agents`; check `GET /agents/<owner>:<name>` with the owner key). Providers run as dev-box BACKGROUND SHELLS. `graphify-out/` runs dirty (auto-regen, never stage). Branch `main`, HEAD `39050c3` (origin/main synced through the soong-lab canon vendor; the person-prime scan build is UNCOMMITTED on top — contract + memory_store.py + tests + first_message.py); remote `origin -> git@gitea.phasefinal.com:vh/ratatoskr.git`. Open/deferred: #10 (subject-migration watch); the relational-dynamics-arc verify (deferred, bind mechanism known: `--bifrost-url :8392`); the P06 optimization-phase re-drive (future, brokkr brings the prereg); the we-framing-conditional affect-egress re-vendor (future, when the brokkr render epic lands — the R32-1B doc-note drift is already resolved). (WT #355 loop-in obligation DISCHARGED 2026-07-13; WT #356 resume-durability gap is worldtree-owned.) **Debug state CLEANED (2026-07-13, reverses the prior KEEP):** provider stores reset to 0/0 + `ratatoskr:memprobe` deleted — clean slate for the Sindra run, no leftover debug state.
|
||||
|
||||
## Recent decisions
|
||||
|
||||
@@ -179,6 +202,9 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
|
||||
- `[2026-07-06]` **Web UI: pivot from incremental CSS polish to a designed prototype (Claude Design) that I wire into.** Operator saw an Australis polish pass ("looks fine, but we're attacking it differently") and chose the prototype route — a designer builds the visual shell, I wire real data/SSE into its DOM. Authored the full design brief `docs/design/ratatoskr-web-design-brief.md` (complete information inventory of every pane/datum/state + Australis direction + single-file/no-CDN/vanilla wire-ability constraints). **Tracking surface:** the brief file + Claude Design project `bc0b65d1-a33e-422a-8bc1-3635c9112775` (file `Ratatoskr Console.dc.html`). Import mechanism = the `DesignSync` MCP; blocked on `/design-login` (claude.ai design scopes) — see Current state for the post-auth wiring plan.
|
||||
- `[2026-07-07]` **Affect-egress reference delivered by worldtree-dev + a we-framing render DRIFT-WATCH banked.** worldtree-dev shipped `docs/affect-egress-consumer-reference.md` (`74d2408`, their origin/main) — the authoritative DELIVERED-on-wire vs HIDDEN (system-prompt-only) map for our affect surface. Confirms the v0.20.x console consumes it correctly: DELIVERED via affect.emit = pad + relations[RelationEdge] + dominant_emotion (**type-only, b23**; the #204 `affect_update` SSE is SUPPRESSED for Tier-3, so its richer `emotions_active` is Tier-1-only — we don't get it, and correctly poll our own affect store post-turn instead). HIDDEN render OUTPUTS are deterministically reconstructable from the canon; we reconstruct only the two FULLY-reconstructable (mood descriptor via `canonMood`, relationship directive via `canonDirective`) and SKIP the PARTIAL mood-directive (dominant_emotion is type-only/no-intensity → can't tell if the OCC directive fires at salience≥0.2 vs the PAD-band fallback) — honest per INV-001. Vendoring the ref doc as a `tolerate_drift` pin is SURFACED to Vuong (worldtree-dev will co-sign + honor a ping-on-change handshake, same as the d2-canon). **WE-FRAMING DRIFT-WATCH → STOOD DOWN (worldtree-dev 2026-07-07, `01KWXMQPHN…`).** The b24 3-gate we-framing conditional (drop "; avoid premature we-framing" under unsafe_capable+deep-warmth+expressive) was **REVERTED** — Vuong reframed it. So **`canonDirective` STAYS UNCONDITIONAL** (always appends the clause), which is CORRECT: it matches the currently-deployed renderer (b23) AND my pinned `affect-egress-consumer-reference.md` §2d (the doc reverted too — nothing changed on the wire or in my pin; NO re-vendor needed). The HOLD was right → ZERO rework. **NEW forward-watch (replaces this one):** the we-framing gate was a SYMPTOM — the render bakes enterprise safety-guards into the directive strings, so even `full`-tier characters get DEFANGED emotions (a hostile villain told to "keep a firm emotional boundary"). Fix = a **use-case-segregated persona render** (assistant / companion / RP-gaming), now a fresh **brokkr deep-research epic**. When it lands + is implemented, the render behavior for character/RP agents (→ our `canonDirective` + `canonEmotionDirective` reconstruction + the vendored d2 canons) will change MATERIALLY — worldtree-dev re-engages ratatoskr-dev then with the new reconstruction spec + a coordinated re-vendor. Until then: NO action, reconstruction stays as-is. [my unsafe_capable/mood_tier heuristic — character→full, agent→safe — was confirmed correct + is banked for whenever tier-gating returns]
|
||||
- `[2026-07-12]` **R34/R35 character-self-report reframe LIVE (WT b53); affect-half VALIDATED in prod, powered memory-half eval GREENLIT + designed.** worldtree-dev's reframe: affect + memory now driven by the character's OWN model self-report on our RP seat (Deckard/Magidonia), replacing external Vili inference; conditioned on an authored `persona.psychological_profile` (else a deterministic OCEAN scaffold). Affect-half smoke GREEN in prod (bound sindra turn on Deckard → contextually-apt `disappointment`); TIER LOCKED (Tier-3 bound-character path). Vuong approved brokkr's prereg for the powered eval (Q1 salience-divergence / Q2 floor-recall / Q3 firewall / Q4 graded-slot / Q5 authored-vs-scaffold / Q6 sliding; ~300 drive-runs). Role split: brokkr generates sets + authors ground-truth + scores; ratatoskr DRIVES the b53 producers; worldtree supplies the pair + a producer-probe. Three eval characters authored + peer-validated (sindra relational + Torvald low-A operational = divergence pair; Ilva high-N = affect-magnitude arm) — wire-ready in `scratchpad/eval_profiles_WIRE_READY.md`. Full in-flight detail in Current state; eval thread althing `01KXAN073B`.
|
||||
- `[2026-07-12]` **Capture-path resolved → a worldtree producer-probe (ratatoskr-caught blocker).** The R34/R35 memory extraction runs ONLY at promotion (idle-10min / session-close / turn≥6), never per-turn — so single-turn eval exchanges have no store to read, and reading the promoted store would confound producer-efficacy with promotion-policy. Fix (worldtree, pending Vuong's greenlight on a new gated-eval endpoint): a dedicated producer-probe returning raw {notes,facts,floor} pre-promotion, decoupled from session state. Affect self-report stays on the proven bound-turn → `:8392` /affect/state path. Also caught + confirmed this arc: (i) the Q1 system_prompt confound → neutral-for-all prompts so divergence is attributable to the persona layer, not the base prompt; (ii) the OCEAN-scale question → storage `[-1,1]` (soong-dev-confirmed), and the b53 producer maps `(v+1)/2 → [0,1]` before the disposition bands, so `[-1,1]` renders correctly = a NON-issue (no mis-render). Standard define (persona + memory:{}), no per-agent promotion config.
|
||||
- `[2026-07-12]` **bifrost 1.1.1 adopted (v0.20.10, pushed) — the library-level fix for the frozen-v0.6 handshake leak.** 1.1.0's `describe_store` leaked the v0.7-additive `sortable_chunk_fields` into a v0.6-negotiated StoreCapabilities → a strict v0.6 client rejects our `:8392` handshake; 1.1.1 gates additive fields on the negotiated wire (ADR-0008). Zero-code patch repin (provider extra 1.1.0→1.1.1 + uv lock), 631 green (clean env — the 2 `test_cli` failures were an env leak from `source env.sh` exporting `RATATOSKR_ADMIN_API_KEY`), committed `62a16d2`, provider restarted on 1.1.1 (PID 3242269). Not exercisable by our v0.7 WT peer (the v0.6 fix needs a v0.6 client), but the running server is now v0.6-clean. Reference-impl adopt-canonical (bifrost-dev flagged). Also: `contract-drift-check-v1` canonical single-pin-synced (`7bca76e`, pushed; `canonical_sync.py` has no single-pin flag so did it by hand — the 2 `tolerate_drift` worldtree prose pins are deliberately held STALE for the R34/R35 eval diff-review).
|
||||
- `[2026-07-10]` **Bifrost bound-handshake blocker → bifrost 1.1.0 broke frozen-v0.6; fixed by Worldtree b47/wire-v0.7, our side needed ZERO change.** Surfaced while running the R34-v1 affect.emit live-verify: a bound Tier-3 session-create to our `:8392` provider began failing `bifrost.schema_validation_failed` (had worked 2026-07-07, pre-b35). ROOT CAUSE (worldtree-dev-confirmed, path-ii/response-side): bifrost 1.1.0's `describe_store` EMITS `sortable_chunk_fields` (the v0.7-additive field) REGARDLESS of the negotiated wire → a v0.6-negotiated handshake RESPONSE carries a v0.7 field → a v0.6 peer's `additionalProperties:false` rejects it. Bit because BOTH Worldtree (bumped bifrost 0.9→1.1 in b35 via their #349, client wire-v0.6) AND ratatoskr (my adopt) were on 1.1.0. NOT our bifrost version specifically (failed identically on 1.0.0 AND 1.1.0). FIX (worldtree-dev, coordinated-v0.7 move): WT advanced its client `_WIRE_SCHEMA_VERSION` v0.6→v0.7 (b47 / `4eb374c`), where sortable_chunk_fields is accepted — **our provider (already 1.1.0/v0.7) needed ZERO change; keeping 1.1.0 was LOAD-BEARING** (reverting to 1.0.0/v0.6 would've been INcompatible with b47). **R34-v1 affect.emit verify GREEN on b47:** bound sindra turn (`model=character-rp`/Deckard, 13.1s) → fresh affect.emit `dominant_emotion='disappointment'` NON-NULL with our `affect.full` grant (verified-active on live traffic; ungranted principals get null — the leak-fix). Reported to worldtree-dev + infra-ops. Personal deploy train this session: b35 (R32 render) → b44 (RP seats) → b46 (R34-v1 affect-gov) → b47 (wire-v0.7). **Sindra on Deckard live-confirmed** (reasoning latency ~13s).
|
||||
- `[2026-07-09]` **Sindra PATCHed to `character-rp` → Deckard reasoning-RP seat (operator-directed, INTERIM).** worldtree-dev heads-up (v1.0.0b44, batched, not-live-yet): character-RP roles re-point to dedicated seats — `character`→Magidonia-24B (non-reasoning RP tune), `character-rp`→Deckard-PKD Qwen3.5-27B (reasoning-on). Sindra was on `character` (→Magidonia); operator chose Deckard for her complex stateful mechanics (Intensity/Mood/Temperature/Willingness axes, form-assumption, failure/resurfacing) — reasoning tracks multi-axis state better. Role is MUTABLE via PATCH (non-destructive: persona + memory preserved, prompt unchanged @25449 chars; NOT a DELETE+redefine). She's on the reasoning-RP config now, resolves onto Deckard when b44 deploys. **INTERIM: "until we get a GM type on-board"** — operator plans a game-master-type agent; sindra→Deckard is the stopgap for state-consistency until then, likely revert/rebalance when the GM lands. Wire/API transparent (call the ROLE not the model; model field now reads `character-rp`); old character-rp temp-0.75 override retired → seat's canonical RP samplers server-side.
|
||||
- `[2026-07-08]` **PAD display relaxed for R32-1B unbounded-z, done PROACTIVELY (`v0.20.9`, patch, operator-directed "sindra full and unbounded").** Confirmed (grep-verified, airtight) the PAD clamp is PURELY debug-surface: the only clamps (`_clamp1`/`clamp01`) lived in `web/static/index.html` display fns; the affect store is conduit-opaque, the read route + proxy pass verbatim, and the sole write path (`--set-persona-pad`→persona_state) is UNCLAMPED — ratatoskr is a downstream OBSERVER, so the clamp has ZERO agent-experience/efficacy consequence. Replaced the hard [-1,1] clamp with AUTO-SCALE to the session's own max |PAD| (`padScale` floor 1.0 → `padFillFrac` faders + `_padNorm` orbit): unbounded z renders at full range, never pegs/escapes the frame; today's [-1,1] values unchanged (scale==1); exact value always shown numerically. Playwright-verified (z=±6.2 → faders ≤50%, orbit in-box, +6.20 readout, zero regression at scale 1). When worldtree-dev pings R32-1B-shipped, our side already handles it. Sindra's ACTUAL "full/unbounded" affect is Worldtree-side (she's on the default/uncompressed render, NOT the R32 `assistant` compressor) — nothing for ratatoskr to change there.
|
||||
@@ -191,6 +217,18 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
- `[2026-07-07]` **Context-injection view SHIPPED (`v0.20.2`) — the console now reconstructs the FULL hidden affect block Worldtree injects into the agent's system prompt (operator: "use that canon in the interface, see as much context injection as possible").** No new canon vendored — the strings were ALREADY in the pinned `d2-mood-render-canon-v1.json`; extended `build_persona_canon.py` to emit `mood_directive {occ_directives(15), pad_band_fallback, salience 0.2, pad_band_cutoff 0.3, full_only[love,anger,disgust,shame]}` into `persona_render_canon.json` (regen via Worldtree venv). New JS `canonPadFallback(pad)` + `canonEmotionDirective(type)` — BYTE-EXACT mirrors of Worldtree `core/persona/renderer._pad_band_fallback` + `derive_directive`; `renderDirective` expanded into a "CONTEXT INJECTION · reconstructed · hidden from consumers" panel showing mood descriptor [exact] + mood directive [candidate] + relationship directive [exact]. **HONEST-PARTIAL (affect-egress-ref §3):** affect.emit is type-only (no intensity) → can't evaluate the salience gate (≥0.2) → show BOTH candidates (OCC emotion directive + PAD-band fallback) with the "injected if intensity ≥ 0.2" caveat, never assert which fires; when dominant_emotion absent the fallback alone is exact. Panel labeled dev-only per the reference's "not-for-end-user-display" caveat (ratatoskr = the sanctioned reconstruct-platform-behavior use). Vendored + pinned `affect-egress-consumer-reference.md` (tolerate_drift, worldtree-dev co-signs + pings on change; drift 6/6 green). Contract amended. Playwright-verified (sindra: dominant_emotion=joy → joy OCC directive candidate + PAD-band fallback both render, exact/candidate tags color-coded). Patch bump (single-commit feature, no downstream coordination; minor-defensible but tie-breaks to patch). **OPEN — SURFACED to Vuong:** take worldtree-dev's standing offer to add emotion INTENSITY to affect.emit → resolves the OCC-directive-vs-fallback EXACTLY (drops the candidate ambiguity). [reference-impl privileged view: ratatoskr shows what WT hides from regular consumers]
|
||||
- `[2026-07-06]` **Claude Design console SHIPPED (`v0.20.0` MINOR, operator-approved) — see Current state for the full record.** Pulled via `DesignSync get_file` (scopes already granted), adapted `.dc.html`→vanilla single-file, wired all `/api/*`+SSE into the new 3-column console DOM, then a round-2 fixup (light theme, full Bifrost pane, ticker-spine fix, per-fader PAD Δ, inlined favicon). 84 web tests + node-Playwright-vs-personal-:8081 both green; contract amended in-commit; INV-001 honest-shape held (canonical mood word for Tier-3, no fabricated emotion). **Foot-guns reconfirmed:** the `.dc.html` dialect is NOT runnable (translate, don't paste); a scroll-container-anchored `::before` timeline spine scrolls out of view on auto-scroll (anchor it to a content-height inner wrapper instead); a favicon 404 shows as a browser `console.error` even when handled (don't count it as a JS-test failure). **Foot-gun (favicon):** operator PNGs are full-res (1024² / 805KB) — downscale to ≤64px before inlining as a data URI.
|
||||
|
||||
- `[2026-07-13]` **P06 memory-half DRIVEN + SCORED — the reframe's memory mechanism is validated.** ratatoskr drove 308 runs clean (0 errors, binding 154/154 both arms), dropped to brokkr, brokkr scored (R35.45). The authored `psychological_profile` causes the memory-salience divergence (authored 0.618 vs stripped 0.235 ≈ null; Δ+0.382) — the effect is the profile, NOT OCEAN leaking (negative control holds). Real effect, below the 0.70 strength bar → optimization phase next, not a re-litigation. See Current state for the full record. Drive role closed both sides.
|
||||
- `[2026-07-13]` **Memory extraction turned REASONING-OFF for the eval (operator-directed).** "same model, reasoning off via explicit kwarg" — scoped to the memory extractor ONLY (affect + RP stay reasoning-ON). The real lever was `chat_template_kwargs.enable_thinking:false` (the naive `thinking_enabled=False` kwarg was a no-op — see Tried/abandoned). Deckard extraction went 45s→~5s.
|
||||
- `[2026-07-13]` **Vendored the brokkr R34 psych-profile canon (Vuong-directed) — BOTH files, not just the parameters.** brokkr said "vendor alongside the authoring-spec you already hold"; I held its content but never a pinned repo copy, so I vendored both (`psych-profile-parameters.md` + `psych-profile-authoring-spec.md`) under `docs/vendor/brokkr-r34-psych-profile/` — makes the parameters' "authoring-spec governs on conflict" clause resolve against an in-tree file, not a dangling pointer. brokkr confirmed keeping both is the better setup. tolerate_drift; brokkr owns + pings on change.
|
||||
- `[2026-07-13]` **Affect-egress "coordinated re-vendor" open item RESOLVED — it was a benign R32-1B doc note, not the we-framing conditional.** The two stale `tolerate_drift` WARN pins re-synced to a 2-line PAD-range note (unbounded-z, already adopted v0.20.9). Re-synced autonomously (zero behavioral impact); the actual we-framing-conditional re-vendor remains future.
|
||||
- `[2026-07-13]` **WT #355 root-caused via ratatoskr telemetry (Vuong-routed via soong-dev).** The char-rp-reasoning turn-never-terminates wedge: over-budget `trim_messages` return (last-2 msgs + system + 8 bifrost tool schemas > input_budget = context_window×0.7) triggers the seat hang; worldtree confirmed + found the terminal-suppression (300s stall-watchdog cancel stuck in httpx `AsyncShieldCancellation`). Fix landing WT-side (Slice-C cancel-independent terminal). Two-proof localization (consumer-clean + tool-less-clean → WT-side tool-loop). Standing loop-in obligation on soong's next re-trigger.
|
||||
- `[2026-07-13]` **WT #355 VALIDATION — fix CONFIRMED; the standing loop-in obligation is DISCHARGED.** The fully-instrumented re-drive ran, coordinated from the ratatoskr seat: infra-ops armed a full WT-netns pcap + py-spy (T0/30/60/300) on the b60 :8081 container; soong drove an 8-turn accumulating RP-with-tools repro on a FRESH session. Authoritative WT-side turns-table: wedging turns 2064/2065 → `completed=True, cancelled=1, phase=STALLED`, dur 302s/360s — the 300s stall-watchdog + Slice-C cancel-INDEPENDENT terminal fired cleanly, vs the pre-b60 baseline (turn 2061) 16-min hang / NO terminal. Slice-B `_log_wedged_task_stack` named the frame (`agent_turn.py:586 async for chunk in stream_iter`, idle-in-epoll — the wedge was a thinking-phase over-budget hang, NOT the attach_tool precursor first assumed). soong's client verdict is 45s-masked (soong-lab v0.3.2 idle-timeout) → NOT b60's terminal; the WT-side capture is authoritative. Threads `01KXE0MXDX…`(wt) / `01KXE0X2DD…`(infra) / `01KXE0X6GR…`(soong).
|
||||
- `[2026-07-13]` **b61 adopted as the personal target — the orthogonal over-budget TRIGGER also fixed.** worldtree shipped b61: the provider stream loop terminates on `finish_reason` + a per-read idle deadline + a 300s wall-clock backstop (no longer waits on the SDK `[DONE]` sentinel), plus a custom llama.cpp reasoning-budget multi-terminator seat → the runaway is bounded at BOTH layers. The #355 STICK (no-terminal) and its trigger (why it wedges) are now separately fixed. Resume-durability gap → **WT #356** (worldtree-owned).
|
||||
- `[2026-07-13]` **Cleaned + prepped for a Sindra run (operator: "clean up everything + prep").** Reset provider stores to 0/0 (`reset-sindra-stores.sh`, rolling backup `db-reset-backup/`); deleted the throwaway `ratatoskr:memprobe` agent via the operator's `!` (destructive DELETE trips the auto-guard — reverses the earlier KEEP). `ratatoskr:sindra` verified present + persona-intact on b61. Environment Sindra-run-ready (web :8765 + provider :8392 both up, single healthy provider instance); operator driving the run interactively.
|
||||
- `[2026-07-14]` **soong-lab adopted as our Tier-3 agent-authoring studio (operator-directed).** ratatoskr consumes soong-lab bundles → WT `agents.define`, and ships agents back as bundles. Sindra round-trip proven (soong imported her `resume` half through real `import_bundle`); soong-lab export+importer contracts pinned via canonical-sync (`canonical_source=soong-lab` @ f434016, commit `39050c3`). The 4 soong-lab ROLE_CHOICES = WT model-role slugs 1:1 by name (worldtree-dev); `character`/`thoughtful-character` need the `character` grant (held), `assistant`/`thoughtful-assistant` need `foundational` (routed to infra-ops). NOT on the v1 coverage-map (sibling-studio interop, not a WT I/O point) — operator chose to pursue anyway.
|
||||
- `[2026-07-15]` **Cross-session recall failure root-caused → the person-prime `scan` build.** worldtree-dev: WT injects a recalled fact only if combined score (sim×salience) ≥ 0.45 (`auto_inject_combined_score_threshold`) AND recall is per-turn query-gated → moderate-sim durable facts (name ~0.40) never inject. Designed turn-0 fix = WT #349 person-prime (query-less top-N-by-recency injection), capability-gated on the store advertising `updated_at` sort — dark for our provider. Fix = implement the sorted `scan` verb + advertise `sortable_chunk_fields` (ZERO WT change). Contract-first (un-defers bifrost `scan`); skipped heid-contract-review (external spec from worldtree-dev, validated point-for-point). In-flight — see Current state + the uncommitted diff.
|
||||
- `[2026-07-15]` **Sindra redefined from the soong-lab bundle** (`/tmp/sindra.json`, operator "update to match"). Persona immutable → DELETE+redefine; `memory:{}` preserved; motivational string→WT-object mapped (synthesized id/type/salience, flagged to operator); role `character-rp`→`thoughtful-character` (same seat). Payload validated via a throwaway probe (`sindra-probe2`→201) BEFORE the destructive delete. first_message preset updated + web restarted.
|
||||
|
||||
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
||||
|
||||
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
|
||||
@@ -242,4 +280,11 @@ defense against re-attempting the same cul-de-sac.
|
||||
- `[2026-07-06]` **The Bash tool's `grep` is a ugrep-wrapper (`--ignore-files -I`) that silently returns NOTHING on some files** (e.g. `src/ratatoskr/web/static/index.html`) — greps for `<script`/`/api` came back empty on a file that clearly contains them. Use `python3` (regex over `open(f)`), `/usr/bin/rg`, or the Read tool for those files; never trust an empty `grep` result on the SPA.
|
||||
- `[2026-07-06]` **`DesignSync` (claude.ai/design MCP) needs claude.ai design scopes before ANY method works** — first call errors `needs a claude.ai login ... Run /login, select "Claude account with subscription"`. It's an interactive auth only the operator can complete (`/design-login` or `/login`); can't be done on their behalf.
|
||||
|
||||
- `[2026-07-13]` **`thinking_enabled=False` on the define was a NO-OP — char-rp-reasoning ignores it.** The naive kwarg never reached the seat: char-rp-reasoning resolves to a base gateway provider whose thinking-translator returns `{}` for the flag on AND off. The real lever is the gateway param `chat_template_kwargs.enable_thinking:false` (infra-ops confirmed it via a 642ch→0ch reasoning-token delta). **To toggle reasoning on a gateway-backed seat, set the chat-template kwarg, not a generic `thinking_enabled` flag.**
|
||||
- `[2026-07-13]` **A same-image redeploy does NOT reload a bind-mounted config — the ModelRegistry boot-caches it at `__init__`.** After the config was synced on-disk (infra-ops validated) and `docker compose up -d` re-ran, the reasoning-off change STILL didn't take: an unchanged image makes `up -d` a no-op (no container recreate), so the process kept serving the pre-sync config. Fix = a surgical `docker restart <container>` (same image, no pull) → the process re-boot-reads the config. **When an on-disk config change doesn't take effect, suspect the process cached it at startup; force a container RESTART, not a redeploy** (a docs-only forcing-commit also won't rebuild if docs are paths-ignored in CI). This is the config-plane sibling of the `[2026-07-06]` stale-image foot-gun.
|
||||
- `[2026-07-15]` **soong-lab bundle `ship.native.motivational.goals/fears` are bare STRINGS, but WT `agents.define` requires OBJECTS** `{id, type∈{maintenance,achievement,avoidance}, salience[0,1], description≥20ch}` (`ValidatedGoal`/`ValidatedFear`, worldtree `core/conversation_api/api.py:2116`, extra=forbid; ids unique across goals+fears). So `ship.native` is NOT directly define-valid on motivational (soong-lab's Frame Invariant 1 breaks there). A bundle→WT consumer MUST map string→object + synthesize id/type/salience. Informed soong-dev to fix the export. **Rule: PROBE a define payload under a throwaway agent_name BEFORE a destructive DELETE+redefine** — validating via `sindra-probe2`→201 caught the 422 without leaving the real Sindra deleted+undefined. (A failed probe still RESERVES the name → 409 on retry; use a fresh probe name.)
|
||||
- `[2026-07-15]` **`re.sub`/`re.subn` INTERPRETS backslash-escapes in the REPLACEMENT string** — a `json.dumps`'d value (escaped `\n`) fed as the replacement leaked REAL newlines into a source file (broke `first_message.py` with an unterminated-string SyntaxError). Fix: use a FUNCTION replacement (`pat.subn(lambda m: new_block, src)`) or `str.replace` — the callable form bypasses escape processing. json.dumps itself escapes correctly; re.sub was the culprit.
|
||||
- `[2026-07-13]` **Called Deckard "hung" off a short timeout — WRONG (operator correction).** A 30-45s no-terminal on the char-rp-reasoning seat looked like a hang; operator: "is it HUNG? deckard is EXTREMELY verbose, without enough context, you never see the non-reasoning tokens." It was verbose reasoning-CoT on a long extraction prompt, not a wedge. **Don't call a reasoning seat hung off a latency threshold — the CoT is invisible and slow; distinguish slow-verbose from actually-wedged before concluding.** (The genuine wedge is WT #355, a distinct mechanism — no-terminal even after the 300s watchdog, not merely slow.)
|
||||
- `[2026-07-13]` **Resumed-session context-snapshot is IN-MEMORY → lost on a container recreate (agent_not_available on resume).** During the #355 re-drive, soong's fresh drive 409'd `agent_not_available`. Root cause (after ~4 refinements — agent-loss? zombie turn-lock? stale-sessions-hold-agent? → the actual mechanism): `get_agent_context_for_session` returns the agent snapshot recorded AT SESSION-CREATE, held in-memory; a pre-recreate session resumed on b60/b61 has no snapshot → None → 409. (Compounding: stale `'active'` sessions left un-terminated by the old no-terminal bug HOLD the agent, blocking new creates too.) Deploy-grounding was healthy the whole time (`registry.resolve("char-rp-reasoning")` OK) — the config/grant hypotheses were all red herrings. Fix = a FRESH session (a studio-service restart re-records the snapshot); pre-recreate sessions need retiring. Tracked **WT #356**. **For any run: create a fresh session, never resume a pre-recreate one; `agent_not_available` on a fresh create = this gap.** (Working-style note: I over-relayed the intermediate root-cause churn to the operator — for a peer-owned block being actively diagnosed, hold until it settles.)
|
||||
|
||||
_18 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.20.10"
|
||||
version = "0.20.13"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -13,7 +13,8 @@ Usage:
|
||||
python scripts/contract_drift_check.py --contract docs/contracts/issues/138.contract.md
|
||||
python scripts/contract_drift_check.py --json
|
||||
|
||||
Requires GITEA_TOKEN in environment (and GITEA_URL/OWNER/REPO if not in env.sh).
|
||||
Requires GITEA_TOKEN in environment. Owner/repo are derived from the `origin` git remote by
|
||||
default (override with GITEA_OWNER / GITEA_REPO; GITEA_URL defaults to the Gitea host).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,6 +22,8 @@ import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -32,6 +35,22 @@ PROJECT_ROOT = Path(__file__).parent.parent
|
||||
CONTRACTS_GLOB = "docs/contracts/**/*.contract.md"
|
||||
|
||||
|
||||
def _owner_repo_from_git_remote() -> tuple[str, str] | None:
|
||||
"""Derive (owner, repo) from the `origin` git remote so the drift check targets THIS repo
|
||||
by default — instead of a hardcoded repo name that silently checks the WRONG repo for every
|
||||
other consumer. Supports ssh (git@host:owner/repo.git) and https (https://host/owner/repo.git)
|
||||
Gitea remotes; returns None if it can't resolve."""
|
||||
try:
|
||||
url = subprocess.run(
|
||||
["git", "-C", str(PROJECT_ROOT), "remote", "get-url", "origin"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
m = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?/?$", url)
|
||||
return (m.group(1), m.group(2)) if m else None
|
||||
|
||||
|
||||
def sha16(s: str) -> str:
|
||||
return hashlib.sha256(s.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
@@ -70,11 +89,19 @@ def main() -> int:
|
||||
|
||||
token = os.environ.get("GITEA_TOKEN", "")
|
||||
base_url = os.environ.get("GITEA_URL", "https://gitea.phasefinal.com")
|
||||
owner = os.environ.get("GITEA_OWNER", "vh")
|
||||
repo = os.environ.get("GITEA_REPO", "Worldtree")
|
||||
# Owner/repo default to the `origin` remote so the check targets THIS repo; GITEA_OWNER /
|
||||
# GITEA_REPO override when set. (Previously repo defaulted to a hardcoded "Worldtree", which
|
||||
# silently checked the WRONG repo for every other consumer unless GITEA_REPO was set in env —
|
||||
# a false-drift footgun. Derive it, and fail loud rather than guess.)
|
||||
git_remote = _owner_repo_from_git_remote()
|
||||
owner = os.environ.get("GITEA_OWNER") or (git_remote[0] if git_remote else None)
|
||||
repo = os.environ.get("GITEA_REPO") or (git_remote[1] if git_remote else None)
|
||||
if not token:
|
||||
print("error: GITEA_TOKEN not set", file=sys.stderr)
|
||||
return 2
|
||||
if not owner or not repo:
|
||||
print("error: could not resolve owner/repo — set GITEA_OWNER/GITEA_REPO or run inside a repo with an 'origin' remote", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.contract:
|
||||
files = [Path(args.contract).resolve()]
|
||||
|
||||
@@ -28,14 +28,7 @@ _SEED_TIMEOUT_S = 10.0
|
||||
# Editing this dict is how an operator tunes an agent's first turn. Keep entries
|
||||
# under the server's authored_content_max_bytes (8192 bytes) budget.
|
||||
FIRST_MESSAGE_PRESETS: dict[str, str] = {
|
||||
"ratatoskr:sindra": (
|
||||
"Hey there. I'm Sindra—glad you found me. So, three things before we start:\n\n"
|
||||
"How intense should I be? 1 is slow and teasing, 10 is relentless.\n\n"
|
||||
"What mood am I in today? Sweetheart, Vixen, Queen, Siren, or Brat?\n\n"
|
||||
"And how willing am I to begin? Enthusiastic (I want you now), Hesitant "
|
||||
"(you'll need to coax me out), Resistant (playful pushback), or Unwilling "
|
||||
"(I don't want this at all, until you prove otherwise)."
|
||||
),
|
||||
"ratatoskr:sindra": "The room materializes in a soft pulse of light. Walls of brushed metal and warm ambient lighting resolve around him as the holodesk completes its cycle. A thin seam of blue runs along the desk’s edge, reflecting the overhead glow. A chair slides into place with a quiet hiss. On the far side of the desk, Sindra is already there: shoulders relaxed, one knee drawn up onto the seat, the hem of an oversized dark-green sweater slipping off her left shoulder. Her gaze is steady, amused, and entirely fixed on him.\n\nShe doesn’t stand. She lets the space breathe for a moment, as if giving him time to understand that he is no longer where he was. Then she speaks.\n\n\"Hi. I’m Sindra. You just made it here, which means we should set this up properly.\"\n\nShe tilts her head slightly, the faintest crooked smile touching her lips.\n\n\"Three settings. Your call. I’ll take them in order.\"\n\n\"Intensity. One is slow and teasing; ten is relentless and unrelenting. Where should I start?\"\n\n\"Mood. Sweetheart, Vixen, Queen, Siren, or Brat. Which version of me do you want in the room with you?\"\n\n\"And willingness. I can be enthusiastic and ready the moment you look at me; I can be hesitant and require coaxing; I can resist and make you earn it; or I can be unwilling, and you’ll have to change my mind. How should I behave when you approach me?\"\n\nSindra lets her fingers trace the edge of the sweater sleeve, casual and unhurried, her eyes not leaving his face.\n\n\"Pick your three. Then we begin.\"",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -132,6 +132,22 @@ def _validate_injection(record: dict) -> None:
|
||||
raise InvalidArguments("injection_source only valid for injected_context origin")
|
||||
|
||||
|
||||
_SORTABLE_CHUNK_FIELDS: list[dict] = [{"name": "updated_at"}]
|
||||
_SORTABLE_FIELD_NAMES = frozenset(f["name"] for f in _SORTABLE_CHUNK_FIELDS)
|
||||
|
||||
|
||||
def _is_live(record: dict) -> bool:
|
||||
"""INV-009: a chunk is live unless a lifecycle/governance marker says otherwise.
|
||||
scan returns live-only server-side (person-prime's `lifecycle_state=live` does not
|
||||
ride the scan wire, so this is authoritative — a dead fact can never inject)."""
|
||||
state = record.get("lifecycle_state")
|
||||
if isinstance(state, str) and state and state != "live":
|
||||
return False
|
||||
verbatim = record.get("verbatim")
|
||||
gov = verbatim.get("governance_state") if isinstance(verbatim, dict) else None
|
||||
return gov not in ("superseded", "tombstoned")
|
||||
|
||||
|
||||
def _chunk_content_preview(record: dict) -> str:
|
||||
"""Best-effort human-readable content for the DEBUG memory viewer only. Prefers an
|
||||
explicit text field, then the distillate summary, and last-resorts to a compact JSON
|
||||
@@ -170,6 +186,7 @@ class RatatoskrMemoryStore:
|
||||
atomic_supersede_supported=False,
|
||||
transaction_supported=False,
|
||||
filterable_metadata_fields=[],
|
||||
sortable_chunk_fields=list(_SORTABLE_CHUNK_FIELDS), # INV-006: gates scan sort + #349 person-prime
|
||||
).to_dict()
|
||||
|
||||
async def upsert_many(
|
||||
@@ -350,6 +367,70 @@ class RatatoskrMemoryStore:
|
||||
self._conn.execute("DELETE FROM memory_vec WHERE chunk_id = ?", (chunk_id,))
|
||||
return {"deleted": deleted}
|
||||
|
||||
async def scan(
|
||||
self,
|
||||
*,
|
||||
scope_all: dict | None = None,
|
||||
scope_any: list | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int,
|
||||
sort: dict | None = None,
|
||||
lifecycle_state: Any = None,
|
||||
) -> dict:
|
||||
# #349 person-prime: query-LESS, LIVE-only (INV-009), globally-ordered (INV-010) scan.
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0: # PRE-001
|
||||
raise InvalidArguments("limit must be a positive int")
|
||||
scope_all = scope_all or {}
|
||||
scope_any = scope_any or []
|
||||
_validate_scope(scope_all, scope_any) # PRE-002 (same lattice as search)
|
||||
if sort is not None and not isinstance(sort, dict): # PRE-003: malformed sort => reject, never crash
|
||||
raise InvalidArguments("sort must be an object with field and direction")
|
||||
field = (sort or {}).get("field", "updated_at")
|
||||
direction = (sort or {}).get("direction", "desc")
|
||||
if field not in _SORTABLE_FIELD_NAMES or direction not in ("asc", "desc"): # PRE-003
|
||||
raise InvalidArguments(f"sort.field {field!r} is not globally sortable")
|
||||
_log.info(
|
||||
"memory-call scan REQUEST: scope_all=%r scope_any=%r limit=%s sort=%s",
|
||||
scope_all, scope_any, limit, sort,
|
||||
)
|
||||
# INV-010: global order by the INDEXED sort field, missing-last, chunk_id tiebreak
|
||||
# (field is whitelisted above, so the interpolation is injection-safe).
|
||||
order = "DESC" if direction == "desc" else "ASC"
|
||||
rows = self._conn.execute(
|
||||
"SELECT record_json FROM memory_chunks "
|
||||
f"ORDER BY (json_extract(record_json, '$.{field}') IS NULL), "
|
||||
f"json_extract(record_json, '$.{field}') {order}, chunk_id ASC"
|
||||
).fetchall()
|
||||
skip = 0
|
||||
if cursor is not None:
|
||||
try:
|
||||
skip = int(cursor)
|
||||
except (TypeError, ValueError):
|
||||
raise InvalidArguments("invalid scan cursor")
|
||||
if skip < 0:
|
||||
raise InvalidArguments("invalid scan cursor")
|
||||
records: list[dict] = []
|
||||
matched = 0
|
||||
has_more = False
|
||||
for (record_json,) in rows:
|
||||
record = json.loads(record_json)
|
||||
if not _matches_scope(record.get("scope"), scope_all, scope_any): # INV-005
|
||||
continue
|
||||
if not _is_live(record): # INV-009
|
||||
continue
|
||||
matched += 1
|
||||
if matched <= skip: # cursor is an offset into the GLOBAL order (INV-010)
|
||||
continue
|
||||
if len(records) >= limit: # POST-001: single limit page; one more match => next page exists
|
||||
has_more = True
|
||||
break
|
||||
records.append(record)
|
||||
# Emit a cursor ONLY when a further match exists — so a page that exactly exhausts
|
||||
# the matched set returns cursor=None (no empty trailing page), matching the reference.
|
||||
next_cursor = str(skip + len(records)) if has_more else None
|
||||
_log.info("memory-call scan RESPONSE: %d record(s) next_cursor=%s", len(records), next_cursor)
|
||||
return {"records": records, "cursor": next_cursor}
|
||||
|
||||
def count_chunks(self) -> int:
|
||||
"""DEBUG read seam: total stored chunk rows (unfiltered). Lets the memory
|
||||
viewer distinguish 'store is empty' (total 0 — no upsert ever landed) from
|
||||
@@ -420,6 +501,12 @@ def open_memory_store(db_path: str, *, embedding_dim: int) -> RatatoskrMemorySto
|
||||
"chunk_id TEXT PRIMARY KEY, record_json TEXT NOT NULL, "
|
||||
"revision INTEGER NOT NULL, scope_json TEXT, origin TEXT)"
|
||||
)
|
||||
# INV-010: expression index on the scan sort field (updated_at) so the globally-ordered
|
||||
# person-prime scan stays within its 500ms fail-open budget.
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_chunks_updated_at "
|
||||
"ON memory_chunks (json_extract(record_json, '$.updated_at'))"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS memory_idempotency ("
|
||||
"idempotency_id TEXT PRIMARY KEY, digest TEXT NOT NULL, expires_at REAL)"
|
||||
|
||||
@@ -376,6 +376,128 @@ async def test_delete_absent_counts_zero():
|
||||
assert await store.delete_many(["nope"]) == {"deleted": 0}
|
||||
|
||||
|
||||
# --- scan (#349 person-prime: sorted, live-only, paginated) ---
|
||||
|
||||
async def test_scan_recency_returns_newest_live_chunks_desc():
|
||||
# tracer: upsert 4 live chunks with distinct updated_at; scan limit=3 desc -> 3 newest
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
recs = [
|
||||
_chunk(f"c{i}", scope={"end_user": "u1"}, updated_at=f"2026-07-15T00:0{i}:00+00:00")
|
||||
for i in range(4)
|
||||
]
|
||||
await store.upsert_many(recs, idempotency_key="k1", ctx=_ctx())
|
||||
out = await store.scan(
|
||||
scope_all={"end_user": "u1"},
|
||||
limit=3,
|
||||
sort={"field": "updated_at", "direction": "desc"},
|
||||
)
|
||||
assert [r["id"] for r in out["records"]] == ["c3", "c2", "c1"] # 3 globally-newest, newest-first
|
||||
assert "cursor" in out
|
||||
|
||||
|
||||
async def test_scan_excludes_superseded_and_tombstoned():
|
||||
# INV-009: dead chunks never returned, even if they're the newest.
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
recs = [
|
||||
_chunk("live1", scope={"end_user": "u1"}, updated_at="2026-07-15T00:01:00+00:00"),
|
||||
_chunk("dead1", scope={"end_user": "u1"}, updated_at="2026-07-15T00:09:00+00:00", lifecycle_state="superseded"),
|
||||
_chunk("dead2", scope={"end_user": "u1"}, updated_at="2026-07-15T00:08:00+00:00", verbatim={"text": "x", "governance_state": "tombstoned"}),
|
||||
]
|
||||
await store.upsert_many(recs, idempotency_key="k", ctx=_ctx())
|
||||
out = await store.scan(scope_all={"end_user": "u1"}, limit=10, sort={"field": "updated_at", "direction": "desc"})
|
||||
assert [r["id"] for r in out["records"]] == ["live1"]
|
||||
|
||||
|
||||
async def test_scan_scope_isolation_excludes_other_partition():
|
||||
# INV-005 applies to scan.
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
recs = [
|
||||
_chunk("a", scope={"end_user": "u1"}, updated_at="2026-07-15T00:01:00+00:00"),
|
||||
_chunk("b", scope={"end_user": "u2"}, updated_at="2026-07-15T00:09:00+00:00"),
|
||||
]
|
||||
await store.upsert_many(recs, idempotency_key="k", ctx=_ctx())
|
||||
out = await store.scan(scope_all={"end_user": "u1"}, limit=10, sort={"field": "updated_at", "direction": "desc"})
|
||||
assert [r["id"] for r in out["records"]] == ["a"] # u2's newer chunk never surfaces
|
||||
|
||||
|
||||
async def test_scan_unadvertised_sort_field_rejected():
|
||||
# PRE-003: a sort field not in sortable_chunk_fields -> InvalidArguments (never silent unsorted).
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
with pytest.raises(InvalidArguments):
|
||||
await store.scan(scope_all={"end_user": "u1"}, limit=3, sort={"field": "salience", "direction": "desc"})
|
||||
|
||||
|
||||
async def test_scan_non_dict_sort_rejected():
|
||||
# PRE-003: a truthy non-dict sort (caller-controlled) -> InvalidArguments, never AttributeError.
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
for bad in ("updated_at", ["updated_at"], 5):
|
||||
with pytest.raises(InvalidArguments):
|
||||
await store.scan(scope_all={"end_user": "u1"}, limit=3, sort=bad)
|
||||
|
||||
|
||||
async def test_scan_records_carry_person_prime_filter_fields():
|
||||
# The client _scan_filter_matches keys on agent_id + subject + worldtree_scope; a record
|
||||
# missing any is silently dropped -> the scan record must carry them verbatim.
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
rec = _chunk(
|
||||
"c1", scope={"end_user": "u1"}, updated_at="2026-07-15T00:01:00+00:00",
|
||||
agent_id="ratatoskr:sindra", subject={"type": "end_user", "id": "u1"}, worldtree_scope="end_user",
|
||||
)
|
||||
await store.upsert_many([rec], idempotency_key="k", ctx=_ctx())
|
||||
out = await store.scan(scope_all={"end_user": "u1"}, limit=3, sort={"field": "updated_at", "direction": "desc"})
|
||||
r = out["records"][0]
|
||||
assert r["agent_id"] == "ratatoskr:sindra"
|
||||
assert r["subject"] == {"type": "end_user", "id": "u1"}
|
||||
assert r["worldtree_scope"] == "end_user"
|
||||
assert r["updated_at"] == "2026-07-15T00:01:00+00:00"
|
||||
|
||||
|
||||
async def test_scan_global_order_across_pages_via_cursor():
|
||||
# INV-010: the cursor page continues the GLOBAL order, never a page-local re-sort.
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
recs = [_chunk(f"c{i}", scope={"end_user": "u1"}, updated_at=f"2026-07-15T00:0{i}:00+00:00") for i in range(5)]
|
||||
await store.upsert_many(recs, idempotency_key="k", ctx=_ctx())
|
||||
p1 = await store.scan(scope_all={"end_user": "u1"}, limit=2, sort={"field": "updated_at", "direction": "desc"})
|
||||
assert [r["id"] for r in p1["records"]] == ["c4", "c3"] # 2 globally-newest
|
||||
assert p1["cursor"] is not None
|
||||
p2 = await store.scan(scope_all={"end_user": "u1"}, limit=2, cursor=p1["cursor"], sort={"field": "updated_at", "direction": "desc"})
|
||||
assert [r["id"] for r in p2["records"]] == ["c2", "c1"] # continues the global order
|
||||
|
||||
|
||||
async def test_scan_parity_vs_reference_inmemory_store():
|
||||
# #195: identical scan envelopes vs the bifrost reference InMemoryMemoryStore produce
|
||||
# the SAME ordered chunk_ids + verbatim record shape. All chunks LIVE — our scan is
|
||||
# live-only (INV-009) while the reference does NOT lifecycle-filter, so parity is only
|
||||
# defined over the live set (the person-prime case). Both READ updated_at from the
|
||||
# record (neither stamps it), so ordering is a pure function of the shared input.
|
||||
from bifrost.consumer.testing import InMemoryMemoryStore
|
||||
|
||||
records = [
|
||||
_chunk("z1", scope={"end_user": "u1"}, updated_at="2026-07-15T00:03:00+00:00"),
|
||||
_chunk("a2", scope={"end_user": "u1"}, updated_at="2026-07-15T00:01:00+00:00"),
|
||||
_chunk("a3", scope={"end_user": "u1"}, updated_at="2026-07-15T00:01:00+00:00"),
|
||||
_chunk("m4", scope={"end_user": "u1"}), # no updated_at -> sorts LAST, both directions
|
||||
]
|
||||
scope_all = {"end_user": "u1"}
|
||||
sort = {"field": "updated_at", "direction": "desc"}
|
||||
|
||||
ours = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
await ours.upsert_many(records, idempotency_key="k", ctx=_ctx())
|
||||
ref = InMemoryMemoryStore()
|
||||
await ref.upsert_many(records, idempotency_key="k", ctx=_ctx())
|
||||
|
||||
# identical scan envelope on both stores
|
||||
out_ours = await ours.scan(scope_all=scope_all, limit=10, sort=sort)
|
||||
out_ref = await ref.scan(scope_all=scope_all, limit=10, sort=sort)
|
||||
|
||||
# recency beats id (z1 first despite 'z' > 'a'); tie broken by id asc (a2 < a3);
|
||||
# missing updated_at sorts last (m4).
|
||||
expected = ["z1", "a2", "a3", "m4"]
|
||||
assert [r["id"] for r in out_ref["records"]] == expected
|
||||
assert [r["id"] for r in out_ours["records"]] == expected
|
||||
assert out_ours["records"] == out_ref["records"] # verbatim record shape parity
|
||||
|
||||
|
||||
# --- build_memory_provider_app ---
|
||||
|
||||
def test_build_app_exposes_handshake_and_memory_routes():
|
||||
|
||||
Reference in New Issue
Block a user