Commit graph

359 commits

Author SHA1 Message Date
446adf7f2b
test(toolbox): a run id that carries a path must be refused, and write nothing
RED. Seven outbox doors compose `<out_dir>/<run_id>-<artefact>.json` and never look at the
run id, so `--outbox-dir <d>/inni --run-id ../../ESCAPE` writes two levels ABOVE the
directory the caller named and answers 0. The guard stood on one half of the path and was
missing on the other. Reproduced on write-prepass before this file existed.

The class is measured, not one case: all seven doors against four path-carrying forms --
relative-up, separator, bare `..`, absolute. Each arm asserts a non-zero exit AND that the
file tree under tmp_path is byte-unchanged afterwards; the first alone would be satisfied
by a door that refused after writing. An rc-0 control per door runs the same argv with a
clean run id, so the refusals measure the run id rather than a malformed call.

The file also carries the guard the `--stop-reason` rule never had. `required=True` is
called load-bearing in the ledger -- "the run finished" and "we never found out" must not
be the same value -- but swapping it for `default=""` passed the whole suite. The arm
fells that mutant: no flag is a usage error, and the empty string stays a value the caller
can supply on purpose.

Measured at this commit: 28 failed, 8 passed (the seven controls and the stop-reason arm).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 11:48:50 +02:00
d8541d583f
chore(release): v1.2.0 -- the toolbox surface, and a status block that says what it is not
The version moves 1.1.0 -> 1.2.0 in every place it is written: pyproject, the package
__init__, the smoke test that pins it, the README's wheel-install command (gate-pinned
against the build) and uv.lock -- five sites, plus the new CHANGELOG section. A new,
backward-compatible surface, no removals: fourteen run-path steps that had only ever been
reachable through a path that builds a chat client now each have a command of their own.

The README gains a status block at the top, and half of it is what v1 is NOT: no domain
expert has validated an output, three planned rounds of expert feedback were set aside
rather than passed, five of the eight kinds of feedback the design names have no typed way
back into a run, and the multi-agent debate is exercised in the suite only against scripted
clients. That half is the point -- a reader who installs this should learn the limits from
the first screen, not from a gate they have to run themselves.

Measured on this tree at this commit: suite 2238 passed / 0 failed / 5 skipped / 5 xfailed
(769 s), ruff check and mypy src clean, and both gates re-run after staging and unchanged
(v1 gate exit 1: 0/3 - 0/3 - 3/8 - no report - 3/8 - NOT MEASURED - 1/20; B gate exit 1:
15/17 - 0/2 - 15/15 - 0/3 - 4/5 - NOT MEASURED).

No tag is created here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 11:33:22 +02:00
c5aa5fcfde
test(b-gate): the pin moves 8 -> 15, and names the two steps that still share a door
`_EXTERNAL_TODAY` and `_WITH_A_DOOR_TODAY` are the arm's own count, not the gate's, so
the seven new doors have to be written down here before the suite agrees they exist. The
two assertions that pinned `kjørekonfig`/`utboks`/`prepass-artefakt`/`parse-feil` as
door-LESS are inverted rather than deleted: they were the arm's discriminator, and a
door that silently went away must still turn this red.

The manifest count moves 514 -> 515 for the one new probe file.

Also a probe for the shape guard: the writers take `Mapping`/`Sequence` and would
serialize whatever they were handed, so a list where an object belongs would become a
valid file with the wrong shape and the run that reads it later would be the one to
fail. The door refuses it as a 3 (the call parsed, so 2 is the wrong code) and leaves no
directory behind. Carries its rc-0 control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:48:16 +02:00
6375ce5af3
feat(toolbox): the seven outbox writers get their own door -- row 1 moves 8 -> 15 of 17
Every one of the run path's seven `outbox.write_*` steps was reachable only through
`run.main`, and every path through that builds a chat client. The steps need no model:
they take already-rendered data and put it on disk in a byte-deterministic form.

Seven subcommands, seven thin adapters. The outbox directory is always the caller's to
name -- never a default, never the repository's own, because a step that wrote into a
folder the framework also reads as an inbox would bypass the Step-8 promotion gate.

`write-outbox` is the one that is not purely mechanical: `outbox.write_outbox` branches
on the outcome TYPE, so a door that took the outcome as an argument would let anyone
author an outbox of claims and hand it to Step 8 as results. The door DERIVES it through
`validate_proposal` -- the run path's own composition -- and a blocked proposal exits 3
with the artefacts still written, since that is where the rejection is recorded.
`verdict_id` stays an argument: `verdict-key` already owns that minting.

`--stop-reason` is required rather than defaulted to the empty string, inheriting the
core writer's measured reason: "the run finished" and "we never found out" must not be
the same value.

Eight probes, each a subprocess with the subcommand in argv, each asserting on the FILE
the command wrote. The ground truth is composed in the test -- the payload it wrote and
counted itself, and the byte form the contract requires -- never `outbox._dump`, which
would have measured the module against itself. The refusal arm carries its rc-0 control.

Measured, own run of the B gate: row 1 8 of 17 -> 15 of 17, exit 1 unchanged, no other
row moved. 0 chat-client names reachable from the toolbox (known-positive control: 24 in
run.py).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:32:12 +02:00
10cef82f7b
test(b-gate): the contract binds the three judgement steps, and the pin moves 5 -> 8
The entries for validering / verdict-id / dom were console-script pointers at run.py::main, which
the gate itself refused with a named reason: the way from that main() requires a chat client.
They now point at the toolbox subcommand that reaches each symbol, with the probe that drives it.

Measured, same denominator both ways, no other row moved:
  row 1  5 av 17  ->  8 av 17   (RED both ways)
  row 2  0/2 · row 3  15/15 GREEN over 514 published files · row 4  0/3 · row 5  4/5 · row 6 NOT
  MEASURED -- unchanged, exit 1 unchanged.

_EXTERNAL_TODAY follows the measurement, and _WITH_A_DOOR_TODAY names WHICH eight, so a door that
moves without being built shows up as a red test rather than as a different number. The arm that
asserted "validering has no door" had to pick a step that still does not: kjorekonfig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:08:48 +02:00
368367e1c5
feat(toolbox): the judgement through the same door -- validate-proposal, verdict-key, capture-verdict
B's premise applied to the three steps that DECIDE a proposal: a proposal authored outside po --
by a human, or by an agent that is not po -- now meets the blocking deterministic gate, mints the
learning key, and is captured as a Verdict, all without a chat client on the way.

Three thin adapters, no second implementation. The reason is the one the first four doors were
built on, but it bites harder here: the refusal SENTENCE is fed back verbatim into the next
attempt by step 5, so a door that reworded it would break the repair loop while still looking
correct. The probes assert the sentence, not a substring two stages share.

One measurement decided a design detail. The IR writes whole magnitudes as JSON integers
(30000), the run path carries the pydantic float, and verdicts._mint_id hashes the raw value --
so minting from the undeclared JSON would hand out a DIFFERENT verdict id than the debate does
for the same proposal. The door therefore reads the proposal through SavingsProposal and feeds
model_dump() to the public features_from_ir; the probe pins both forms and asserts they differ,
so the shortcut cannot come back silently.

A blocked proposal exits 3, carrying the verdict rather than an exception envelope. "You asked
right and the answer is no" is the same fact whether a file was missing or a claim was
infeasible, and a caller that only reads the exit code must not see a blocked proposal as a
cleared one.

Fasit outside the door in every arm: the base's own checked-in golden suite (written before the
toolbox existed, so it cannot have been fitted to it), a cost baseline authored in the test, a
method cap computed by hand from the fixture, and the public minting rule. Each refusal arm has
an rc-0 control on an argv that would otherwise be accepted.

STATED LIMIT: the input-grounding stage (P7, stage 0b) has no flag here. It falsifies a proposal
against the rendered prompt the model received, and an outside caller has no such prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:08:37 +02:00
f430b790a3
fix(b-gate): a heading is not a section -- the rest of N3, and one rewrapped ledger line [skip-docs]
The measured escape: a runbook that was the five contract-named headings plus "x" (125 characters
in all), with a correctly checksummed attestation, read `2 av 2 GRØNN`. Only a file whose whole
content was "x" had been refused -- the rule asked whether the section NAME appeared in the text,
never whether anything stood under it. A table of contents is not a runbook anyone can follow.

Section names are now bound to a HEADING line, and what counts is the body beneath it
(`section_bodies`, 80 non-whitespace characters as a floor). The row says so itself, and says what
the floor is not: a length is never a measure of whether the runbook is true. That stays the
operator's, which is why row 6 is still `IKKE MÅLT`.

Two rc-0 controls in the suite carried section bodies of "noe" and "steg 1: naviger pakken" -- both
would now fail on the new rule rather than on the rule they were written for, so both got a real
body. A control that falls on the wrong rule has stopped controlling.

Also: the one very long line in the ledger (the arm name mid-paragraph) rewrapped. Cosmetic, named
in the 2026-09-20 checkpoint's leftovers.

NOT taken from that same list: freezing the outbox shape so the round-builder's denominator arm
stops skipping outside a tree with `scratchpad/`. It needs a checked-in fixture of the measured
form, which is not the "only if cheap" the order allowed, and the checkpoint proposed no round for
it either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 09:05:08 +02:00
6ca4c7eb9f
test(b-gate): two arms the mutation run demanded -- dead code around a door call
M1 (`if False:` counted as live) and M2 (code after `return` counted as live) both SURVIVED the
whole suite. The pruning was real and nothing reached it: every dishonest form the arms already
carried fell on the data-dependence rule first, so removing the pruning changed no verdict.

The escape the pruning actually closes needs a probe that is GREEN in pytest: run a real process
that is NOT the door, bind the name the assert reads, and put the door call in dead code under
the same name. The taint then arrives from a line that can never run, and the probe "proves" a
step it never touched. Both forms are planted here, each with the honest rc-0 control beside it.

