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>
This commit is contained in:
parent
e47be68b57
commit
38df79126f
8 changed files with 532 additions and 37 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -27,11 +27,14 @@ Python ≥3.10. MAF (`agent-framework-core` 1.16.0, `-orchestrations` 1.1.1 —
|
||||||
- `shared/` er en **git subtree** av [`portfolio-optimiser-commons`](https://git.fromaitochitta.com/open/portfolio-optimiser-commons) (source of truth, R1 realisert 2026-07-03; publisert i `open/` 2026-08-04 — `commons`-remoten peker fortsatt på den private namespacen og virker uendret). Synk er **pull-only**: endringer committes i commons og hentes med `git subtree pull --prefix=shared commons main --squash`. ALDRI `git subtree push` fra konsument — re-split lekker hele konsument-historikken inn i commons (observert + opprydd 2026-07-03). Se `shared/README.md`. Wheelen bærer treet som pakkede data siden Fase 4a — se invarianten under.
|
- `shared/` er en **git subtree** av [`portfolio-optimiser-commons`](https://git.fromaitochitta.com/open/portfolio-optimiser-commons) (source of truth, R1 realisert 2026-07-03; publisert i `open/` 2026-08-04 — `commons`-remoten peker fortsatt på den private namespacen og virker uendret). Synk er **pull-only**: endringer committes i commons og hentes med `git subtree pull --prefix=shared commons main --squash`. ALDRI `git subtree push` fra konsument — re-split lekker hele konsument-historikken inn i commons (observert + opprydd 2026-07-03). Se `shared/README.md`. Wheelen bærer treet som pakkede data siden Fase 4a — se invarianten under.
|
||||||
|
|
||||||
## Kommandoer
|
## Kommandoer
|
||||||
- Sync: `uv sync` — installerer to konsoll-kommandoer: `portfolio-optimiser` (CLI, `run:main`) og
|
- Sync: `uv sync` — installerer tre konsoll-kommandoer: `portfolio-optimiser` (CLI, `run:main`),
|
||||||
`portfolio-optimiser-demo` (offline-beviset, `simulation:main`). `python -m`-formene virker
|
`portfolio-optimiser-demo` (offline-beviset, `simulation:main`) og `portfolio-optimiser-toolbox`
|
||||||
uendret og er byte-identiske på stdout (målt). Bevisst KUN to av fem `main()` —
|
(kjørestiens steg som rene kommandoer, `toolbox:main` — 20.09.2026). `python -m`-formene virker
|
||||||
|
uendret og er byte-identiske på stdout (målt). Bevisst KUN tre av seks `main()` —
|
||||||
`costsim`/`hitl`/`preflight` er operatørverktøy, ikke produktets inngang, og hvert navn her er et
|
`costsim`/`hitl`/`preflight` er operatørverktøy, ikke produktets inngang, og hvert navn her er et
|
||||||
navn frysen må bære. Pinnet av `tests/test_console_entry_points.py` mot den INSTALLERTE
|
navn frysen må bære. Verktøykassen er der fordi den er det ENE de to andre ikke kan brukes til:
|
||||||
|
hver vei gjennom `run` bygger en chatklient, og stegene den bygger på trenger ingen modell.
|
||||||
|
Pinnet av `tests/test_console_entry_points.py` mot den INSTALLERTE
|
||||||
distribusjonens metadata, ikke mot TOML-en: en `[project.scripts]`-linje som aldri er `uv sync`-et
|
distribusjonens metadata, ikke mot TOML-en: en `[project.scripts]`-linje som aldri er `uv sync`-et
|
||||||
er en påstand, ikke en kommando.
|
er en påstand, ikke en kommando.
|
||||||
- Test: `uv run pytest`
|
- Test: `uv run pytest`
|
||||||
|
|
|
||||||
36
README.md
36
README.md
|
|
@ -72,10 +72,38 @@ Both are tag-pinned deliberately: they are security components, and a version th
|
||||||
an install is a gate that can stop gating without a local diff. `uv sync` from a clone reads the
|
an install is a gate that can stop gating without a local diff. `uv sync` from a clone reads the
|
||||||
pins from `pyproject.toml`, which is why the source path above needs none of this.
|
pins from `pyproject.toml`, which is why the source path above needs none of this.
|
||||||
|
|
||||||
`uv sync` installs two commands: `portfolio-optimiser` (the CLI) and `portfolio-optimiser-demo`
|
`uv sync` installs three commands: `portfolio-optimiser` (the CLI), `portfolio-optimiser-demo`
|
||||||
(the offline end-to-end proof). They are equivalent to the `python -m portfolio_optimiser.run` and
|
(the offline end-to-end proof) and `portfolio-optimiser-toolbox` (the run-path steps as plain
|
||||||
`python -m portfolio_optimiser.simulation` forms used throughout this README, which keep working —
|
commands). They are equivalent to the `python -m portfolio_optimiser.run`,
|
||||||
the module form is spelled out below so a reader can see which module answers a given command.
|
`python -m portfolio_optimiser.simulation` and `python -m portfolio_optimiser.toolbox` forms used
|
||||||
|
throughout this README, which keep working — the module form is spelled out below so a reader can
|
||||||
|
see which module answers a given command.
|
||||||
|
|
||||||
|
### The toolbox: run-path steps without a model
|
||||||
|
|
||||||
|
Every path through the CLI above builds a debate, and therefore a chat client. The steps the
|
||||||
|
debate is built *on* need no model at all, and `portfolio-optimiser-toolbox` exposes them so an
|
||||||
|
outside caller — you at a terminal, or an agent that is not this framework — can drive them
|
||||||
|
directly. No model call, no network, JSON on stdout, and an exit code that says what happened:
|
||||||
|
`0` the step ran, `2` the call was malformed, `3` the step refused and the refusal is named.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Open a knowledge base and report what navigation reached (and every link it could not follow)
|
||||||
|
uv run portfolio-optimiser-toolbox navigate-bundle --bundle-dir shared/examples/bygg-energi-mikro
|
||||||
|
|
||||||
|
# Retrieve citation-ready chunks — the same call the agents' in-process tool makes
|
||||||
|
uv run portfolio-optimiser-toolbox retrieve-chunks \
|
||||||
|
--query "LED retrofit" --docs-dir shared/examples/bygg-energi-mikro --top-k 2
|
||||||
|
|
||||||
|
# Derive the cost baseline from a priced schedule already in the base
|
||||||
|
uv run portfolio-optimiser-toolbox cost-baseline --bundle-dir <base> --project-id <id>
|
||||||
|
|
||||||
|
# Admit (or refuse, by name) a declared pre-pass cut before it may shape a run
|
||||||
|
uv run portfolio-optimiser-toolbox prepass-admit --payload <cut.json> --bundle-dir <base>
|
||||||
|
```
|
||||||
|
|
||||||
|
Each subcommand calls the same function the run path calls — not a copy of it. That is what makes
|
||||||
|
the answers you get here the answers the debate gets.
|
||||||
|
|
||||||
Verify the install by running the whole suite from the clean clone:
|
Verify the install by running the whole suite from the clean clone:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ dependencies = [
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
portfolio-optimiser = "portfolio_optimiser.run:main"
|
portfolio-optimiser = "portfolio_optimiser.run:main"
|
||||||
portfolio-optimiser-demo = "portfolio_optimiser.simulation:main"
|
portfolio-optimiser-demo = "portfolio_optimiser.simulation:main"
|
||||||
|
portfolio-optimiser-toolbox = "portfolio_optimiser.toolbox:main"
|
||||||
|
|
||||||
# Distribution channel for the shared ingest library (mirrors portfolio-optimiser-claude,
|
# Distribution channel for the shared ingest library (mirrors portfolio-optimiser-claude,
|
||||||
# verified in consumer CI): git pin against the public Forgejo repo — reproducible for every
|
# verified in consumer CI): git pin against the public Forgejo repo — reproducible for every
|
||||||
|
|
|
||||||
|
|
@ -130,11 +130,14 @@
|
||||||
"scope": "run_project"
|
"scope": "run_project"
|
||||||
},
|
},
|
||||||
"entry": {
|
"entry": {
|
||||||
"kind": "console-script",
|
"kind": "subcommand",
|
||||||
"module": "run.py",
|
"module": "toolbox.py",
|
||||||
"scope": "main"
|
"scope": "main",
|
||||||
|
"command": "navigate-bundle"
|
||||||
},
|
},
|
||||||
"probe": []
|
"probe": [
|
||||||
|
"tests/test_toolbox_doors.py::test_navigate_bundle_from_outside_reports_every_file_the_base_holds"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "kostnadsgrunnlag",
|
"id": "kostnadsgrunnlag",
|
||||||
|
|
@ -146,11 +149,14 @@
|
||||||
"scope": "run_project"
|
"scope": "run_project"
|
||||||
},
|
},
|
||||||
"entry": {
|
"entry": {
|
||||||
"kind": "console-script",
|
"kind": "subcommand",
|
||||||
"module": "run.py",
|
"module": "toolbox.py",
|
||||||
"scope": "main"
|
"scope": "main",
|
||||||
|
"command": "cost-baseline"
|
||||||
},
|
},
|
||||||
"probe": []
|
"probe": [
|
||||||
|
"tests/test_toolbox_doors.py::test_cost_baseline_from_outside_derives_exactly_what_the_priced_table_says"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "kontekst",
|
"id": "kontekst",
|
||||||
|
|
@ -162,11 +168,14 @@
|
||||||
"scope": "run_project"
|
"scope": "run_project"
|
||||||
},
|
},
|
||||||
"entry": {
|
"entry": {
|
||||||
"kind": "console-script",
|
"kind": "subcommand",
|
||||||
"module": "run.py",
|
"module": "toolbox.py",
|
||||||
"scope": "main"
|
"scope": "main",
|
||||||
|
"command": "retrieve-chunks"
|
||||||
},
|
},
|
||||||
"probe": []
|
"probe": [
|
||||||
|
"tests/test_toolbox_doors.py::test_retrieve_chunks_from_outside_matches_the_in_process_seam_exactly"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "prepass",
|
"id": "prepass",
|
||||||
|
|
@ -178,11 +187,14 @@
|
||||||
"scope": "run_project"
|
"scope": "run_project"
|
||||||
},
|
},
|
||||||
"entry": {
|
"entry": {
|
||||||
"kind": "console-script",
|
"kind": "subcommand",
|
||||||
"module": "run.py",
|
"module": "toolbox.py",
|
||||||
"scope": "main"
|
"scope": "main",
|
||||||
|
"command": "prepass-admit"
|
||||||
},
|
},
|
||||||
"probe": []
|
"probe": [
|
||||||
|
"tests/test_toolbox_doors.py::test_prepass_admit_from_outside_admits_the_producers_own_payload"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "validering",
|
"id": "validering",
|
||||||
|
|
|
||||||
190
src/portfolio_optimiser/toolbox.py
Normal file
190
src/portfolio_optimiser/toolbox.py
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
"""The toolbox door: po's run-path steps as ONE command line, without a chat client on the way.
|
||||||
|
|
||||||
|
This is B's premise made callable. The framework's own CLI (``portfolio_optimiser.run``) drives a
|
||||||
|
MAF debate and therefore constructs a chat client on every path through it; an outside caller —
|
||||||
|
a human at a terminal, or an agent that is NOT po — cannot reach ``navigate_bundle`` or
|
||||||
|
``retrieve_chunks`` through that door without paying for a model. The steps themselves need no
|
||||||
|
model at all. This module exposes exactly those steps, and nothing else.
|
||||||
|
|
||||||
|
**One CLI, four subcommands, one core call each.** Each subcommand parses arguments, calls the
|
||||||
|
SAME function the run path calls, writes the result as JSON on stdout, and returns an exit code
|
||||||
|
that says what happened: ``0`` the step ran, ``2`` the call was malformed (argparse), ``3`` the
|
||||||
|
step refused and the refusal is named in the JSON. There is no fourth code and no silent zero —
|
||||||
|
the exit code is the only signal a calling agent has before it reads a byte.
|
||||||
|
|
||||||
|
**No re-implementation, and that is the load-bearing part.** Every handler below is a thin
|
||||||
|
adapter: it converts strings to the types the core function already takes and converts what came
|
||||||
|
back to JSON. A handler that computed anything of its own would be a second implementation of a
|
||||||
|
run-path step, and the two would drift — which is the one thing a toolbox door must not do,
|
||||||
|
because the whole claim is that the outside caller gets what the debate gets.
|
||||||
|
|
||||||
|
**Deliberately NOT here:** anything that needs a chat client, anything that writes into the
|
||||||
|
run's outbox, and any path back into ``portfolio_optimiser.run``. The import list is part of the
|
||||||
|
contract: B-gate row 1 refuses a door whose entry reaches a chat-client name, and row 3 refuses
|
||||||
|
a repository with a written path to Claude. Both read this file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from portfolio_optimiser import okf, prepass
|
||||||
|
from portfolio_optimiser.datasource import retrieve_chunks
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
#: Exit code for a step that REFUSED. Separate from 2 (malformed call) because they are two
|
||||||
|
#: different facts about the caller's request: 2 means "you asked wrong", 3 means "you asked
|
||||||
|
#: right and the answer is no". An agent retries the first and reports the second.
|
||||||
|
REFUSED = 3
|
||||||
|
|
||||||
|
#: What a refusal is allowed to be. Everything here is a REFUSAL the core functions document —
|
||||||
|
#: a bad bundle, an unpriced schedule, a payload that is not this base's cut. A bug in po is not
|
||||||
|
#: in this tuple and must keep crashing with its traceback.
|
||||||
|
_REFUSALS = (ValueError, FileNotFoundError, OSError)
|
||||||
|
|
||||||
|
|
||||||
|
def navigate_bundle_command(args: argparse.Namespace) -> Mapping[str, Any]:
|
||||||
|
"""``navigate-bundle`` — open a knowledge base and report what navigation reached.
|
||||||
|
|
||||||
|
``skipped`` is carried out of the door rather than summarised away: "this document was never
|
||||||
|
written" and "the link to it was wrong" are two different repairs, and a caller that only
|
||||||
|
saw a file count could act on neither."""
|
||||||
|
bundle = okf.navigate_bundle(args.bundle_dir)
|
||||||
|
return {
|
||||||
|
"bundle_dir": bundle.dir,
|
||||||
|
"files": [
|
||||||
|
{"name": f.name, "type": f.type, "characters": len(f.body)} for f in bundle.files
|
||||||
|
],
|
||||||
|
"skipped": [
|
||||||
|
{"from_file": s.from_file, "target": s.target, "reason": s.reason}
|
||||||
|
for s in bundle.skipped
|
||||||
|
],
|
||||||
|
"counts": {"files": len(bundle.files), "skipped": len(bundle.skipped)},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cost_baseline_command(args: argparse.Namespace) -> Mapping[str, Any]:
|
||||||
|
"""``cost-baseline`` — derive the anchor from a priced schedule already in the base.
|
||||||
|
|
||||||
|
``project_id`` is required for the reason ``okf.derive_cost_baseline`` requires it: the base
|
||||||
|
carries one too, and reading it here would make this a second reader of a fact that has an
|
||||||
|
owner."""
|
||||||
|
bundle = okf.navigate_bundle(args.bundle_dir)
|
||||||
|
baseline = okf.derive_cost_baseline(bundle, project_id=args.project_id)
|
||||||
|
return baseline.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve_chunks_command(args: argparse.Namespace) -> list[dict[str, Any]]:
|
||||||
|
"""``retrieve-chunks`` — the shared data-source call, byte for byte.
|
||||||
|
|
||||||
|
The SAME function the in-process ``FunctionTool`` and the MCP path call. An outside caller
|
||||||
|
gets the citation-ready shape the agents get, not a variant of it."""
|
||||||
|
return retrieve_chunks(args.query, args.docs_dir, args.top_k)
|
||||||
|
|
||||||
|
|
||||||
|
def prepass_admit_command(args: argparse.Namespace) -> Mapping[str, Any]:
|
||||||
|
"""``prepass-admit`` — everything that must hold before a declared cut may shape a run.
|
||||||
|
|
||||||
|
The identity check needs the base's RESOLVED id, so the door resolves it the one way the
|
||||||
|
repository resolves it (``okf.reconcile_bundle_id``) rather than reading the mount name."""
|
||||||
|
payload = prepass.load_prepass_payload(args.payload)
|
||||||
|
resolved = okf.reconcile_bundle_id(args.bundle_dir)
|
||||||
|
prepass.admit_payload(
|
||||||
|
payload,
|
||||||
|
bundle_dir=args.bundle_dir,
|
||||||
|
resolved_id=resolved,
|
||||||
|
dimension=args.dimension,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"admitted": True,
|
||||||
|
"bundle_id": resolved.id,
|
||||||
|
"bundle_id_origin": resolved.origin,
|
||||||
|
"mount": resolved.mount,
|
||||||
|
"considered": payload.denominators.considered,
|
||||||
|
"withheld": payload.denominators.withheld,
|
||||||
|
"delivered": payload.denominators.delivered,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""The four doors, each registered by name.
|
||||||
|
|
||||||
|
``required=True`` on the subparsers: a toolbox invoked with no command must be a usage error,
|
||||||
|
never a zero. Measured as a class in this repository — an exit 0 for a call that did nothing
|
||||||
|
is indistinguishable, to an automated caller, from a call that did everything."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="portfolio-optimiser-toolbox",
|
||||||
|
description="po's run-path steps as plain commands — no model call, no network. "
|
||||||
|
"Exit 0 when the step ran, 2 on a malformed call, 3 when the step refused.",
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
naviger = sub.add_parser("navigate-bundle", help="open a knowledge base and report its files")
|
||||||
|
naviger.add_argument("--bundle-dir", required=True, help="the base's root directory")
|
||||||
|
|
||||||
|
kostnad = sub.add_parser("cost-baseline", help="derive the cost baseline from a priced table")
|
||||||
|
kostnad.add_argument("--bundle-dir", required=True, help="the base's root directory")
|
||||||
|
kostnad.add_argument("--project-id", required=True, help="the project the baseline anchors")
|
||||||
|
|
||||||
|
hent = sub.add_parser("retrieve-chunks", help="retrieve citation-ready chunks")
|
||||||
|
hent.add_argument("--query", required=True, help="what to retrieve for")
|
||||||
|
hent.add_argument("--docs-dir", required=True, help="the folder to retrieve from")
|
||||||
|
hent.add_argument("--top-k", type=int, default=3, help="how many chunks (default: 3)")
|
||||||
|
|
||||||
|
slipp = sub.add_parser("prepass-admit", help="admit a declared pre-pass cut, or refuse it")
|
||||||
|
slipp.add_argument("--payload", required=True, help="the producer's payload JSON")
|
||||||
|
slipp.add_argument("--bundle-dir", required=True, help="the base the cut claims to be of")
|
||||||
|
slipp.add_argument("--dimension", default=None, help="restrict admission to one dimension")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(args: argparse.Namespace) -> Any:
|
||||||
|
"""Name the four handlers, one branch each — deliberately not a ``set_defaults(handler=…)``.
|
||||||
|
|
||||||
|
The dispatch table argparse offers is one line shorter and hides the only thing a reader of
|
||||||
|
this module wants to see: which command reaches which run-path step. B-gate row 1 asks the
|
||||||
|
same question of the source (it walks the call graph from ``main`` down to the step's symbol)
|
||||||
|
and a callable stored in a Namespace is a hop neither a reader nor the gate can follow."""
|
||||||
|
if args.command == "navigate-bundle":
|
||||||
|
return navigate_bundle_command(args)
|
||||||
|
if args.command == "cost-baseline":
|
||||||
|
return cost_baseline_command(args)
|
||||||
|
if args.command == "retrieve-chunks":
|
||||||
|
return retrieve_chunks_command(args)
|
||||||
|
if args.command == "prepass-admit":
|
||||||
|
return prepass_admit_command(args)
|
||||||
|
raise RuntimeError(f"unregistered command {args.command!r}") # pragma: no cover - argparse
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
result = dispatch(args)
|
||||||
|
except _REFUSALS as refusal:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"kind": type(refusal).__name__,
|
||||||
|
"message": str(refusal),
|
||||||
|
"command": args.command,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sys.stdout,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
return REFUSED
|
||||||
|
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - dekket av subprosess-probene
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -44,7 +44,7 @@ _NOW = datetime(2026, 9, 19, 12, 0, tzinfo=timezone.utc)
|
||||||
#: Repoets egne tall ved skriving (19.09.2026), talt av armene under mot KILDEN. De står her for
|
#: Repoets egne tall ved skriving (19.09.2026), talt av armene under mot KILDEN. De står her for
|
||||||
#: at en stille endring i kjørestien skal vise seg som en rød test og ikke som et nytt tall.
|
#: at en stille endring i kjørestien skal vise seg som en rød test og ikke som et nytt tall.
|
||||||
_STEPS_TODAY = 17
|
_STEPS_TODAY = 17
|
||||||
_EXTERNAL_TODAY = 1
|
_EXTERNAL_TODAY = 5
|
||||||
_ROLES_TODAY = ("proposer", "checker")
|
_ROLES_TODAY = ("proposer", "checker")
|
||||||
_PATTERNS_TODAY = 15
|
_PATTERNS_TODAY = 15
|
||||||
|
|
||||||
|
|
@ -106,16 +106,25 @@ def test_the_step_denominator_equals_an_independent_count_of_the_source() -> Non
|
||||||
assert row.n == _STEPS_TODAY
|
assert row.n == _STEPS_TODAY
|
||||||
|
|
||||||
|
|
||||||
def test_row1_today_is_red_with_the_steps_that_have_no_door_named() -> None:
|
#: Stegene som HAR en dør ut 20.09.2026, skrevet ut. Fire av dem er verktøykassens under-
|
||||||
"""Var «3 av 17» til 19.09, så 0 av 17 da proben ble BUNDET til steget. Fra 20.09 teller
|
#: kommandoer (steg 2 i byggerekkefølgen); ``gate`` er v1-gatens egen kommando, som alltid var
|
||||||
``gate`` igjen: den proben kjører v1-gaten som subprosess og leser JSON-en ut av den, og
|
#: en ekte dørprobe og ble avvist på en navneteknikalitet til klasse-rettingen samme dag.
|
||||||
steget er alene bak den døren — den ble avvist på en navneteknikalitet, ikke på atferd."""
|
_WITH_A_DOOR_TODAY = ("gate", "kontekst", "kostnadsgrunnlag", "pakke", "prepass")
|
||||||
|
|
||||||
|
|
||||||
|
def test_row1_today_is_red_and_names_exactly_the_steps_that_have_a_door() -> None:
|
||||||
|
"""Var «3 av 17» til 19.09, så 0 av 17 da proben ble BUNDET til steget, og 5 av 17 fra 20.09.
|
||||||
|
Armen teller ikke bare k — den navngir HVILKE, så en dør som flytter seg uten å bli bygd
|
||||||
|
(eller en probe som slutter å drive sin dør) blir rød her og ikke bare et annet tall."""
|
||||||
passing = {n: "passed" for step in _CONFIG["steps"] for n in step.get("probe", ())}
|
passing = {n: "passed" for step in _CONFIG["steps"] for n in step.get("probe", ())}
|
||||||
row = gate.score_toolbox(_CONFIG["steps"], _SRC, _CONFIG["run_path"], passing, _REPO)
|
row = gate.score_toolbox(_CONFIG["steps"], _SRC, _CONFIG["run_path"], passing, _REPO)
|
||||||
assert (row.k, row.n, row.status) == (_EXTERNAL_TODAY, _STEPS_TODAY, gate.RED)
|
assert (row.k, row.n, row.status) == (_EXTERNAL_TODAY, _STEPS_TODAY, gate.RED)
|
||||||
named = {x.split(":")[0] for x in row.exceptions}
|
assert len(_WITH_A_DOOR_TODAY) == _EXTERNAL_TODAY
|
||||||
assert "validering" in named and "utboks" in named
|
uten_doer = {x.split(":")[0] for x in row.exceptions}
|
||||||
assert "prepass-artefakt" in named and "parse-feil" in named
|
alle = {str(s["id"]) for s in _CONFIG["steps"]}
|
||||||
|
assert sorted(alle - uten_doer) == sorted(_WITH_A_DOOR_TODAY), row.exceptions
|
||||||
|
assert "validering" in uten_doer and "utboks" in uten_doer
|
||||||
|
assert "prepass-artefakt" in uten_doer and "parse-feil" in uten_doer
|
||||||
bound = {x.split(":")[0] for x in row.exceptions if "atferdsprobe" in x}
|
bound = {x.split(":")[0] for x in row.exceptions if "atferdsprobe" in x}
|
||||||
assert bound == {"rundebinding", "rapport"}, row.exceptions
|
assert bound == {"rundebinding", "rapport"}, row.exceptions
|
||||||
|
|
||||||
|
|
@ -1862,3 +1871,38 @@ def test_the_row_limit_is_a_class_description_and_no_longer_an_enumeration() ->
|
||||||
assert "tekstmønstre" in limit or "tekstvakt" in limit
|
assert "tekstmønstre" in limit or "tekstvakt" in limit
|
||||||
assert "uttømmende" in limit
|
assert "uttømmende" in limit
|
||||||
assert "git-manifestet" in limit
|
assert "git-manifestet" in limit
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_subcommand_door_is_only_driven_when_the_command_stands_in_argv(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""For en underkommando ER kommandonavnet døren. En probe som starter CLI-en uten det har
|
||||||
|
kjørt en annen dør — eller ingen. Uten denne armen ville kravet kunne fjernes uten at noe
|
||||||
|
ble rødt: verktøykassens egne prober har alle navnet i argv."""
|
||||||
|
src = _fake_src(tmp_path, chat_client=False)
|
||||||
|
_write(
|
||||||
|
src / "verktoey.py",
|
||||||
|
"import argparse\n\nfrom fakesrc.steg import gjoer_noe\n\n\n"
|
||||||
|
"def main(argv=None):\n"
|
||||||
|
" parser = argparse.ArgumentParser()\n"
|
||||||
|
' sub = parser.add_subparsers(dest="kommando", required=True)\n'
|
||||||
|
' sub.add_parser("naviger-pakke")\n'
|
||||||
|
" parser.parse_args(argv)\n"
|
||||||
|
" return gjoer_noe()\n\n\n"
|
||||||
|
'if __name__ == "__main__":\n raise SystemExit(main())\n',
|
||||||
|
)
|
||||||
|
steg = _one_step()
|
||||||
|
steg[0]["entry"] = {
|
||||||
|
"kind": "subcommand",
|
||||||
|
"module": "verktoey.py",
|
||||||
|
"scope": "main",
|
||||||
|
"command": "naviger-pakke",
|
||||||
|
}
|
||||||
|
_write_probe(tmp_path, door="fakesrc.verktoey", needle="naviger-pakke")
|
||||||
|
row = gate.score_toolbox(steg, src, _fake_run_path(), _FAKE_PASSED, tmp_path)
|
||||||
|
assert (row.k, row.n) == (1, 1), row.exceptions
|
||||||
|
|
||||||
|
_write_probe(tmp_path, door="fakesrc.verktoey", needle="gjoer_noe")
|
||||||
|
row = gate.score_toolbox(steg, src, _fake_run_path(), _FAKE_PASSED, tmp_path)
|
||||||
|
assert (row.k, row.n) == (0, 1), row.exceptions
|
||||||
|
assert any("underkommandoen" in x for x in row.exceptions), row.exceptions
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,13 @@ clone materializes after ``uv sync``, so it is the only reading that can fail wh
|
||||||
merely *declared*. The declaration is checked too (the TOML is the source the metadata is built
|
merely *declared*. The declaration is checked too (the TOML is the source the metadata is built
|
||||||
from), but the metadata assert is the load-bearing one.
|
from), but the metadata assert is the load-bearing one.
|
||||||
|
|
||||||
Scope, stated so it is a decision and not an oversight: exactly TWO commands are exposed. ``run``
|
Scope, stated so it is a decision and not an oversight: exactly THREE commands are exposed.
|
||||||
is the framework CLI (three documented modes) and ``simulation`` is the offline end-to-end proof
|
``run`` is the framework CLI (three documented modes), ``simulation`` is the offline end-to-end
|
||||||
the README points a newcomer at. ``costsim`` / ``hitl`` / ``preflight`` keep the ``-m`` form — they
|
proof the README points a newcomer at, and ``toolbox`` (added 2026-09-20) is the door an outside
|
||||||
are operator utilities, not the product's front door, and every name added here is a name the
|
caller reaches the run-path steps through WITHOUT a chat client — B-gate row 1's premise, and the
|
||||||
freeze has to carry.
|
one thing the other two cannot be used for, since every path through ``run`` constructs a client.
|
||||||
|
``costsim`` / ``hitl`` / ``preflight`` keep the ``-m`` form — they are operator utilities, not the
|
||||||
|
product's front door, and every name added here is a name the freeze has to carry.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -32,6 +34,7 @@ _DIST = "portfolio-optimiser"
|
||||||
_EXPECTED: dict[str, str] = {
|
_EXPECTED: dict[str, str] = {
|
||||||
"portfolio-optimiser": "portfolio_optimiser.run:main",
|
"portfolio-optimiser": "portfolio_optimiser.run:main",
|
||||||
"portfolio-optimiser-demo": "portfolio_optimiser.simulation:main",
|
"portfolio-optimiser-demo": "portfolio_optimiser.simulation:main",
|
||||||
|
"portfolio-optimiser-toolbox": "portfolio_optimiser.toolbox:main",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
214
tests/test_toolbox_doors.py
Normal file
214
tests/test_toolbox_doors.py
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
"""B-gatens rad 1, steg 2: de fire verktøykasse-dørene, drevet UTENFRA som en kommando.
|
||||||
|
|
||||||
|
Hver arm her er en **atferdsprobe** i B-gatens forstand: den starter `portfolio_optimiser.toolbox`
|
||||||
|
som en subprosess med underkommandoens navn i argv, og asserterer på det kommandoen SKREV. Ingen
|
||||||
|
av dem importerer kjernefunksjonen og kaller den in-prosess — det er nettopp den forskjellen
|
||||||
|
gaten finnes for å måle, og en probe som gjør begge deler beviser ingenting om døren.
|
||||||
|
|
||||||
|
Nevneren for «hva kommandoen skal ha skrevet» er hentet UTENFOR døren i hver arm: filsystemet
|
||||||
|
(navigate-bundle), en tabell transkribert fra prisskjema-fixturen (cost-baseline), det in-prosess
|
||||||
|
API-et den deler søm med (retrieve-chunks), og produsentens egen innsjekkede nyttelast
|
||||||
|
(prepass-admit). En arm som sammenlignet døren med seg selv ville vært grønn uansett hva døren
|
||||||
|
gjorde.
|
||||||
|
|
||||||
|
INGEN modellkall, intet nett: hele poenget med verktøykassen er at disse stegene ikke har en
|
||||||
|
chatklient på veien, og gaten feller døren om de får en.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from portfolio_optimiser import datasource
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parents[1]
|
||||||
|
_MIKRO = _REPO / "shared" / "examples" / "bygg-energi-mikro"
|
||||||
|
_PRISSKJEMA = _REPO / "tests" / "fixtures" / "k2-prisskjema-SYNTETISK"
|
||||||
|
_PREPASS_PAYLOAD = (
|
||||||
|
_REPO / "tests" / "fixtures" / "prepass" / "bygg-energi-mikro-fixture.payload.json"
|
||||||
|
)
|
||||||
|
_DECLARED_ID = "bygg-energi-mikro-fixture"
|
||||||
|
|
||||||
|
#: Prisskjema-fixturens tabell, transkribert fra fixturen — ikke fra derivasjonen som leser den.
|
||||||
|
_PRICED_ROWS = {
|
||||||
|
"21.1": (1250.0, 850.0),
|
||||||
|
"24.2": (48000.0, 42.5),
|
||||||
|
"36.1": (4.0, 187500.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _toolbox(*args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Døren, som en subprosess. ``-m``-formen med vilje: den virker i en ren klone uten sync."""
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, "-m", "portfolio_optimiser.toolbox", *args],
|
||||||
|
cwd=_REPO,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- navigate-bundle --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigate_bundle_from_outside_reports_every_file_the_base_holds() -> None:
|
||||||
|
"""Fasiten er FILSYSTEMET: mikro-basens `.md`-filer, talt her. Basen er bygget så hver av dem
|
||||||
|
nås fra indeksen, så «navigert» og «finnes» skal falle sammen — og gjør de det ikke, er det
|
||||||
|
dét armen skal si."""
|
||||||
|
proc = _toolbox("navigate-bundle", "--bundle-dir", str(_MIKRO))
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
payload = json.loads(proc.stdout)
|
||||||
|
paa_disk = sorted(p.name for p in _MIKRO.glob("*.md"))
|
||||||
|
assert sorted(f["name"] for f in payload["files"]) == paa_disk
|
||||||
|
assert payload["counts"]["files"] == len(paa_disk)
|
||||||
|
assert payload["bundle_dir"] == str(_MIKRO)
|
||||||
|
# Sporet bæres UT av døren, ikke summert bort: mikro-basens indeks lenker med vilje til
|
||||||
|
# `shared/README.md`, som ligger utenfor basen. En dør som bare ga et filtall ville latt
|
||||||
|
# «dokumentet finnes ikke» og «lenken var feil» se like ut for den som skal rette det.
|
||||||
|
assert payload["skipped"] == [
|
||||||
|
{"from_file": "index.md", "target": "../../README.md", "reason": "outside-bundle"}
|
||||||
|
]
|
||||||
|
assert payload["counts"]["skipped"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigate_bundle_refuses_a_directory_that_is_no_bundle_with_a_named_code() -> None:
|
||||||
|
"""rc-0-kontrollens motstykke: en avvisning må IKKE være 0. Exit-koden er det eneste
|
||||||
|
signalet en agent som kaller døren har."""
|
||||||
|
proc = _toolbox("navigate-bundle", "--bundle-dir", str(_REPO / "docs"))
|
||||||
|
assert proc.returncode == 3, proc.stdout
|
||||||
|
assert json.loads(proc.stdout)["error"]["kind"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- cost-baseline ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cost_baseline_from_outside_derives_exactly_what_the_priced_table_says() -> None:
|
||||||
|
proc = _toolbox(
|
||||||
|
"cost-baseline", "--bundle-dir", str(_PRISSKJEMA), "--project-id", "K2"
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
payload = json.loads(proc.stdout)
|
||||||
|
assert payload["project_id"] == "K2"
|
||||||
|
assert set(payload["items"]) == set(_PRICED_ROWS)
|
||||||
|
for code, (quantity, unit_cost) in _PRICED_ROWS.items():
|
||||||
|
assert payload["items"][code]["quantity"] == quantity, code
|
||||||
|
assert payload["items"][code]["unit_cost"] == unit_cost, code
|
||||||
|
|
||||||
|
|
||||||
|
def test_cost_baseline_refuses_an_unpriced_schedule_and_names_the_row() -> None:
|
||||||
|
proc = _toolbox(
|
||||||
|
"cost-baseline", "--bundle-dir", str(_MIKRO), "--project-id", "bygg-kontor-nord"
|
||||||
|
)
|
||||||
|
assert proc.returncode == 3, proc.stdout
|
||||||
|
assert json.loads(proc.stdout)["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- retrieve-chunks --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrieve_chunks_from_outside_matches_the_in_process_seam_exactly() -> None:
|
||||||
|
"""Den ene armen der fasiten ER det in-prosess API-et: verktøykassen skal gi en ekstern
|
||||||
|
kaller NØYAKTIG det MAF-agentene får gjennom sin FunctionTool, ikke en egen variant."""
|
||||||
|
proc = _toolbox(
|
||||||
|
"retrieve-chunks", "--query", "LED retrofit", "--docs-dir", str(_MIKRO), "--top-k", "2"
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
payload = json.loads(proc.stdout)
|
||||||
|
assert payload == datasource.retrieve_chunks("LED retrofit", str(_MIKRO), 2)
|
||||||
|
assert len(payload) == 2
|
||||||
|
assert payload[0]["snippet"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrieve_chunks_refuses_a_top_k_that_asks_for_nothing() -> None:
|
||||||
|
proc = _toolbox(
|
||||||
|
"retrieve-chunks", "--query", "x", "--docs-dir", str(_MIKRO), "--top-k", "0"
|
||||||
|
)
|
||||||
|
assert proc.returncode == 3, proc.stdout
|
||||||
|
assert "top_k" in json.loads(proc.stdout)["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- prepass-admit ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _base_declaring_its_id(tmp_path: Path) -> str:
|
||||||
|
"""Mikro-basen kopiert med `bundle_id` erklært — `shared/` er et pull-only subtree."""
|
||||||
|
root = tmp_path / "en-annen-mount"
|
||||||
|
shutil.copytree(_MIKRO, root)
|
||||||
|
index = root / "index.md"
|
||||||
|
lines = index.read_text(encoding="utf-8").split("\n")
|
||||||
|
assert lines[0].strip() == "---", "basen åpner ikke lenger med frontmatter"
|
||||||
|
lines.insert(1, f"bundle_id: {_DECLARED_ID}")
|
||||||
|
index.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
return str(root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepass_admit_from_outside_admits_the_producers_own_payload(tmp_path: Path) -> None:
|
||||||
|
"""Produsentens innsjekkede output, uendret. Uten denne rc-0-kontrollen måler avvisnings-
|
||||||
|
armen under ingenting — den ville vært grønn mot en dør som avviste alt."""
|
||||||
|
proc = _toolbox(
|
||||||
|
"prepass-admit",
|
||||||
|
"--payload",
|
||||||
|
str(_PREPASS_PAYLOAD),
|
||||||
|
"--bundle-dir",
|
||||||
|
_base_declaring_its_id(tmp_path),
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
payload = json.loads(proc.stdout)
|
||||||
|
assert payload["admitted"] is True
|
||||||
|
assert payload["bundle_id"] == _DECLARED_ID
|
||||||
|
assert payload["delivered"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepass_admit_refuses_a_payload_that_names_another_base(tmp_path: Path) -> None:
|
||||||
|
raw = json.loads(_PREPASS_PAYLOAD.read_text(encoding="utf-8"))
|
||||||
|
raw["bundle"]["bundle_id"] = "en-helt-annen-base"
|
||||||
|
endret = tmp_path / "endret.payload.json"
|
||||||
|
endret.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8")
|
||||||
|
proc = _toolbox(
|
||||||
|
"prepass-admit",
|
||||||
|
"--payload",
|
||||||
|
str(endret),
|
||||||
|
"--bundle-dir",
|
||||||
|
_base_declaring_its_id(tmp_path),
|
||||||
|
)
|
||||||
|
assert proc.returncode == 3, proc.stdout
|
||||||
|
assert "en-helt-annen-base" in json.loads(proc.stdout)["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- døren selv ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_toolbox_has_no_chat_client_on_any_of_its_paths() -> None:
|
||||||
|
"""Verktøykassens ene forutsetning, målt i kilden: ingen av de fire kommandoene drar en
|
||||||
|
modell med seg. Gaten måler det samme på rad 1; her står det som en egen påstand, fordi det
|
||||||
|
er DENNE modulens grunn til å finnes."""
|
||||||
|
modul = _REPO / "src" / "portfolio_optimiser" / "toolbox.py"
|
||||||
|
tre = ast.parse(modul.read_text(encoding="utf-8"))
|
||||||
|
kalt = {
|
||||||
|
node.func.attr if isinstance(node.func, ast.Attribute) else getattr(node.func, "id", "")
|
||||||
|
for node in ast.walk(tre)
|
||||||
|
if isinstance(node, ast.Call)
|
||||||
|
}
|
||||||
|
for navn in ("create_chat_client", "client_factory", "BaseChatClient", "get_backend"):
|
||||||
|
assert navn not in kalt, navn
|
||||||
|
|
||||||
|
importert: set[str] = set()
|
||||||
|
for node in ast.walk(tre):
|
||||||
|
if isinstance(node, ast.ImportFrom):
|
||||||
|
importert |= {f"{node.module}.{a.name}" for a in node.names}
|
||||||
|
elif isinstance(node, ast.Import):
|
||||||
|
importert |= {a.name for a in node.names}
|
||||||
|
assert not any(navn.startswith("portfolio_optimiser.run") for navn in importert), importert
|
||||||
|
assert "portfolio_optimiser.run" not in importert
|
||||||
|
# Kontroll: armen KAN se en import — uten den ville den vært grønn mot et tomt sett.
|
||||||
|
assert "portfolio_optimiser.okf" in importert or "portfolio_optimiser.prepass" in importert
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_usage_is_exit_two_and_never_a_silent_zero() -> None:
|
||||||
|
assert _toolbox().returncode == 2
|
||||||
|
assert _toolbox("ingen-slik-kommando").returncode == 2
|
||||||
|
assert _toolbox("navigate-bundle").returncode == 2
|
||||||
Loading…
Add table
Add a link
Reference in a new issue