Commit Graph

8 Commits

Author SHA1 Message Date
Eugene Eisenstein 158ed6ad16 feat(sandbox): give sandbox.sh a --help
The commands were documented in a header comment and in a one-line usage string
that only appeared when you ran the script with no arguments at all, so the
options and the environment variables were readable only by opening the file.

`--help`, `-h` and `help` now print the commands, both options, the four
environment variables that change ports and timeouts, and examples. It also
states the one rule that is otherwise surprising: seed and reset act through the
containers already running, so they refuse a --provider that disagrees with the
running stack.

The header comment now points at --help rather than repeating it, so there is
one copy to keep current. Running with no command prints the same text to stderr
and exits 1, since that is a mistake rather than a request for help, and unknown
commands and flags now name --help instead of listing what they expected.

Verified: --help, -h and help exit 0 on stdout; no arguments exits 1 on stderr;
`up --help` prints help and starts nothing; unknown flags and commands exit 1
pointing at --help; and --provider, --provider= and --build parse as before.
2026-09-03 19:39:38 -04:00
Eugene Eisenstein f5bd2b18b3 refactor(sandbox): name the seeder's records and enforce its lint
Cleanup pass over the sandbox tooling, no behavior change.

inject_conclusions.py carried three ad-hoc dicts and a positional 3-tuple as
records: the Honcho internals bundle, each fixture conclusion, and the per-pair
plan. They are now frozen dataclasses (Internals, SeededConclusion, PairPlan),
so the fields are named and PairPlan owns both the "which levels are derived"
filter and the "observer -> observed" label the messages repeat. inject_pair was
142 lines running two passes; the passes are now seed_explicit and seed_derived,
with the DocumentCreate construction in build_derived_document.

seed.py drops a parameter verify() never read, which basedpyright flags but
nothing enforced, because sandbox/ matched none of the pre-commit file scopes.
Added it to the ruff and ruff-format scopes so the directory is linted from now
on. basedpyright is left off it deliberately: 0 errors but 37 warnings, almost
all reportImplicitStringConcatenation from the multi-line failure messages.

fixture.json's comments drop a ticket reference and an internal project name,
keeping the technical point each was attached to. Also renamed the remaining
single-character loop variables.

Verified: ruff and ruff-format clean via pre-commit on the newly-scoped
directory, basedpyright 0 errors, a down/up from clean volumes seeding 4
explicit, 2 deductive and 1 inductive with 3 premise links traversing both ways,
and all five injector failure paths still refusing (unobserved peer, premise
index out of range, derived with no explicit, unknown item key, clean no-op).
2026-09-03 17:47:54 -04:00
Eugene Eisenstein 98fc68a3f3 fix(sandbox): stop the seed retrying non-idempotent writes
`up` intermittently failed with "expected 6 messages, found 12". Creating
messages is not idempotent, and the SDK retries on 429/500/502/503/504 as well
as on timeouts, so a write that commits and then errors on the way back is
replayed and the fixture is seeded twice. The window is the cold api that
cmd_seed has just restarted to apply migrations.

The seed now runs with max_retries=0. The sandbox is local and always seeds from
a cleared database, so a transient failure should stop and be re-run rather than
be papered over into a silently doubled fixture -- and the doubling is not
silent by luck alone: the message-count assertion is what caught it.

Verified: three consecutive down/up cycles from clean volumes, 6 posted and 6
verified each time.
2026-09-03 17:40:11 -04:00
Eugene Eisenstein 1536f4c446 fix(sandbox): stamp seeded derived conclusions with the session
They were written with session_name=None on the reasoning that a dream is not
scoped to one session. That is not what the dreamer does: dream_scheduler picks
a session (whichever holds the most recent explicit conclusion) and specialists
thread it through create_tool_executor into every observation written, so a
real-mode dream against this fixture produces derived rows carrying
sandbox-session. The seeded rows now match.