[mutation-ok] -- no guard is switched off. The `if False:` in this diff is INSIDE a Python string
that the arm writes to a tmp_path file; the gate only PARSES that file, never runs it, and the
arm asserts the form gives 0 of 1. The commit is the arm that makes dead-code pruning fall when
it is removed.

Found by mutating, not by reading. A rule no mutant can kill is a rule the suite has not measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 08:30:01 +02:00
a3f9df5251
test(b-gate): the published surface is 514, because this delivery put two files on it
`_PUBLISHED_TODAY` is the row-3 denominator, pinned in the suite against `git ls-files`. It read
512 and the arm was green in the worktree only because `toolbox.py` and `test_toolbox_doors.py`
were still untracked -- the clone measured 514 the moment they were committed. The pin is what it
is for: a surface that grows without anyone noticing is a surface the guard has not measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 08:14:31 +02:00
38df79126f
feat(toolbox): the first four doors out of the toolbox, without a chat client on the way
B-gate row 1's premise, made callable. Every path through the framework CLI constructs a chat
client, so an outside caller -- a human at a terminal, or an agent that is NOT po -- could not
reach a single run-path step without paying for a model. These four steps need no model at all.

One CLI, four subcommands, one core call each:

  navigate-bundle  --bundle-dir                         -> okf.navigate_bundle
  cost-baseline    --bundle-dir --project-id            -> okf.derive_cost_baseline
  retrieve-chunks  --query --docs-dir [--top-k]         -> datasource.retrieve_chunks
  prepass-admit    --payload --bundle-dir [--dimension] -> prepass.admit_payload

Each handler is a thin adapter: strings in, the SAME function the run path calls, JSON on stdout,
and an exit code that says what happened (0 ran, 2 malformed call, 3 the step refused, named).
A handler that computed anything of its own would be a second implementation of a run-path step,
and the outside caller would stop getting what the debate gets.

Dispatch is an explicit branch per command, not argparse's `set_defaults(handler=...)`: the table
hides the one thing a reader wants to see, and B-gate row 1 asks the same question of the source
(it walks the call graph from `main` down to the step's symbol), where a callable in a Namespace
is a hop neither can follow.

Probes (`tests/test_toolbox_doors.py`, 10 arms): each starts the door as a SUBPROCESS with the
subcommand in argv and asserts on what it wrote -- never by importing the core function, which is
the whole difference the gate exists to measure. The yardstick is outside the door in every arm:
the filesystem (navigate-bundle, including the one deliberate outside-bundle link), a table
transcribed from the priced fixture (cost-baseline), the in-process seam it must equal byte for
byte (retrieve-chunks), and the producer's own checked-in payload (prepass-admit). Every refusal
arm has an rc-0 control beside it.

`portfolio-optimiser-toolbox` is the THIRD console script, and the pin test now says why: it is
the door the other two cannot be used for. README and CLAUDE.md updated with the command and the
reason it exists; every documented invocation was run.

Row 1: 1 -> 5 of 17 (four subcommands + `gate`, which the class fix in e47be68 stopped rejecting
on a name technicality). No other row moved; exit 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 08:11:58 +02:00
e47be68b57
fix(b-gate): the binding measures an execution, and the row-3 sentence stops counting doors
Third repair of the same failure, and the first that does not read a NAME. The judge measured
row 1 to 17 of 17 with 17 one-line probes and a door module without a single import: "the probe
touches the door" was still a text check, one notch further in.

What is measured now, in the probe's own source: an EXECUTION SITE (a process starter carrying
the door's name in its arguments, or the entry imported from the door's module and called), and
an assert that is DATA-DEPENDENT on that call. Dead code is pruned first -- a call in a branch
that never runs, or after a return, is no execution. The honest forms the suite already uses
keep counting: the command built in a variable first, and the subprocess started in a helper
that returns it.

All seven forms the judge measured (a1, a2, b1, b2, c1, c2 + the one-liner recipe) now give
0 of 1, each with an rc-0 control in the same setup.

[mutation-ok] -- no guard is switched off. The two `if False:` lines in this diff are the
judge's b1 probe form planted as test DATA (a Python string written into a tmp_path file the
gate only PARSES, never runs) and the docstring naming why dead code is pruned. The commit adds
the rule that makes `if False:` around a door call stop counting.

Naming the step becomes a DISCRIMINATOR, as a class: it is required only when more than one step
stands behind the same door. Driving a door with a sole step IS driving that step -- which is why
`gate` (a real end-to-end door probe against v1_gate) was rejected on a name technicality. Row 1:
0 -> 1 of 17. For a subcommand door the command name must stand in what was actually executed,
and the CLI itself must be registered or `-m`-runnable: an add_parser in a module nobody can
start is no door.

held_out: a reason must be named prose. "-", "todo", "x", "." were all accepted as a reason by
`.strip()`; measured against the contract's own 28 reasons (shortest: 30 chars, three words).

Row 3: the five forms the checkpoint measured as open AND cheap to close are closed -- importlib
for both SDK names, deno/npm exec/yarn dlx, and the official TypeScript SDK. 12 of 12 -> 15 of 15,
still 0 hits over 512 published files. The limit sentence stops enumerating what remains ("EXACTLY
TWO", then "THREE" -- both falsified by the first new measurement) and says what the guard IS.

N7 stays open in the gate and is closed in the suite: a deleted off-path step still shrinks the
denominator silently there, so the four are written out in an arm that goes red when one goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 07:48:34 +02:00
1b8b42e515
docs(invarianter): the five other types, as the outboxes actually lie [skip-docs]
Third writing of the same sentence, third time untrue. Measured in this working tree, by the arm
committed red before this one: FOUR of the five types outside the measured seven do not sit in an
outbox that also holds a coverage -- and for `plan-review` that says nothing at all, because the
type has 0 artefacts in the repo. `multibase` DOES sit there, in four outboxes
(`p17b-multibase/`, `p20-stress/`, `p21-stress/`, `p22-stress/`, all `lindaas`), and each of those
four holds TWO coverage files. That is why they fall outside the "exactly one coverage" rule the
union of seven is counted over -- and the same fact is the proof that seven is a FLOOR and not a
ceiling, which is the argument the false sentence was trying to make.

The comment above `_RUN_LEVEL_TYPES` carried the same claim in English and is corrected with it.
The arm now counts the outboxes and holds the ledger's row to the count, so a fourth writing is a
failing test rather than a review finding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:36:32 +02:00
93b6def598
test(round-builder): the stage the report names, and a denominator sentence that counts
RED FIRST, and only one of these can be red in the ordinary way: the ledger's own sentence about
the five artefact types outside the measured seven is UNTRUE, so the new arm that counts the
outboxes fails on ASSERT (1 of 44 arms red, 0 on ImportError/AttributeError/collection). The
other changes fix ARMS, not code -- src/ is untouched -- so their discrimination is shown by the
mutant campaign in the next commit's message: surviving before, felled after.

- `_STAGE_SENTENCES`: stage -> (the clause the refusal heading opens with, a word only that
  stage's explanation carries), written out here the way `_STATUS_WORDS` is. The arm that names
  where an approach fell built its expected heading from `rb.STAGE_PROSE` and then agreed with
  it: PM's mutant P14 (the sentences for `stage4-p90` and `stage0b-grounding` swapped) passed all
  43 arms, and the report then said an approach fell on the grounding when it fell on the
  uncertainty calculation. Both halves are compared against this file's table, on the ONE heading
  line located by prefix, and the whole table is covered -- not only the two stages the fixture
  exercises, because a fixture is not a denominator.
- That each mark belongs to exactly ONE stage is COUNTED in the test, so swapping explanations
  alone is a failure too.
- `_section(report, heading)`: every `split("## ...")[1]` in the file goes through it. Renaming a
  heading failed four arms on 19.09, three of them on IndexError -- a traceback that says a list
  was too short, not that the report lost the section the expert reads first.
- New arm: `multibase` DOES lie in outboxes that also hold coverage (four of them, two coverage
  files each). The arm counts the outboxes and requires the ledger's sentence to say what the
  count says; the false clause is pinned verbatim so its fourth writing is a failing test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:33:28 +02:00
6a8106a897
test(b-gate): the mutant's needle is the door's name BARE, not the door's name in a sentence
First attempt at M12 still passed 86 of 86 -- the arm planted a docstring that
MENTIONED the module in prose, and the gate compares whole strings, so prose never
reached the check either way. The arm proved something already true and left the
mutant alive.

The cheapest cheat is the bare name: a string that IS the dotted module, standing
alone. That is what the arm plants now, and the same for the step's symbol.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:08:14 +02:00
75cb142252
test(b-gate): the mutant that survived -- a docstring that NAMES the door is not touching it
Mutant M12 against the delivered tree: let _meaningful return the whole body, so a
string standing alone counts. 85 of 85 GREEN. That is a finding, not a pass: the
repair's own rule -- "the gate never reads a string that stands alone" -- had no arm
watching it, and without one the fix would have drifted back to "mentioning the door
is touching it", which is the same defect one floor in.

The arm carries its rc-0 control first (a real probe WITH a docstring still counts),
then breaks each half on its own: a probe whose docstring names the real door while
its call drives another module, and one whose docstring names the step while its call
passes another word. Both must be refused, each with its own reason.

12 of 12 mutants now felled, control 86 of 86.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:04:55 +02:00
9eec31b7da
fix(b-gate): a probe is bound to its step, a reason cannot be empty, and the row's limit is what was measured [skip-docs]
Row 1 goes 3 of 17 -> 0 of 17. Nothing was removed from the product and no row
changed colour; the three that counted stopped counting because the contract they
satisfied was satisfiable without capability.

BEARING 1 -- the probe is bound to the step, and the binding is MEASURED in the
probe's own source. Chosen: read the probe (ast) rather than demand it live in a
contract-named file, because a file name is a convention a stub meets as easily as a
real probe. Three traits, each measured: it drives the DOOR (the dotted module or the
registered command name appears as a string it uses -- anywhere but a docstring,
because the honest form assembles argv in a variable first), it names the STEP (the
symbol, id or subcommand as a whole WORD in what it passes INTO a call or calls), and
it asserts at all. A probe claimed by two steps proves at most one and the gate cannot
tell which -- so neither.

Two traps found while measuring, both closed:
  - substring: "gate" is not named by portfolio_optimiser.evals.v1_gate
  - local alias: the first cut accepted step `gate` because the probe file imports the
    module AS `gate`. Names are therefore read only where they are sent or called.

What that costs, measured against the contract that stands:
  rundebinding  drives the door, names no step         (was green)
  rapport       never goes through the door at all     (was green)
  gate          drives the door, names no step         (was green)

BEARING 2 -- the limit statement said exactly TWO ways remained; the checkpoint
planted 21 call forms and measured SIX. Four are closed with a guard each (the
official Python SDK in both spellings, the node and uv runners, a dynamic import);
three remain and are now named: a runtime-composed name, a name from an environment
variable, a base64-decoded name. Left open deliberately -- the encodings are not
enumerable and our own contract stores base64 by design. Row 3: 9 of 9 -> 12 of 12,
still GREEN, 0 hits over 512 files. One of the three caught a command written in this
round's own test docstring; it was rewritten, not exempted.

BEARING 3 -- held_out accepted an EMPTY reason and shrank the denominator, while the
summary said "held out with a reason" either way. A blank reason is no reason: the
symbol stays in the denominator as a call without a door, the summary counts reasons,
and the four steps declared OUTSIDE the run path are now named one by one as having no
derived source instead of being counted in silence.

Five small rests, closed: a pruned manifest (451 of 512 was still GREEN) is now NOT
MEASURED, one sentinel per area the old handlist missed; a non-UTF-8 file is read as
byte text instead of counted and skipped; a symlink out of the tree is named and
fails the row; a runbook whose whole content is "x" no longer passes, the contract
names its sections; and the row states that its ratio is not a coverage measure.

Two stated, not closed, each with its reason in the row's own attestation: a po call
moved one floor down into a helper leaves the denominator (following helpers would
pull private ones in and make the denominator the curated list this row exists to
avoid), and row 3's k/n can still be padded by a guard with no measured escape behind
it. The ledger's two precision errors are corrected: "no row became greener" is true
of colour, not of numbers, and ENTRY_KINDS has three arts, not four.

Suite: 2172 passed, 5 skipped, 5 xfailed in 645 s. ruff and mypy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:48:44 +02:00
40869e272f
test(b-gate): nineteen arms that fail because a probe proves nothing, a reason may be empty, and the row's own limit is untrue
RED FIRST, second repair. PM's checkpoint on c1bdb37 measured N2b: row 1 goes from
3 of 17 to 17 of 17 GREEN with fourteen stub doors and ONE unrelated green test used
as the behaviour probe for all of them. The gate only looked up whether a nodeid was
`passed`; nothing bound the probe to its step. EXTERNAL_DOOR promised the probe
"calls the door and reads the artefact it writes" and there was no code for it --
the same class of defect as entry["kind"] one round ago.

20 new arms, 19 RED, every one on an assert about behaviour (43 assert lines, 0
ImportError, 0 AttributeError, 0 collection errors). The twentieth is green by
construction: every held_out entry in the contract does carry a reason today, and
the arm exists so that stops being true loudly.

Each refusal arm carries its own rc-0 control FIRST -- the same probe, the same
surface, accepted -- then breaks exactly one trait. The constructed probe is now
WRITTEN (`_write_probe`), not merely named: the repaired gate reads the probe's own
source, so a nodeid that does not exist cannot be a behaviour.

Red on the three bearing rests:
  probe not bound to its step   door untouched, step unnamed, nothing asserted,
                                test missing, one probe shared by two steps, and
                                N2b against the contract that stands (17 -> k=0)
  limit statement untrue        the six escapes PM measured as unseen, planted one
                                at a time, and the "TWO ways remain" sentence
  held_out accepts empty        a blank reason shrinks the denominator; the summary
                                counts entries, not reasons; the four steps outside
                                the run path are counted without being named

Red on five of the nine small rests: pruned manifest (451 of 512 still GREEN), a
non-UTF-8 file counted but never scanned, a symlink out of the tree, a runbook whose
whole content is "x", and the row's ratio read as a coverage measure.

One test-hygiene change, not a product fix: the git-dependent arm now skips itself
without .git instead of failing, so "red in a clean extract" stops growing with each
new arm of that class.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:24:06 +02:00
000a7fe4ed
test(round-builder): the verdict word, the two counts and the amount, each pinned to its own line
RED FIRST, HONESTLY: 0 of the 3 new arms can be red at HEAD, because neither finding is a code
defect. The binder already printed every one of these things correctly; what was missing was an
arm that looked. There is no fix to be red before. The proof that the arms discriminate is the
mutant run, not this commit — and that run is reported here in full.

REPRODUCED AS SURVIVORS FIRST, against the 40 arms as they stood: the checkpoint's N3
(`_status_word`, "rejected" -> "validert") and N2 (the heading's amount truncated to whole
kroner) both passed 40 of 40. Eight more of my own against the same first screen: the verdict
word dropped entirely, the label paired with the NEXT row's word, `not_evaluated` read as
"avvist", `unsupported` read as "validert", the commissioned count off by one, and the heading's
"kroner" changed to "kr" — 8 of 10 survived. Two were caught (the sum off by one øre, the
overview sorted).

AFTER: 12 of 12 felled, every one on an AssertionError about behaviour, 0 on
ImportError/AttributeError/ModuleNotFoundError. Control unmutated: 43 of 43. Mutants lived only
in a scratch clone of `git archive HEAD`; the work tree was never mutated.

THE CAUSE WAS MEASURED, NOT GUESSED. `"60 000,01" in text` also matched the "Berørte
kostnadslinjer" line, which is built from `unit_cost` and which the mutant never touched. Counted
over the fixture's own report, 4 of this file's 11 positive substring-assert sites have their
needle on MORE THAN ONE line, and 3 of those 4 are asserts about one particular line. Those three
now compare the WHOLE line: `- **{label}** — {word}`, `### {label} — {amount} kroner`,
`### {label} — falt på {stage}`. The fourth claims only presence, and presence is what it
measures, so it still says `in`. The docstring's "cannot pass by accident" now says which half it
covers.

The word table is written out HERE rather than imported from `_status_word`: an arm that asks the
binder which word it chose and then agrees with it cannot tell "avvist" from "validert". It is
pinned to the MANDATE's own status list (`ApproachOutcome.status`, four values) the way the stage
vocabulary is pinned to the validator's — `_status_word` falls back on `str(row["status"])`, so a
status added there without a word here reaches the expert as a bare English identifier.
"validert" is a PREFIX of "validert, men uten erklært krav", which is exactly why no substring
assert can separate those two.

Also, two prose claims that promised more than the measurement:
- "Every artefact TYPE a real run leaves in an outbox" -> the types the runs MEASURED left.
  `run.py` calls TEN of outbox.py's ten writers; `exploration`, `prepass`, `multibase`,
  `plan-review` and `proposal-reviews` are five more types. Re-counted over every outbox in this
  repo holding exactly one coverage — 15 directories, out of 25 coverage files in 20 directories
  — the union is still 7, so 7 is a measured floor and not a ceiling. The arm is renamed to say
  so.
- "Four of the seven are RUN-level" -> FIVE. `_RUN_LEVEL_TYPES` has five members.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:51:36 +02:00
2d957a93c5
test(b-gate): the surface arms hold in an extract too, and one arm owns the git-manifest denominator
Three of the new row-3 arms pinned 512 files and the literal source label, which only a tree WITH
git metadata can answer; a clean extract (git archive | tar -x) would have turned all three red for
the same reason the three already-named extraction artefacts go red. The property they test holds
in both worlds, so they now assert it there: main.py, examples/, spikes/ and contexts/ are inside
the guard whichever manifest was read, and the row prints whatever denominator it measured.

The exact number and the label belong to ONE arm — the one whose subject IS the git manifest. In an
extract it is an extraction artefact of the same class as the others, and says so in its docstring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:42:40 +02:00
59f35fde22
fix(b-gate): every denominator answers to a source outside the gate, and a name is no longer a door [skip-docs]
Row 1's M was a curated list of 13 in the gate's OWN b_gate.json. It is now DERIVED: run_path_calls
reads what run.py::run_project actually calls (41 po-functions, re-counted here against PM's 39 —
the two that differ are generate_via_llm and fresh_workflow, held out because they need a chat
client). A call that is neither a declared step nor named-with-a-reason counts in the denominator
WITHOUT a door, so the number cannot shrink in silence: removing a step from the contract now
leaves N unchanged and names the orphan. The four outbox writers the run path uses and nobody had
declared (write_prepass, write_parse_failures, write_proposal_reviews, write_debate_tools) are
steps now; 28 calls are held out, each with its reason printed under the row.

A door must be REGISTERED and PROVEN. entry["kind"] is read (it was read 0 times before) and must
be one of three kinds the gate has code to verify: console-script in pyproject, module-main with
its own __main__ guard, subcommand registered in the module's argparse. On top of that every step
needs a named probe that calls the door and reads the artefact. The MCP sentence is struck from
EXTERNAL_DOOR: it claimed a capability with no code behind it.

Row 3 now scans the repo manifest (git ls-files, or the tree itself in an extract), not a hand
list of 11 roots: 512 published files instead of 433, so main.py, examples/, spikes/, contexts/,
CLAUDE.md and llms.txt are inside the guard for the first time. Three new patterns catch the
indirect invocations that walked past the old six — absolute path, list in a variable, constant,
shell string — 5 of 5 of PM's variants are refused now, with 0 false positives measured over the
whole surface. An empty surface is IKKE MAALT, not GREEN: the row demands a sentinel file and
prints the file count and the manifest it read.

Rows 4, 5 and 6 get sources for their denominators. Row 4 counts only checks whose named source
symbol resolves in the code. Row 5 counts each structural requirement on its own (2 profile
members + factory + seam + probe = 5) instead of collapsing three into one unit. Row 6's N comes
from the artefacts the contract names, and the attestation is VALIDATED: it must name the
contract's runbook, carry its sha256, say who ran it, and bear a real ISO date that is not in the
future — v1_gate's own date rule, reused, BOM tolerated as there.

Measured in a scratch clone (/tmp/claude-po/bgate-mut): 12 of 12 mutants felled, control 65 of 65.
All six of PM's broken attacks reproduced as refused, with the rc-0 control green.
No row got greener: 3 of 17 (was 3 of 13), 0 of 2, GREEN, 0 of 3, 4 of 5 (was 1 of 2), IKKE MAALT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:40:42 +02:00
332eb5965b
test(b-gate): 31 arms against the gate's own denominators, 30 red on an assert about behaviour
The PM checkpoint on 207337c judged the gate DELVIS: row 1's M=13 is a curated list in the
gate's OWN b_gate.json (the run path has 41 po-calls, 7 of 10 outbox writers), rows 4, 5 and 6
have denominators with no source at all, 6 of 10 cheat-attacks got through, and the
never-Claude guard sees 433 of 512 published files.

This commit is the red half. Every arm fails on an ASSERT about behaviour, never at collection:
the four names that do not exist yet (ENTRY_KINDS, run_path_calls, registered_entry,
published_files) are stubbed here with DELIBERATELY wrong values — everything is a door, the
run path calls nothing, the surface is empty — so each arm measures the defect rather than the
absence of a symbol.

30 av 31 red on assert. The one that is green is the rc-0 control
(test_a_valid_attestation_is_the_only_thing_that_turns_row6_green): a valid attestation must
turn row 6 green both before and after, or the row refuses everything, which proves as little
as refusing nothing. All 34 pre-existing arms stay green — measured, not assumed.

The planted claude-invocations are base64 in the test file for the same reason the contract's
patterns are: tests/ is itself part of the surface row 3 scans, and a cleartext variant here
would register as its own finding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:14:17 +02:00
470fd00f89
test(round-builder): the sum's own guard, measured apart from the one upstream of it
A green mutation is a finding. Of the twelve planted against this file, eleven fell and
one survived: dropping `row["validated"]` from validated_ore's filter changed nothing.

It is equivalent — but only for as long as the OTHER guard holds. derive_outcome already
refuses to put a refused row's figure into (d), so on every outcome this builder writes,
validated_nok is None wherever validated is False and the two guards are
indistinguishable from the outside. Deleting either one alone is free today; deleting
both is the leak, and nothing witnessed that.

validated_ore is public and takes any outcome mapping, so the arm that separates them
hands it the outcome a future coverage writer could produce: a refused row whose amount
already sits in validated_nok. 137 000 002 øre, counted from the table, against the
227 000 002 a leak would give.

12 of 12 mutants now fall; control in the clone is 40 of 40.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 10:26:49 +02:00
38eaf5a649
test(round-builder): 11 arms for the report's CONTENT, and a fixture that holds all seven artefact types
The 19.09 checkpoint planted 20 mutants against this file and SEVEN survived the whole
suite: the report's round number, an artefact type outside proposal/outcome/coverage
dropped in silence, a never-evaluated approach left off its own list, the source quote
removed entirely, the quote's COUNT removed, the affected cost lines removed, and a
refused row's amount counted into the validated total. None of them survived because the
builder was wrong — it does all seven things correctly. They survived because no arm in
709 lines ever looked (grep for citation/cost-line words gave 2 hits, both in the
fixture, none in an assert). An unwitnessed seam is a seam the next edit may delete for
free.

Two fixture defects made three of those blind spots unreachable rather than merely
unmeasured:

  * THE DENOMINATOR. A real run leaves SEVEN artefact types; the fixture wrote three.
    Counted here, not remembered: over the four archived runs the checkpoint read
    (tunnel-hauglia-2027-04/-06/-07/-08), each <run_id>-<rest>.json is typed proposal /
    outcome when <rest> ends there and <rest> itself otherwise. -06, -07 and -08 hold all
    seven (15 files each); -04 holds six (12 files, no parse-failures — that file is
    written only when something failed to parse, so its absence is the signal). Union =
    7, written by two commands: run.py writes six, stress.py writes -verdict.json. Six
    are now written through the product's own writers; -verdict.json is bytes, because
    the builder never reads it and that difference is stated rather than hidden.

  * THE CITATIONS. Every proposal carried an identical stamp, so "1 av 1 siterte steder"
    could not tell a dropped COUNT from a kept one. Counts are now distinct per approach,
    and a shared-list mode reproduces what the real runs actually do.

The refused row now carries an amount, and no archived run does: measured on the same
four outboxes, every rejected row has saving_nok = None. The guard that only a validated
row's figure becomes validated_nok therefore aims at a coverage writer that does not
exist yet — and a fixture that cannot produce the number cannot witness the guard at all.

4 of the 11 new arms are RED on ASSERT, all four on behaviour that does not exist yet:
a dangling symlink as the round directory (exists() follows the link, so the build dies
on the filesystem's own FileExistsError instead of a sentence), the identical citation
list stated once instead of five times, the same cost line named on both sides of the
verdict, and a removed approach shown by the label the expert saw instead of a raw id.
The other 7 pin behaviour that is already correct; they are not red here, and the proof
that they discriminate is the mutant run, not this commit.

The cause of the identical quote was MEASURED before anything was written, because
"the builder reads the wrong field" and "the outbox says the same thing five times" want
opposite fixes: all five proposals in tunnel-hauglia-2027-08 carry byte-identical
270-citation lists (one sha256 across all five), and so do -04, -06 and -07. It is the
run's whole retrieved context stamped once per proposal. The report cannot make that
quote informative; it can stop repeating it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 10:15:51 +02:00
f7ade7aa8b
feat(b-gate): the gate that measures po as a toolbox, red on six measured rows [skip-docs]
python -m portfolio_optimiser.evals.b_gate — one command, offline, no model call, exit 1 today:

  1 steg i kjørestien kallbare utenfra        3 av 13   RØD
  2 roller som kan leveres utenfra            0 av 2    RØD
  3 vakter mot en vei fra po til Claude       6 av 6    GRØNN  (435 published files)
  4 løpet drevet uten et eneste modellkall    0 av 3    RØD
  5 Foundry-veien urørt og samme artefaktfamilie 1 av 2 RØD
  6 kjøreboka finnes og er kjørt              0 av 2    IKKE MÅLT

Every denominator is read off the source, never off a list in the gate. Row 1 counts the steps
of the run path that resolve to a symbol AND have a call site; a step is externally callable only
when a CLI (or MCP-registered) entry reaches it without any chat-client construct on the way —
which is why the ten run.py steps are red and round_builder's two plus the v1 gate are green. Row
2 reads the roles off workflow._MAKER_CHECKER_ROLES. Row 3's patterns each carry a known-positive
AND a known-negative fixture, so a guard that cannot hit is not counted as a zero.

Three decisions the operator cannot answer without reading code, made here and stated in the
gate's own output:

* the external door is a CLI subcommand, not MCP — po already has five main() and two console
  commands, and MCP would need a server the run path does not have. The gate still counts an
  MCP-registered door, so the choice does not bind the next order.
* the budget guard in B is NOT po's: BudgetMiddleware is fail-closed on missing usage and is
  never constructed without a chat client, so keeping it here would turn fail-closed into
  fail-open. The ceiling in B is the Claude Code session's own spend, which po neither sees nor
  steers. The Foundry path keeps its ceiling unchanged.
* row 6 is IKKE MÅLT, never green, until the operator attests that the runbook actually drove an
  analysis — a file the gate never writes, the same rule as the v1 gate's attestation.

Row 3's pattern text is base64 in the config so the contract cannot register as its own finding;
that is what lets the row run without an exclusion list, and a row without exclusions is a row
nobody can switch off by adding a filename.

Suite after: 2106 passed / 5 skipped / 5 xfailed (was 2072/5/5; +34 new, none changed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:29:10 +02:00
0a784065d0
test(b-gate): 34 red tests for the gate that measures po as a toolbox Claude Code drives
Operator decision 19.09.2026: in development and test Claude Code LEADS and portfolio-optimiser
is the toolbox. po never calls Claude; production stays on Foundry. This commit writes the
measurement RED — the contract first, the capability later.

Six rows, each with a denominator taken from the SOURCE and counted independently here:

1. toolbox complete — every deterministic step of the run path, anchored to (module, symbol) and
   to the scope that calls it; k = steps reachable from a CLI entry WITHOUT a chat client.
2. what the model delivered can be delivered from outside — denominator read off
   workflow._MAKER_CHECKER_ROLES, k = roles with passing named probes.
3. po has no path to Claude — six patterns, each carrying its own known-positive AND
   known-negative fixture. Pattern text is base64 in the config so the gate's own contract cannot
   register as a hit against the surface it scans.
4. no model calls in toolbox mode — three named probes.
5. the Foundry path untouched — profiles, factory and injection seam read from source, plus a
   schema-comparison probe.
6. the runbook exists — NOT MEASURED until the operator attests, and the gate never writes that
   attestation itself.

Red now for the reason that matters: the tests fail to import a module that does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:01:29 +02:00
86c61a343b
test(round-builder): the determinism arm builds twice in two processes, not twice in one
Found while killing mutants: an ordering made to depend on hash() survived
test_two_builds_of_the_same_outbox_are_byte_identical untouched. One interpreter has one hash
seed, so the two builds agreed with each other and with nothing else -- the arm could not tell a
deterministic report from a report that is merely consistent within a process. The ordering arm
caught that mutant, so the seam was witnessed; this one was not witnessing it.

The second build now runs as a subprocess with PYTHONHASHSEED=1. Re-measured with the same
mutant: both arms red, where before only one was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:35:44 +02:00
db38cfc1a4
feat(round-builder): one command turns a run's outbox into a round the gate can read [skip-docs]
python -m portfolio_optimiser.evals.round_builder --outbox <dir> --round <n> --ran-at <ISO>
writes <rounds-dir>/<n>/ with the run's artefacts COPIED in, outcome.json derived from that
copy, and report.md -- the one artefact in a round a domain expert reads and corrects. Round 0
of the v1 criterion can now be made; it counted 0 of 3 because it could not be, which is a
different failure from a round nobody had held.

What it derives it derives with the gate's own functions rather than a second copy: verify_run
decides whether the run stands up to itself (an artefact contradicting its coverage row, a
half-missing family and a stray artefact are all refused AT THE SOURCE, before a byte is
written), stage_of gives column (c), row_changed gives the report's "changed since the previous
round", parse_time refuses a stamp without a zone, safe_rounds_dir refuses a round directory the
repo would commit. The validated total is ledger.to_ore per amount, summed as integers.

Two things it never does, and both are the point. It never writes the operator's attestation --
the gate stops at FORM OK without one, and that is correct, because no arrangement of files can
witness that a run happened. And it never invents: --ran-at is required because no outbox
artefact carries a clock, and feedback_ids stays empty because no run records which feedback
item produced which row. The report says "ingen tilbakemelding forklarer dette" on every changed
row rather than hiding that model noise and an answered objection look alike.

Chosen and why: --ran-at as a required argument rather than the coverage file's mtime, because
an mtime is a filesystem attribute one call sets and reading it as evidence made row 2 green on
a tree nothing had run in (18.09). The report carries no raw stage identifier -- every stage
sentence is "<short name>: <explanation>" so the one-line diff of what changed has words a
reader can act on. A citation shows its COUNT, because a run that cited 446 places and one that
cited one must not look the same.

[skip-docs]: the ledger row is in docs/invarianter.md, which is where this repo's rules live.
README is the product's front door and this is an operator tool behind `python -m`, the same
class as costsim/hitl/preflight, which README deliberately does not carry; v1-rounds/ is
gitignored internal machinery and the gate itself is not in README either. CLAUDE.md was emptied
of exactly this kind of row in session 130 and is not the place to put one back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:09:12 +02:00
0fa612a22f
test(round-builder): 26 red tests for a round directory built from a run's outbox
Round 0 of the v1 criterion cannot be made today: nothing binds a finished run's outbox to
<rounds-dir>/<n>/, and nothing in src writes markdown a domain expert could read. These tests
state what a builder has to do before one exists, and every number they assert is counted a
second time from the fixture's own table rather than read back from the builder.

Red on assertions, not on import: round_builder.py lands as a contract -- dataclass, signatures,
neutral returns -- so each test fails in its own body.

Two gate helpers become public rather than being copied: row_changed (the report's "changed since
the previous round" section must not disagree with the gate about what changed) and
safe_rounds_dir (the builder CREATES the directory the gate only reads, and the writer is where a
leak of the expert's feedback has to be stopped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:55:50 +02:00
68079469c3
test(frozen-pin): an added or removed file, and listing order, pinned by tests; drift named apart from missing
K5 survived a full run (a mutant ignoring an extra or removed file left the suite green) and K4
(sort removed) was caught only by corpus data. Now pinned with no corpus: the add arm, the remove
arm, and a shuffled directory listing digesting the same. The gate says "pin-drift" for a drifted
copy instead of "artefakter mangler"; the unread "store" key is gone from frozen_bundles.json; the
forbidden-path scan covers md/yaml/yml/toml and proves a known positive per suffix.

Five mutants killed in a scratch clone (drop-last-entry 8 failed, no-sort, no-names, drift-as-missing,
scan-suffixes-narrowed 1 failed each).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:49:54 +02:00
a13b905b0c
test(v1-gate): reword a docstring so the net diff carries no mutant talk
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:32:18 +02:00
cd6fb4302e
test(v1-gate): the attestation's date, form and writer, as red tests — a future date, a +14:00 offset and a duplicated key all read green
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:11:42 +02:00
1b48124a01
fix(v1-gate): rows 1-2 stop at FORM OK — what files cannot show, a person has to say
The PM checkpoint of 18.09 paid M-6's price three times and row 2 still read 3 of 3 GREEN:
a whole artefact family written by hand and made internally consistent (verdict_id minted
with the product's own rule, about sixty lines of script), four REAL runs' artefacts under
a handwritten feedback file, and `<n>/outbox` symlinked out of the round to a real run
elsewhere. `RUN_ATTESTATION` stood in the output the whole time, and the row was green
anyway — which is the finding: a row that PRINTS its limit and reports GREEN has not
stated a limit, it has decorated one.

One of the three is a hole and is closed; two are not holes at all.

1. The outbox must lie in the round ITSELF (`outbox_escape`). Derived is only derived if
   the filesystem cannot redirect it: `<n>/outbox` as a symlink, or any symlinked artefact
   inside it, is refused by name.

2. Rows 1 and 2 can no longer be green from files. What the computation gives is
   `FORM_OK` ("FORM OK, IKKE BEVIST"): every check the gate knows how to make passed, the
   round counts 0 against the criterion, and the exit code still fails — it is un-green in
   every way RED is. What separates the two is what the reader must do next, not what the
   gate concludes.

3. Green is `<n>/attestering.txt`, per round: the operator's own statement, naming the
   round, the run and the date. Plain text with three keyed lines, deliberately not JSON —
   it is the one artefact in this contract a PERSON types, and a missing brace must never
   be able to read as "not confirmed". Nothing in the product writes one, and a test in
   `src/` says so: a product that can produce a witness to its own run has produced
   exactly the thing these rows cannot.

   A MISSING attestation is FORM OK: nobody has confirmed anything yet, and the gate is
   not entitled to call that a lie. A PRESENT one that names another round or run, carries
   no date, an unparsable date, or a date before the run, is RED: it is a statement about
   something this is not. Row 2 needs round 0 attested as well, since round 1 is measured
   against it — an unattested baseline is an unattested comparison.

The attestation is read raw off `outcome.json` (`_declared_run`), not through
`read_outcome`, so row 1 — which reads no run — does not inherit row 2's artefact checks
through the back door.

Nothing moved on the criterion: 0/3 · 0/3 · 3/8 · no report · 3/8 · IKKE MÅLT · 1/20,
exit 1. What changed is that rows 1-2 now name what they are waiting for, and it is not
another file.

Suite after `git add` on a clean tree: `uv run pytest -q` = 2014 passed, 5 skipped,
5 xfailed (508 s). The attack of b769537/ab00016 is the red half of this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:38:42 +02:00
ab00016ed5
test(v1-gate): the PM's three consistent forgeries, as red tests — a family that agrees with itself is still not a witness
M-6 moved the price of a forgery from `touch` to reproducing the product's own artefact
set. The PM checkpoint of 18.09 then paid that price three ways, and row 2 read 3 of 3
GREEN each time:

  F1  the whole artefact family written by hand and made internally consistent — coverage,
      one proposal/outcome pair per approach, `verdict_id` minted with the product's own
      rule. About sixty lines of script. `RUN_ATTESTATION` stood in the output; the row was
      green anyway.
  F2  four REAL runs' artefacts under a handwritten feedback file.
  F4  `<n>/outbox` made a symlink to a real run's directory somewhere else, so a DERIVED
      path was redirected by the filesystem.

F4 is a hole. F1 and F2 are not: no arrangement of files can be told apart from a round
that happened, because what is missing is not a check but a WITNESS. These tests say so.

Red here, 16 of them:

- rows 1 and 2 must stop at `gate.FORM_OK` — 0 against the criterion, exit still 1 — on a
  tree where every computed check passes but nobody has attested the rounds (F1, F2);
- F2 is built through `outbox.write_outbox` and `write_coverage`, the exact bytes a real
  run leaves behind, so it doubles as the control that `verify_run` reads the PRODUCT's
  output and not merely the shape this test file writes;
- an outbox that is a symlink out of the round, and an artefact symlinked into one, are
  refused by name (F4);
- the step from FORM OK to GREEN is `<n>/attestering.txt`, per round, round 0 included
  because row 2 measures round 1 against it: present and matching -> GREEN (the rc-0
  control, asserted first in every arm), missing -> FORM OK, present but naming another
  round, another run, no date, an unparsable date or a date before the run -> RED;
- nothing in `src/` may write that file: a product that can produce a witness to its own
  run has produced exactly the thing rows 1-2 cannot.

One more arm, green on arrival, and that is the finding: the guard at `v1_gate.py:415`
(`keyed != {(run_id, aid)}`), pinned to a constant-false branch, left the suite at 68
passed, identical to baseline. The guard bit, nothing read it — so
`_mut_the_family_is_labelled_for_another_run` gives the pair the right FILENAME and
another run's labels inside.

The fixture now writes the operator's attestation on every round it builds, and the
file's name is pinned in the test file as well (`_ATTEST_FILE`), the way M-5 pins the
rest of the contract: renaming it in the gate alone must fail a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:24:00 +02:00
a362504108
fix(v1-gate): row 2 binds a round to the run's own artefact family, and says out loud what it still cannot prove
The 18.09 re-measurement took row 2 to 3 of 3 GREEN on a tree this product had never
run in: four handwritten outcome.json, four handwritten <run_id>-coverage.json in an
outbox the forger named in those same files, and os.utime for the ordering. "Round 0
must be a named real run" was implemented as "a file with that name exists" — which
touch satisfies. The attack is committed as a red test in b769537.

Three bindings, chosen because each removes one of the forger's three moves:

1. The outbox is DERIVED, never declared. It is <rounds-dir>/<n>/outbox/, and an
   outcome.json that names one is refused by name. A path a round file chooses is a
   path it can point at a directory the same hand just wrote.

2. "The run exists" now means the run's own artefacts agree WITH EACH OTHER on content
   the gate recomputes (verify_run). Every evaluated approach has the proposal/outcome
   pair write_outbox actually persists; outcome_type IS the coverage status, reason IS
   its detail, a validated row's figure IS the proposal's own claimed_saving_nok, the
   provenance stamp follows the same branch, and verdict_id is RE-MINTED here from the
   proposal's own IR with the product's one minting rule (A5) rather than read. A
   not_evaluated approach wrote neither file, and an artefact naming an approach the
   coverage omits belongs to another run. verdicts._features_from_ir is made public for
   this: a second private copy in the gate could drift from the rule the run stamped
   with, which would turn the binding into a coincidence.

3. mtime decides nothing. The run's time is the round's declared ran_at (ISO-8601 with
   zone, required). An mtime is not evidence — it is a filesystem attribute one utime
   call sets.

What this does NOT do is prove a run happened. Nothing in a directory can: the outbox
writers are byte-deterministic and carry no clock by contract. So row 2 states its own
limit on every run instead of leaving GREEN to imply it (Row.attests / RUN_ATTESTATION):
that a run was actually made, and when, is the operator's to confirm. The cost of a
forgery moves from touch to reproducing the product's own artefact set, minting rule
included.

Measured, in a scratch copy, never in the work tree — four new mutants in this class,
each one line, each felled by the whole suite:
  M7  if "outbox" in data:          -> if False:   1 failed (declares its own outbox)
  M8  if verdict_id != minted:      -> if False:   1 failed (key is not the IR's)
  M9  if strays:                    -> if False:   1 failed (artefact of another run)
  M10 ran_at                        -> coverage mtime  18 failed (incl. the utime test)
Control, same scratch copy, unmutated: 1993 passed, 10 skipped, 5 xfailed.
Work tree, re-run after git add: uv run pytest -q -> 1998 passed, 5 skipped, 5 xfailed.
Gate: uv run python -m portfolio_optimiser.evals.v1_gate -> exit 1, row 2 RED (0 of 3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:32:14 +02:00
b769537830
test(v1-gate): row 2 goes green on four handwritten files — the attack, as a red test
The 18.09 re-measurement of 9825b26 made row 2 read 3 of 3 GREEN without a run existing
anywhere: four handwritten outcome.json, four handwritten <run_id>-coverage.json in an outbox
named by those same files, and os.utime for the ordering. read_outcome does cross-check the
outcome against the coverage — but `outbox` is a free path from the round file and the coverage
file is written by the same hand, so "round 0 must be a named real run" is implemented as "a file
with that name exists", which touch satisfies.

The attack is rebuilt in the test rather than driven through the _outcome fixture, so the
fixtures can be raised to a whole run family without the attack drifting with them. Row 1 is
asserted GREEN on the same tree: the feedback IS well formed there, which is what makes this an
attack on row 2 and not a broken fixture.

RED as committed (3, 'GRØNN') != (0, 'RØD') — the fix is the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:26:58 +02:00
98f1a771f3
docs(claude-md): move the invariant ledger to docs/invarianter.md, verbatim
CLAUDE.md had grown to 310 919 bytes against Claude Code's 150 000-character
injection limit, so every row past the cut reached no session. The 93 measured
rows move to docs/invarianter.md in their original order; CLAUDE.md keeps the
eight short standing rules and a pointer, and says new rows are written there.

Verified as a partition: every moved line appears in the original section in
order, the eight kept rows likewise, and head/tail of CLAUDE.md are byte-
identical apart from the visitor note. One code span is reworded and the ledger
head says so: the funn 99 row cited MAF's DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_
REQUEST with its value inside the span, which the doc-constant-sync gate reads
as a citation of a constant in this package (fail-closed on an unknown name).
The ledger is registered in _LIVE_DOCS, so that gate now also guards it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 06:21:10 +02:00
50c9763706
feat(frozen-bundles): the measurements read a pinned copy, not another repo's build dir
Measured 2026-09-17 17:43: vegnormal-okf rebuilt build/ferdig/r761-2025 while this
repository's v1 gate, the stress judge and four corpus tests pointed straight at it.
Rows 6-7 went IKKE MAALT and five tests fell, for a change no one here made. The failure
mode was never falsehood - the gate says IKKE MAALT and exits non-zero, never green - it
was instability: two projects shared a directory neither owns, so what this repository
MEASURES could move without a commit here.

A copy alone would push that directory one move away, so the copy comes with a pin.
frozen_bundles.json (tracked) carries path + sha256 + file count per base; the bundles
themselves are NEVER committed here. Three states, separated by construction: match ->
resolves; gone -> FrozenBundleMissing (an OSError, so the gate's existing except OSError
gives IKKE MAALT + exit 1 unchanged and the corpus tests SKIP, MAJOR-3's ceiling); drift
-> FrozenBundleDrift (a ValueError), loud, named, and never a skip. The two classes are
deliberately unrelated: a caller that catches "missing" to skip must not swallow "drift".

The NAME is hashed alongside the bytes, and the directory name carries the first 12 chars
of the digest so a stale copy is visible in ls. Renewal is a decision: new copy + new pin
in the SAME commit (README). --bundle-root / PORTFOLIO_VEGNORMAL_ROOT stays as the
operator's explicit, UNPINNED live mount.

Iron Law: the tests were written and run RED first (collection error, then two arms of my
own making). Load-bearing MEASURED, eight mutations all red against the WHOLE suite with a
green control of 1984 passed / 5 skipped / 5 xfailed and a strict node-id superset
(1977 -> 1994, 0 removed): M1 the pin is never verified (7) - M2 drift collapsed into
missing (5) - M3 the name is not hashed (40) - M4 the gate seam reverted to root/name (1) -
M5 the corpus helpers skip on drift too (4, one per file) - M6a the slash spelling back in
src (1) - M6b the quoted path segment back in a test (1) - M7 the directory name drops the
short digest (1, and 45 skipped, which proves absence is a SKIP and not a false green) -
M8 the explicit override ignored (3, two of them in test_stress_judge_loadbearing.py,
independent witnesses older than this work).

M2 FALSIFIED THE TEST FIRST: the four parametrised arms did not go red, they went to SKIP
(5 -> 9 skipped) and stayed green - pytest.skip inside a pytest.raises is not a failure.
The arm now catches pytest.skip.Exception explicitly and turns it into an AssertionError.

grep -rnE 'vegnormal-okf/build|["'"'"']vegnormal-okf["'"'"']' src tests contexts -> 0
(3 + 4 hits before; the three remaining prose mentions document history and are allowed).
Gate re-run against the frozen copy: identical to the live mount (rows 0/3 - 0/3 - 3/8 -
no report - 3/8 - IKKE MAALT - 1/20, exit 1).

Order 20260917T223645Z-1296211942-from-.claude.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 03:22:22 +02:00
9825b2677c test(v1-gate): harden the gate against a handwritten green
An independent review made rows 1, 2 and 4 green from a handwritten
directory in a minute, and 10 of 20 mutants survived the gate's tests.

Rounds now need a new point and their own ids, a timezone-aware given_at
in order, and a report the feedback was given on; every outcome must name
a run whose own coverage confirms (a)-(d), the feedback must fall between
the two runs, and a NOK change under 1 % is noise. Row 4 counts content
lines kept unchanged and in order, shows the expert's additions, and calls
a byte-identical copy untouched unless round 3 acknowledges it. Row 6
counts the runs' own proposals. Types 3 and 7 are proven through the real
flags with the action in the result (still 3 of 8). The contract numbers
and the evidence register are pinned to their source. Every run prints
that rows 1-2 cannot prove who wrote the feedback. A rounds directory
inside the repo that git would commit, and a missing stress or bundle
root, are usage errors.

The review's 20 mutants, re-run: 20 of 20 killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:53:40 +02:00
938a1ca30e feat(row6): a proposal whose approach declared no requirement is unsupported
Stress round 6 validated three falsification arms, and every validated
approach rested only on run-level declarations nobody can attribute to one
approach. declare_requirement now takes a required approach_id (a mandate
id or own-proposal; an unknown id is refused naming the valid ones), and a
ValidatedProposal whose approach has neither a mandate requirement nor a
declaration under its own id becomes validator.Unsupported - a Rejection
subclass carrying the validator's own ruling, reported as `unsupported` in
coverage, the outcome artefact, the settlement and the judge, and never
counted or summed. The rule is active whenever the debate held the
declaration tool, the micro base included; the road and pre-pass paths are
untouched. Declaration quality is not judged, so the rule can be satisfied
by declaring any document the run read.

The v1 gate's row 6 probes pass; its artefact half reads IKKE MÅLT because
stress round 6 predates approach-addressed declarations, and IKKE MÅLT is
never green - it fails the exit code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:40:54 +02:00
9847e014e7 test(v1-gate): the MAF list is operator-approved; row 5 reads 3 of 8
The eight U-IDs and their type pointers were checked against the approved
list (no deviation) and the data file now says approved, with the date and
the source. Row 5 no longer reports "not approved" but the measured count:
3 of 8 (U12, U4, U6), because a point counts only when every type it points
at is green and only types 1, 3 and 7 are. No gate logic changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:56:57 +02:00
83c94e4fb6 test(v1-gate): the v1 gate, written RED
One command says how far the repo is from v1, row by row, with an exit
code: rounds with a real domain expert 0/3, traced measurable change 0/3,
feedback types with a way in and an action 3/8 (1, 3, 7), round 3 report
kept - none, MAF points with a green type pointer 0/8 (list not approved),
validated without the approach's own declaration 10/10 in stress round 6,
and `named` 1/20 as a diagnosis that never moves the exit code.

The gate defines the contract (a fixed rounds directory, gitignored by
default), not the generator. Rows 3 and 6 run named tests with
--runxfail; the red probes are xfail(strict=True) so the suite stays green
while the gap is real. No product code changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:31:48 +02:00
f8709ec228 feat(p22): a refusal names the DOCUMENTS when the ancestor has no subdirectories
P21/C2 made a refusal for an absent path name the ancestor's SUBDIRECTORIES, and it bought what it
was built for: read_dir against a level the base does not hold went from 16 of 104 to 8 of 128. It
did nothing for documents -- read_file against a document the base does not hold went 2 of 38 to
7 of 52 -- and the reason is structural: the nearest listable ancestor of a guessed DOCUMENT path
often holds documents and no subdirectories, and then the neighbour clause was omitted, deliberately,
because an empty list is a sentence with nothing in it.

Measured over round 5's six read_file misses, THREE land on such an ancestor: krav/N100 with 445
documents, and R761/1 with exactly ONE -- which two separate guesses in one run were both reaching
for. The other three have subdirectories and were already answered.

okf.nearest_documents is the sibling of nearest_subdirectories, never a widening of it: never both
clauses, and the subdirectory branch stays FIRST, which is what keeps every C2 refusal byte-identical.
Built from context_files and through the same in_dimension predicate the listing uses, so a refusal
can never advertise the type: verdict layer by path, and every name it hands back resolves -- measured
by feeding each one back into read_file, not by asserting the list is non-empty.

A MUTATION FOUND THE RANKING UNWITNESSED, and that is recorded rather than dropped: replacing
_shared_prefix with a plain reverse sort left the whole suite green. The bound, the source and the
resolve property were all gated; the ORDER was not. For R761/1 that costs nothing, but a level of a
delivered corpus can hold 445, and then which five it names is the whole value of the clause. The new
arm builds a level where the closest name is also the LONGEST, so a length rule puts it last and an
alphabetical one puts another first -- only the prefix rule puts it first.

Load-bearing MEASURED (tests/test_document_neighbours_loadbearing.py, 10 arms), seven mutations all
red against the WHOLE suite + green control 1891/5 (from 1881/5, superset, 0 removed) and golden
demo-transcript.stdout BYTE-UNCHANGED (shasum -a 1 of the CONTENT =
ea8c534773acdbe41ae68f2c55724d69aaf8be4f): C1 detach the document branch in read_file (5 red) -
C2 detach it in read_dir (1) - C3 build from files (1) - C4 ignore the dimension (1) - C5 no bound
(1) - C6 both clauses at once (1) - C7 a second ranking rule (1, after the test was fixed; green
before, which is the finding).

Honesty limits, stated: the foreign-dimension arm was VACUOUSLY green before this change (nothing
was named, so nothing could leak) and is gated only now -- C4 is what makes it real; no LIVE model
has read the new clause (DEL D is the measurement); and the clause is help text, not a gate -- it
cannot make a guessed path right, only cheaper to correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 01:17:39 +02:00
9072359606 feat(p22): declare_requirement answers with a COMPARISON, not a confirmation
P19 DEL A made a direction name the requirement that binds it; P20/A1 made the reply carry the
DOCUMENT's own title and number instead of echoing the caller's arguments. Re-measured at the head
of this session against the six round-5 debate traces: requirement_hit is 0 of 20 approach rows and
0 of 12 declarations -- the third round in a row at zero. P21/C1 made the runs LOOK first and it
worked on its own terms (distinct documents before a declaration went 1,1,1,2,5,13 -> 3,3,5,7,11,12)
and the hit did not move. The runs were made to read MORE, not righter.

The reply now compares: it names the directions the run was commissioned to pursue and says which
of their words appear in the declared document's own title and number, or that none do.

A REPORT, never a gate -- the declaration is recorded either way, because a requirement can bind a
measure without sharing a word with the name someone gave it, which is exactly how the alternative
rule the C1 measurement rejected failed one rung over. The words compared are the DOCUMENT's, never
`ref`: a comparison against the caller's own argument can only ever agree. Matching is generous in
both directions, and that failure direction is chosen -- a false "no overlap" pushes a model away
from a declaration that was right, a false "overlap" merely keeps the report quiet.

MEASURED BEFORE IT WAS BUILT, offline against the six traces as the order required (no paid calls
in DEL B): the rule speaks on 10 of 12 declarations and stays quiet on 2. A rule that spoke on 12
of 12, or on 0 of 12, could not tell the two classes apart.

`labels` defaults to empty, so every call site written before today is byte-identical and the three
keys are ABSENT rather than empty -- "there was nothing to compare against" and "we compared and
found nothing" are different facts. RUN-level, as the declaration itself is (P19 A4).

Also re-measured: the order cited requirement_hit as "0 of 12". The field is per APPROACH (0 of 20);
12 is the number of DECLARATIONS (7 distinct, 0 hits). Both zero, so the conclusion stands, but they
are two populations.

Load-bearing MEASURED (tests/test_requirement_comparison_loadbearing.py, 8 arms), eight mutations
all red against the WHOLE suite + green control 1881/5 (from 1873/5, superset, 0 removed) and
golden demo-transcript.stdout BYTE-UNCHANGED (shasum -a 1 of the CONTENT =
ea8c534773acdbe41ae68f2c55724d69aaf8be4f): B1 detach the run.py wiring (1 red, that arm alone) -
B2 always report an overlap (5) - B3 never report one (2) - B4 compare against the caller's ref
(1, that arm alone) - B5 make it a gate (5) - B6 emit the keys with no directions (2, one an OLDER
independent witness) - B7 exact token equality instead of substring (1) - B8 drop the minimum word
length (3).

Honesty limits, stated: no LIVE model has read the comparison yet (DEL D is the measurement); the
report cannot say a requirement IS right, only that it shares no word with the direction; and
finding 4 (`named` 1/20) is this same matter from the other side, so DEL D measures it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 00:04:47 +02:00
0a81de2d76 feat(p22): stage 0's unknown-code refusal NAMES the codes the project buys
P21 was the first ANCHORED stress round and it bought something real -- must_refuse 5/5, all
five caught on stage0-baseline. It cost something measured just as clearly: 0 of 20 approaches
validated, and 26 of 26 rejections (20 approach rows plus 6 own-proposals -- a LARGER population
than the 20) read `unknown cost code '<invention>': not in project P's cost baseline (5 known
codes)`. The model invented signalregulering_konstruksjon, VENTIL_IMP, RIGG01, baerelag_asfalt
and 22 more, and it could not have done otherwise: the price schedule reaches the VALIDATOR and
never the proposer, and the refusal stated the COUNT of known codes, not one name. Step 5 feeds
that sentence verbatim into the next attempt -- and "you guessed wrong, there are five right
answers" carries nothing to correct towards.

The contrast already lived in the same stage: the MAGNITUDE half NAMES the baseline value, and
that is the half that let the loop converge in session 94. This gives that property to the other
half, in one place, and Step 5 carries it forward for free.

The window is a FIXED COUNT of whole codes (20), never a share, and it counts codes rather than
characters because a character cut can sever a code mid-name and hand the proposer an identifier
that exists nowhere. The cut is announced; a schedule that fits is not marked truncated; the
order is the schedule's own.

Measured with denominators: every cost baseline in this repo or its measured corpora is at most
six codes, and the largest real delivered price schedule measured is K2's prissammenstilling at
14 priced rows. Nothing measured reaches the window; it exists for the R761-style mengdebeskrivelse.

Load-bearing MEASURED (tests/test_named_known_codes_loadbearing.py, 10 arms), eight mutations all
red against the WHOLE suite + green control 1873/5 (from 1863/5, superset, 0 removed) and golden
demo-transcript.stdout BYTE-UNCHANGED (shasum -a 1 of the CONTENT = ea8c534773acdbe41ae68f2c55724d69aaf8be4f):
A1 revert to the bare count (7 red) - A2 a share instead of a fixed window (6) - A3 sorted (1) -
A4 silent cut (2) - A5 character slice (3) - A6 no bound at all (3) - A7 break the rejection_stage
marker (2, one an OLDER independent witness) - A8 grow the magnitude half with a code list
(2, one an OLDER independent witness).

Honesty limits, stated: no paid run yet confirms this changes the outcome live (that is DEL D);
the truncation branch is exercised only synthetically because nothing measured reaches the window;
and the schedule still does not reach the prompt, so the first attempt guesses as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:52:37 +02:00
ac0bfdba27 feat(p21): a declaration that must have LOOKED, and a refusal that names the neighbours
C1. Round 4 produced 13 declarations over six runs and NOT ONE named a fasit concept.
The distinct documents opened before each were 1,1,1,1,1,1,1,2,5,5,6,13,13: seven
declared the base's FIRST requirement after opening exactly ONE document.

The order offered two rules and asked which discriminates. Replayed against the real
listings: "the declared document must have come back from a read_dir filtered on a word
from the approach's label" refuses 13 of 13 -- including Soraasen's 12.11, the closest
any run came -- because ZERO of the 13 were reached through a filtered listing at all.
A gate that refuses every measured case, right and wrong alike, cannot discriminate.
"fewer than k distinct documents opened" at k=3 refuses 8 of 13 and keeps the five that
navigated. k=3, 4 and 5 refuse the SAME eight -- the distribution has a gap between 2
and 5 -- so the threshold is not on a cliff, and 3 is the lowest of that plateau.
DISTINCT paths, not calls, and capped by the base's own size so a small base stays
declarable.

C2. Over the same traces 18 of 143 path-bearing calls named a path the base does not
hold, ELEVEN of them one run walking R761/4-3, 4.3, 4-2, 4-1, 4-0, 4-5, 4-6 while the
real names are R761/4, R761/41, R761/42. The refusal already named the nearest listable
ancestor; now it also names up to five of that rung's own subdirectories, ranked by
longest common prefix with the segment that failed. ONE copy shared by both refusal
sites, built from context_files through in_dimension, so every name handed back resolves
and the verdict layer can never be advertised in an apology. MEASURED after: 16 of 18.

Load-bearing MEASURED, four mutations all red against the WHOLE suite, green control
1863/5, golden byte-unchanged: C3(i) the declaration gate detached (2 red) . C3(ii) the
neighbour list empty (6) . C3(iii) built from files (1, the verdict arm alone) . C3(iv)
count CALLS instead of distinct documents (1, the repetition arm alone).

Three existing arms REWRITTEN, not weakened: all three read one document and declared,
which is the measured failure class exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:38:57 +02:00
7b4f85d77c feat(p21): the PROJECT carries the price, so a run against a road normal can be anchored
Four paid stress rounds ran entirely UN-ANCHORED, all of them, because the one file
loader reads cost-baseline.json out of the BUNDLE and no vegnormal ships one: N100,
N200, N500 and R761 are knowledge, and knowledge carries requirements, never amounts.
The validator's stage 0 -- the one stage that tells an invented cost line from a line
this project actually buys -- was skipped in every single run, so "validated" could not
mean what it says. P20 G1/G2 measured real R761 process numbers (12.11 three times on
Soraasen, 1.1.1 on Lindaas) validating with amounts nobody had anywhere.

--cost-baseline FILE is PM decision (e), taken over the three alternatives P20 wrote
down. A LOADED object, never a path (prepass_payload's rule): the CLI owns the file and
loads it ONCE, so the notice, the stamp and every base of an --across-bundle pass all
descend from one read. ONE parse, two doors -- load_cost_baseline delegates to
load_cost_baseline_file -- while safe_resolve stays on the bundle door alone, because a
project's own schedule is legitimately outside every base. No tolerant twin: this path
exists only because an operator NAMED a file.

DEL B: five anchored context sets, a1-a3 with their line and a4 with none, so stage 0 is
what catches the falsification arm. THE ORDER'S OWN ARM (h) WAS FELLED BY MEASUREMENT:
"no baseline code is a requirement number the base declares" is measured 0 of 4 on the
project-coded sets and 5 of 5 on kontrakt-sorasen -- which is what R761 Prosesskoden IS,
a bill of quantities priced BY process code. The complement keeps both, and the order's
own mutation still bites.

DEL B3: the judge reports anchored (off the run's own stamp), priced per row, and WHICH
falsifier caught the falsification arm.

Load-bearing MEASURED, five mutations all red against the WHOLE suite, green control
1850/5 (from 1809/5, superset, 0 removed), golden byte-unchanged:
A3(i) the flag is read but the baseline is unused (3 red) . A3(ii) only the first base
gets it (1) . A3(iii) report_forbidden drops it (1) . B2(i) a4 gets a line (1, arm (g)
alone) . B2(ii) a code swapped to 12.11 (2, arms (f) and (h)).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:49:10 +02:00
e513bc97ad fix(p20): code_forms follows ITS OWN approach, and the announcement has a witness [skip-docs]
Two defects the mutation battery and the paid round found, both measured before
being touched.

(1) code_forms described the WRONG candidate. Every per-approach artefact copied
the run's stamp and overrode only validator_decision, so an artefact about
approach 2 reported approach 1's codes. Measured in BOTH round 3 and round 4 --
and stress.py, which reads this field before re-deriving, then produced an EMPTY
prose_codes for every approach but the first, which is what round 3's table was
built on. The field's own comment already says it is stamped "off the proposal
being stamped"; run-level was the drift, not the intent. Model, citations and
token usage stay the run's, because they are the run's.

(2) The C2 announcement seam had no witness. Mutation C-iii reverted the call
site to `args.project_id or "the portfolio"` and the WHOLE suite stayed green
(1808/5): all three arms drove announced_subject directly. The missing arm drives
main() on a free dry run and reads the announcement off STDOUT, where an operator
reads it, and is red against exactly that mutation.

Sixteen mutations, ALL red against the whole suite. Green control 1809/5 (from
1781, +28, 0 removed), golden demo-transcript.stdout BYTE-UNCHANGED
(shasum -a 1 of the CONTENT = ea8c534773acdbe41ae68f2c55724d69aaf8be4f).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 08:28:44 +02:00
c8f0c8f7c4 feat(p20): the requirement that is RIGHT, and a clause number that is not a price
Three seams, one commit: A, B and C touch the same four modules (run.py carries
the debate task, the grounding composition and the announcement; okf.py carries
one reference-number vocabulary read by both A and B), so splitting them into
three commits would have meant hunk-level staging of entangled files. Stated
rather than silently restructured.

A — the declaration answers with the DOCUMENT's own words. Measured: 13
declarations over round 3 and P17b, not one naming a fasit concept, while the
tool answered {"declared": true, ...} by echoing the caller's own arguments. It
now returns the document's title and req_number, read off Bundle.context_files
(so the type: verdict layer can never be named back), plus the sentence saying
what the declaration binds. A path the base carries as no concept answers with
empty strings rather than refusing. The commission's success_criteria now reach
the DEBATE task through mandate.criteria_block, the one renderer, empty when
there are none — which is what keeps every un-commissioned prompt, and the
golden, byte-identical.

B — a clause number is not a price. THE ORDER'S OWN RULE WAS FELLED BY
MEASUREMENT: it asks to refuse a code that IS declared req_number/prosessnr,
and neither of its two known positives is. n500 declares seksjon 10.4.1..10.4.4
but never the bare 10.4; r761 declares 2727 prosessnr and 2753 seksjon, none of
them 1.10.4, which occurs once, as prose ("iht. vegnormal N200 kap. 1.10.4").
The COMPLEMENT fires on both and closes the hole _ground_against_input already
admits in writing -- "it fails OPEN on a coincidental match". Unanchored run +
requirement-shaped code + the base declares a vocabulary + the code is not in
it -> refused, naming the denominator. All five of kontrakt-sorasen's real
process codes ARE declared and pass, which is what keeps the one context set
built on real codes measurable. Replayed over all 24 codes of round 3 + P17b:
exactly the two known positives flip validated -> rejected, 22 unchanged.

C — a parse failure no longer burns the round ledger blind. _fetch_parsed takes
a BUILDER instead of a finished message list, so the retry carries the parse
reason; measured, kontrakt-sorasen-04 spent 11 of 12 rounds re-asking the same
question. And announced_subject names the routed bases instead of saying "the
portfolio" for a two-base commission.

Suite 1807/5 (from 1781, +26, 0 removed), golden demo-transcript.stdout
BYTE-UNCHANGED (shasum -a 1 of the CONTENT = ea8c534773acdbe41ae68f2c55724d69aaf8be4f),
ruff and mypy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:02:46 +02:00
c4e88003e2 fix(p17b): close the flags multi-base mode neither carried nor refused [skip-docs]
Measured after the paid run, not before it: the across-bundle door honoured
fourteen flags and refused five, which left eight accepted and then dropped. The
worst of them was ``--mcp-config`` -- configured egress with nothing printed,
which this repo forbids outright -- and ``PROJECT_ID``/``--docs-dir``, which
would LOOK honoured while the dispatch read each base's project from that base's
own IR projection and used each base as its own docs dir.

The two anchoring flags are WIRED rather than refused. They are bundle concerns
and this dispatch hands ``run_project`` one bundle at a time, so they compose
exactly -- and ``--require-cost-baseline`` is the named remedy for the defect
this session's own paid run measured (``1.10.4``, a requirement number accepted
as a cost code on a base with no schedule: P19 F1, now reproduced on a second
base). Wiring the free drill too, so the dry run and the paid run cannot
disagree about what the run will do.

The two ``requires --bundle-dir`` guards no longer answer for this mode: falling
through would tell an operator to add the one flag this mode also refuses, which
is the repo's own standing objection to that pattern.

Mutation (v) -- the requirement reaches the dispatch and never the per-base runs
-- is red on its own arm against the whole suite. Suite 1781/5, golden unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 05:02:05 +02:00
da0ccd0489 feat(p17b): a context set that spans TWO bases, and a judge told which one [skip-docs]
``contexts/dekke-og-kontrakt-lindaas-2027`` is the first set whose approaches
route at more than one knowledge base: a1/a2 at n200-2024 (material requirements)
and a3/a4 at r761-2025 (the rig, and the falsification arm). That is the whole
reason it exists -- P17b measures that ONE commission can be run across several.

``bundle.txt`` grows a block per base; a set naming one base is one block, so the
four pre-P17b files parse byte-identically. The reader now has ONE home
(``stress.read_bundle_declarations``): it used to be a private copy in the P14
gate and a second, looser one inside ``stress.main``, and the multi-base form is
exactly the change that would have let them drift.

Rule U becomes the UNION of every declared base, and that is not a formality.
MEASURED 15.09: ``enhetspris`` is absent from n200-2024 and carried by 70 of
r761-2025's 2 756 concepts, so anchors admitted per base would have admitted a
question the pass as a whole CAN ground. It was dropped from the fifth set's
anchors for that reason.

``score_context_set(bundle_id=...)`` restricts the judgement to the approaches
routed at THIS base. Without it, judging the n200 outbox reports the r761
approach as ``not_evaluated``/``absent`` -- a false finding, because that
approach WAS evaluated, against the other base, under the other run_id. That
defect is pinned by its own arm. The judge's CLI refuses to guess when a set
declares several bases, with an rc-0 control on ``--bundle``.

Arm (d) gained a second half: every DECLARED base must be named by some
approach, because a base no approach names is never run.

The P19/B2 fasit denominator moved 26 -> 32 and is asserted, not dropped: six new
references, two of them bare ``prosessnr`` (12.11, 12.12), so B1's
punctuation-and-digits form is now exercised by a fasit and not only by a
known-positive.

Suite 1774/5, golden byte-unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 04:44:37 +02:00