This deliberately does not change what a session-scoped read returns.
ALLOWLIST_SAFE_LEVELS in src/utils/representation.py serves only explicit under
a session allowlist whatever the stamp says, because the dreamer reads across
all sessions and scoping its output would leak conclusions synthesized from
outside the allowlist (DEV-2201). So representation(session=...) is
explicit-only by construction either way; the comment now says so, since the
alternative is someone concluding their seeded conclusions failed to land.

Verified: derived rows carry sandbox-session in SQL, seed and verify pass, and
representation(target="alice") without a session still returns all three levels.
2026-09-03 17:29:44 -04:00
Eugene Eisenstein 35e7b99846 feat(sandbox): seed conclusions at every reasoning level
The fixture could only get memory by running the deriver, and derived memory is
assertable in exactly one mode. Under the mock provider a conclusion's text is
synthetic and its level is always explicit, because the Dreamer's specialists
write via tool calls the mock never emits -- so the README had to say: assert
that conclusions exist, never what they say. That rules out content assertions
in the only mode that is free and deterministic, which is most of what a harness
test wants.

fixture.json now takes optional explicit/deductive/inductive keys per peer. The
peer carrying them is the observed and the observer is inferred from
observe_others, so the standard shape resolves to assistant -> alice, the pair
the dream already uses. Items are a bare string or {content, premises}, where a
premise indexes the same peer's explicit list. With no keys the sandbox behaves
exactly as before.

Level and premise links are not reachable from the public API:
crud.create_observations hardcodes level="explicit" with no source_ids, and
neither the schema nor the SDKs have a field for either. Widening the API was
rejected -- it would let any client assert a conclusion is deductive with
premises it invented. So inject_conclusions.py runs inside the api container,
which already is Honcho's venv with the api's settings and a live embedding
client; the script arrives on stdin and the fixture in the environment, so
nothing is mounted and nothing is left in the container. The cost is calling
internals out of a pinned image, so it checks the signatures it depends on and
says "bump image.env and update this script together" rather than half-seeding.

Premise indices become real source_ids pointing at the rows from the explicit
pass, so the tree actually traverses in both directions; synthetic ids would
satisfy the schema and resolve to nothing. Two passes suffice because premises
only ever cite explicit conclusions.

Every count is asserted exactly, because each failure mode here reduces a count
without raising: exact-content dedup is always on and cannot be disabled,
semantic dedup replaces rows, per-item embedding failures drop rows, and the
session-purity invariant skips session-less explicit rows. Conclusions on a peer
nobody observes is an error rather than a silent no-op. And because a broken
reasoning tree is invisible from outside -- the API does not expose source_ids
and get_reasoning_chain is a Dialectic tool, not a route -- the seeder reads its
own links back before the snapshot: every cited premise must be reachable from
its children, and the source_ids those children carry must all resolve.

seed.py splits into seed and verify phases so injection cannot mask the deriver.
The check that proves derivation ran is "some conclusion exists", which seeded
rows would satisfy on their own, so it now runs before anything is injected.
Verify then asserts seeded content and level exactly, in both modes, plus that
premise text renders in the representation. The fingerprint hashes the new file
too, or editing it would restore a stale template.

Verified from clean volumes in mock mode: up in 41.5s seeding 4 explicit, 2
deductive and 1 inductive alongside 4 derived, with 3 premise links traversing
both ways and every declared premise resolving in SQL; reset restores it in
0.89s. Negative cases all fail loudly: an unobserved peer, an out-of-range
premise index, derived conclusions with no explicit to cite, near-duplicate
content collapsing under dedup, a dangling premise id, and a premise sought in
the wrong collection. Real mode is unverified -- it needs a key and injection
touches the provider only through the embedding client.
2026-09-03 17:29:44 -04:00
Eugene Eisenstein 03fd460b3f fix(sandbox): refuse seed and reset through the wrong provider
Neither command recreates containers — seed uses `compose start` plus
`--no-recreate` to avoid paying a container rebuild, and reset touches no
container at all. So `--provider` on either could not change what the stack
actually talked to, only what got recorded: seed would derive through the
running provider and then stamp the requested one into the template, which
is exactly what the fingerprint guard was supposed to catch. It compares the
flag, not reality, so it matched.

The real-stack-seeding-a-mock-template direction is the damaging one. It
keeps real.env credentials on api and deriver, so it spends money, produces
non-deterministic conclusions, labels them `mock`, and every later
`reset --provider mock` restores those as the deterministic baseline that
mock mode exists to provide.

`up` now records the provider it created the stack with alongside the image
in .state.env, and seed and reset refuse a mismatch pointing at
`up --provider ...`. One mechanism covers both call sites and matches the
file's existing posture of refusing rather than misleading. A .state.env
with no provider line — written before this change — reads as unknown and
is allowed; the next `up` fills it in.

`status` also stopped reporting the flag as though it were the running
stack.

Verified against a live mock stack: a mismatch refuses from both seed and
reset and exits 1; a match resets normally; `up` from a real-recorded state
recreates, rewrites .state.env, and its internal reset does not trip the
guard it just armed (3.7s end to end).
2026-09-03 17:29:44 -04:00
Eugene Eisenstein 92df37ee60 chore(sandbox): pin the published image that carries the mock provider
The pinned digest predated src/mock_provider, so mock mode required
`up --build`. That module is now on main and published, so the sandbox runs
from a released image again and plain `up` works.

The module check stays. A digest without the module resolves and pulls fine,
then crash-loops one service, so the guard is still the difference between a
clear message and an obscure failure — its wording is just no longer tied to
an unmerged PR.

Verified against the new digest from clean volumes: `up` in 44s with no
--build, all three Honcho services on the pinned digest, 4 explicit
conclusions as documented; a junk peer added then `reset` in 1.1s restored
exactly the seeded state; and pinning the old digest still refuses to start
with the rewritten message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 17:29:44 -04:00
Eugene Eisenstein 846ecd19f6 feat(sandbox): committed ephemeral Honcho sandbox with one-command reset
A local Honcho that comes up already seeded and resets to that exact state in
under a second, so harness integration testing stops depending on machine state.

`docker-compose.yml.example` gave the topology but was an example, unseeded, and
had no teardown path, so every harness change was verified against whatever state
a given laptop was in. The blocker to fixing that was the deriver's provider call,
which made a sandbox neither free nor deterministic; src/mock_provider removes it.

Reset restores a Postgres template database rather than re-deriving: `seed`
snapshots the finished state, `reset` drops the live database and recreates it
from that snapshot, then flushes Redis. Nothing is stopped or restarted --
DROP DATABASE ... WITH (FORCE) evicts the connection pools and both services
reconnect on their own. Measured 0.86s in mock mode, 1.29s in real mode against
a 361s seed.

Two provider modes, both first class. Mock is the default: deterministic, free,
no egress. Real points the same stack at a configured provider, reading
credentials from one gitignored file rather than ambient environment. The
difference matters and is documented: mock embeddings are hash-derived and carry
no semantic similarity, so vector-recall assertions are impossible there, and
mock conclusions come out explicit-only. On the committed fixture, mock yields 4
synthetic explicit conclusions and real yields 22 across all three levels.

The sandbox is configured only by what Compose injects. PYTHON_DOTENV_DISABLED
and HONCHO_CONFIG_TOML_DISABLED are both set because src/config.py calls
load_dotenv(override=True) at import and the Dockerfile bakes any local
config.toml into the image; without them a developer's own provider config wins
silently. Deriver scheduling is pinned for the same reason: on stock settings a
sandbox seeded with a handful of messages produces zero conclusions and gives no
indication why, because work units wait for a 512-token batch or 30 minutes and
startup jitter delays the first poll by up to 30s.

Snapshots can go stale, so each carries a fingerprint -- Alembic revision,
fixture hash, provider mode -- and reset refuses on a mismatch instead of
restoring a state that predates a migration. Templates are per-mode, so both can
coexist.

Note: the pinned digest in sandbox/image.env predates the mock provider, so
`--build` is required until that lands and a new image is published. sandbox.sh
detects this and says so rather than crash-looping on a missing module.
2026-09-03 17:29:44 -04:00