- Python 99.9%
F15 re-verifisert EKSTERNT i dag (29.08): core siste er 1.16.0 (28.08), ikke 1.15.0 -
repoet er sju minor bak, ikke seks. Scratch-venv-diff (1.9.0 vs 1.16.0, slettet etter
bruk) bekrefter de to konkrete private-API-premissene testen navngir holder strukturelt
stabile. Fresh U1-U19-telling flytter U12 fra nei til ja siden
|
||
|---|---|---|
| docs | ||
| examples | ||
| scripts | ||
| shared | ||
| spikes | ||
| src/portfolio_optimiser | ||
| tests | ||
| .gitignore | ||
| .python-version | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| CODE_OF_CONDUCT.md | ||
| CONTRIBUTING.md | ||
| DEPLOY.md | ||
| env.template | ||
| LICENSE | ||
| llms.txt | ||
| main.py | ||
| pyproject.toml | ||
| README.md | ||
| SECURITY.md | ||
| uv.lock | ||
portfolio-optimiser
Generic, open framework on Microsoft Agent Framework (MAF): multi-agent cost-saving proposals gated by a mandatory deterministic validator, with HITL learning.
A generic, open framework — built on Microsoft Agent Framework (MAF) — that finds cost savings inside each project of a portfolio of independent projects. A swarm of agents generates candidate measures; a mandatory deterministic validator (solver + Monte Carlo) decides the numbers; domain experts judge the outcomes (human-in-the-loop); and the system learns from their verdicts across runs.
Solo-maintained, fork-and-own. This is a starting point, not a vendor product. One maintainer, no SLA, MIT licensed. Issues are welcome as signals; pull requests are not accepted. See the organisation governance for the full model — including what to adopt instead if you need vendor accountability.
AI-generated: all code produced by Claude Code through dialog-driven development, with human review, test and judgement before anything ships.
A short visual introduction — 12 slides, in Norwegian, for a general audience — ships with the repo: open docs/kort-presentasjon.html in any browser.
Table of Contents
- Install
- Walk the whole chain offline
- Non-goals
- Built on an LLM wiki: Karpathy's idea, Google's format
- AI-first, humans on top
- How it works
- How it is set up
- What this enables
- The task API — runnable Python, no wrapper
- Docs
- Stack & develop
Install
Python ≥3.10, with uv. The package is not published to a package
index — install it from source:
git clone https://git.fromaitochitta.com/open/portfolio-optimiser.git
cd portfolio-optimiser
uv sync
The clone is the documented path because the walkthrough below points at files in the tree. It is
no longer a technical requirement for the DATA: a built wheel carries the shared spec, the persona
skill and the example bundles under shared/ as packaged data, and the
resolver prefers a working tree when one is present (PORTFOLIO_SHARED_ROOT overrides both) — so
an installed distribution finds its knowledge without a checkout.
Installing a built wheel
A wheel is not installable on its own, and the failure is a resolver error rather than a
missing file. Two dependencies are pinned to git tags, and [tool.uv.sources] is uv configuration
that does not travel with wheel metadata — so the wheel names llm-ingestion-okf and
llm-ingestion-guard as bare names that no package index can resolve. Supply the two requirements
alongside the wheel (measured: 65 packages, exit 0):
uv pip install portfolio_optimiser-1.1.0-py3-none-any.whl \
"llm-ingestion-okf @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.3.2" \
"llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v0.3.4"
Both are tag-pinned deliberately: they are security components, and a version that can move under
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.
uv sync installs two commands: portfolio-optimiser (the CLI) and portfolio-optimiser-demo
(the offline end-to-end proof). They are equivalent to the python -m portfolio_optimiser.run and
python -m portfolio_optimiser.simulation 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.
Verify the install by running the whole suite from the clean clone:
uv run pytest
There is no CI runner in this organization, so nothing runs that suite automatically — the command above is the verification.
Walk the whole chain offline
Seven commands, no API key, no network, no cost. They exercise the real loop — context navigation over the knowledge base, the maker/checker debate, the deterministic validator, the verdict — with scripted stand-ins for the agents' answers. Every scripted invocation prints a banner saying so, because a scripted run that reads like a model run would be worse than having no offline mode at all. What this shows is that the loop closes and the gate bites; it does not show how well a given model would propose or judge.
1 — Look at the knowledge base. It is curated markdown, not a black box:
ls shared/examples/bygg-energi-mikro/
2 — Watch the learning loop close. Two runs separated by an expert approval, with the second demonstrably informed by the first:
uv run python -m portfolio_optimiser.simulation # or: uv run portfolio-optimiser-demo
Each run shows the refinement step: the proposer's first claim is falsified by the deterministic validator, and the corrected claim validates. Between the runs, knowledge travels back on both feedback timescales, each carrying its own marker so neither can stand in for the other: the persona's approved verdict is promoted into the file-backed wiki (promote → re-seed → fold), and a second verdict — an operations note an expert drops into an inbox folder after the run, the way a reviewer would days later — is merged from disk into the next run's store before its hypothesis is formed (write file → merge → fold). Both markers are present in Run B's prompt and absent from Run A's. Nothing crosses in memory.
The run is anchored: the demo copies the example knowledge base and adds a cost-baseline.json
— the project's actual cost lines — so the validator's first stage reconciles every cost line a
proposal claims against them, before the solver runs. The declared baseline is printed at the top,
because it is the one property the rest of the output looks identical with or without. Those
numbers are synthetic, derived from the scripted replies themselves; a knowledge base that ships
its own baseline replaces them through the same seam, with no code change.
3 — Run the loop over a knowledge base, with answers you supply. Write the stand-in replies, then point the CLI at the bundle:
cat > replies.json <<'JSON'
{
"proposer": "{\"measure\":\"LED-retrofit\",\"affected_items\":[{\"code\":\"ENERGI-TOTAL-EL\",\"quantity\":300000,\"unit_cost\":1.0}],\"claimed_saving_nok\":30000}",
"checker": "The numbers are within a feasible range. VERDICT: APPROVE"
}
JSON
uv run python -m portfolio_optimiser.run BYGG-KONTOR-NORD \
--docs-dir shared/examples/bygg-energi-mikro \
--bundle-dir shared/examples/bygg-energi-mikro \
--scripted-replies replies.json
Ends in ValidatedProposal. Swap --bundle-dir/--docs-dir for your own bundle to run it over
your own data — that is the point of this door, and the reason it is not the same thing as step 2.
Your bundle needs one file the ingest layer does not write for you: validator-input.json, the
candidate the deterministic validator judges (a bundle without it is refused, by design — see
docs/extending.md). Copy the shape from
shared/examples/bygg-energi-mikro/.
4 — Watch it say no. Raise claimed_saving_nok to 250000 in replies.json and run the same
command again. The outcome becomes Rejection: the deterministic validator refuses a saving the
project's own numbers cannot support, no matter how confidently the proposer asserted it. This is
the part of the method that carries the weight — the agents propose, and something that cannot be
argued with decides.
Read that summary line carefully. Nobody reviewed this run, so it says exactly that:
Rejection (no expert verdict given; verdict key=…). Rejection is the validator's outcome;
the second half is about the human, and there was no human here. The key it quotes is the id
under which an expert verdict on this candidate will arrive later — your join back into the
expert-answer channel.
Record one and the line changes:
uv run python -m portfolio_optimiser.run BYGG-KONTOR-NORD \
--docs-dir shared/examples/bygg-energi-mikro \
--bundle-dir shared/examples/bygg-energi-mikro \
--scripted-replies replies.json \
--decision approved --rationale "the retrofit is within scope"
Now it reads Rejection (verdict id=…, decision=approved), which is not a contradiction: a machine
gate that blocks and a human judgement that approves are different questions and are never
collapsed into one field. The two flags go together or not at all — half a verdict is refused by
name, because the missing half is the expert's to write and never ours to default. Until 1.1.0
--decision defaulted to approved, so every flagless run recorded an approval nobody gave and
carried it into the next project's hypothesis; that default is gone.
5 — See what it would cost with a real model, before spending anything:
uv run python -m portfolio_optimiser.costsim --projects 4 --profile local
Modelled upper bounds per role and model, with the source of each price quoted. --profile local
prices the free local backend; the estimate is a ceiling, not a bill.
6 — Run the whole portfolio, and watch the gate anchor to each project separately. The same flag works across every bundled reference project at once:
cat > replies.json <<'JSON'
{
"proposer": "{\"measure\":\"scope_reduction\",\"affected_items\":[{\"code\":\"01.1\",\"quantity\":1,\"unit_cost\":850000}],\"claimed_saving_nok\":40000}",
"checker": "Rigging and site operations can absorb this reduction. VERDICT: APPROVE"
}
JSON
uv run python -m portfolio_optimiser.run --portfolio --scripted-replies replies.json
One ValidatedProposal, three Rejection. All four reference projects carry a cost line 01.1,
but at four different amounts — so a claim stated against one project's estimate is refused for the
other three. Nothing about the proposal changed between them; what changed is the project's own
numbers, which is the whole point of anchoring the gate to a cost baseline rather than to the
proposal's internal arithmetic.
The four lines quote the same verdict id=. That is not a bug: a verdict is keyed on the
candidate it judges, not on the project it was judged in, so an identical proposal mints an
identical id by design — that key is how a later run finds the earlier judgement.
A portfolio pass reports what happened to every project. Projects that raised are printed to stderr with their error, and the command exits non-zero; the projects that completed still print their outcome, because one dead project must not discard the rest of the pass. A pass stopped because a savings goal was reached says so too.
The global token cap is reported separately from a goal stop — running out of budget and hitting your target are not the same event — but note that the cap itself has no command-line flag yet: only a library caller can install one, so that line is unreachable from the CLI today.
7 — Report what has actually been realized:
uv run python -m portfolio_optimiser.run --report --ledger savings-ledger.json
This reads a savings ledger and prints per-project and portfolio totals with each entry's provenance. It makes no model calls and changes nothing.
The ledger is an input, and the framework will not write it for you. It records savings that
were actually realized — a contract was changed, an invoice came in lower — which is a fact about
the world, not a conclusion the system is entitled to draw from its own proposals. A validated
proposal is a claim; a ledger entry is a result. Keeping them apart is deliberate, and it is why no
command here produces a ledger as a side effect. Run --report before creating one and it says so
plainly (run report refused: savings ledger not found).
You write entries when the saving materializes:
from portfolio_optimiser.ledger import LedgerEntry, SavingsLedger, to_ore
ledger = SavingsLedger()
ledger.add_realized(
LedgerEntry(
project_id="FV42-GSV-E1",
dimension="rigg",
candidate_identity="33fba649cade8529",
amount_ore=to_ore(40000),
verdict_id="33fba649cade8529",
provenance="expert Kari Nordmann, 2026-08-05, realized via contract amendment",
)
)
ledger.save("savings-ledger.json")
Amounts are held in øre as integers, and to_ore is the only conversion — money is quantized once,
per amount, before anything is summed.
--live-dry-runis a different, narrower drill: it builds contracts, clients and budget against your own configuration and stops before the first model call. It verifies the setup; it does not run the loop.--scripted-repliesruns the whole loop. The two are mutually exclusive and passing both is refused rather than one silently winning.
Non-goals
- Not a compliance product. It ships the technical prerequisites — local-only operation, provenance on every proposal, no silent data egress — and stops there. Processing purpose, DPIA and risk assessment stay with the deploying organization.
- Not a portfolio-level reallocator. It finds savings inside each project. Moving budget between projects, ranking projects against one another and portfolio governance sit above the method and are out of scope.
- Not autonomous decision-making. The deterministic validator can only block; approving a measure is a domain expert's call (human-in-the-loop), and the framework implements nothing on the agents' say-so.
- Not a turnkey vertical solution. The aim is a generic core with explicit extension points (data sources, cost models, personas) — not the last 10% of any one domain.
- Not a model benchmark. The end-to-end proof runs offline against a scripted stand-in client: it shows that the loop closes, not how well a given LLM proposes or judges.
Status: the full 8-step agentic loop is wired and proven with load-bearing tests. The end-to-end proof is primarily an offline simulation with a scripted stand-in client, but one live run against a real endpoint (
gpt-4.1-mini, 2026-08-14) has also completed: it ended in a correctrejectedoutcome — the deterministic validator caught a cost line the model had invented outright (a code absent from the knowledge base), on the tolerance gate rather than the stricter existence gate, because the bundle it ran against ships no cost baseline to anchor against. No run has yet produced a validated proposal against a live model, and every human-in-the-loop verdict currently seeded into the knowledge base is a synthetic, AI-authored seed marked as such — no genuine expert verdict has entered the tree yet. The ingest layer (real data sources) is implemented — file/CSV and SQL on both stacks with bit-identical golden extractions from the shared spec, plus HTTP as a MAF-only demonstrated extension point against a local mock — but exercised only against committed fixtures: no bundle has yet been materialized from a live source. Ingested content passes a content gate (materialize_gated) that scans every generated concept withllm-ingestion-guardbefore any of it reaches the bundle; a refused run writes nothing. A sibling implementation of the same method on the Claude Agents SDK exists in a separate repository but is currently parked, not developed in parallel.
Disclaimer — technical framework only. Deploying organizations own their processing purposes and assessments (DPIA, risk/ROS, security review). The framework ships the technical prerequisites — local-only mode, provenance, no silent data egress — but makes no compliance guarantees.
Built on an LLM wiki: Karpathy's idea, Google's format
The knowledge architecture is the heart of the project, and it is deliberately not ours:
- The idea is Andrej Karpathy's "LLM wiki": instead of pointing a model at documents written for people, you curate a small, versioned body of knowledge written for the model to read — concept files, explicit structure, explicit links.
- The format is Google Cloud's Open Knowledge Format (OKF)
(open spec, v0.1), which formalizes that pattern: a knowledge bundle is a directory of
markdown files with YAML frontmatter (one required field,
type), a reservedindex.mdentry point, and intra-bundle cross-links forming an emergent graph. Custom frontmatter fields are allowed and must be preserved — which is exactly where this project's own layers (expert verdicts, ingest provenance) live.
Because OKF is open and vendor-neutral, the same bundles are consumed unchanged by both reference implementations (MAF and the Claude Agents SDK sibling) — the knowledge outlives any particular agent stack.
Not RAG. Agents read a bundle by navigating it — index.md first, then its
cross-links, with progressive disclosure — never by keyword retrieval or stuffing the whole
bundle into a prompt. Query-time retrieval against the bundle is explicitly forbidden by the
method spec: it would leak the verdict layer around the learning gate.
A cross-link that cannot be followed is tolerated (OKF SPEC §4 — navigation never raises) but
no longer silent: each one is recorded on Bundle.skipped with the file it was written in, the
link text verbatim, and which of the two reasons applied (missing — resolves inside the bundle
with no readable file there; outside-bundle — resolves outside the bundle root). --live-dry-run
and a full run both print the list, and print nothing when every link was followed — so a bundle
that was only half read stops looking like a bundle that was simply smaller.
AI-first, humans on top
A traditional wiki is built for people — optimized for humans finding and reading information, with machine access bolted on afterwards. This project inverts that order, and is a concrete example of what that looks like:
- The wiki (the OKF bundle) is written for the model: it is the agent's working memory and the substrate the learning loop reads from and promotes into.
- The human affordances are layers on top: experts judge outcomes by dropping a plain JSON verdict file in an inbox folder; an explicit, fail-closed promotion gate is the only path by which an approved verdict becomes wiki knowledge; reports and reviews are rendered from the machine-readable layers.
Humans stay decisive — nothing enters the wiki without an approval — but the primary reader of every file is the model, not a person browsing.
How it works
One run, one project, eight steps — with the learning loop closing across runs:
- Understand — navigate the project's OKF bundle; fold the candidate's prior expert verdicts into the hypothesis prompt (ExpeL-style, retrieved structurally, never by text).
- Hypothesise — one typed candidate measure (strict IR, fail-fast schema).
- Debate — a maker-checker pair argues the reasoning (round-capped).
- Validate — two falsifiers on the same candidate: the deterministic validator gates the numbers (blocking, never optional) and the checker gates the reasoning. The validator is anchored to the project's declared cost baseline, so a proposal cannot invent the cost lines it claims to save against.
- Refine — a rejected attempt retries informed by the rejection reason, under hard
attempt and token caps. Unbounded loops are forbidden everywhere. The falsifications that
informed a later attempt are surfaced on the result (
RunResult.refinements), so what the run corrected in response to is inspectable, not just what it ended up with. - Propose or discard — a validated proposal with risk percentiles, or a typed rejection.
- Expert feedback — days later, an expert drops a verdict file in an inbox folder; a later run picks it up. Fully resumable; no live session assumed.
- Promote — an approved verdict is lifted into the wiki as a
type: verdictconcept file, navigable by the next run. The gate is fail-closed: raw agent output never self-promotes.
Every proposal carries provenance (citations into the bundle, model, validator decision, token usage). Every seam above is protected by a load-bearing test — a test designed to fail when the seam is detached, so the loop cannot silently degrade into theater.
How it is set up
-
One shared, framework-neutral core (
shared/, a git subtree ofportfolio-optimiser-commons): the business concept, the normative method spec and ingest spec, the expert-reviewer persona as an Agent Skill, and an example bundle with a golden suite as the only ground truth. Both stacks implement from the spec alone. -
Per project: one OKF bundle — the bundled examples are hand-curated; the ingest layer that materializes a bundle from a source (file catalogues/CSV + SQL, HTTP as a MAF-only demonstrated extension point) via a deterministic, schema-validated manifest that runs before the loop is implemented and exercised against committed fixtures — no bundle has yet been materialized from a live source.
-
Backends: a run binds to one of two profiles.
local(the development default) talks to an OpenAI-compatible endpoint on loopback and makes no egress.azuretalks to a Foundry project and resolves its configuration from the environment at call time:- Endpoint —
PORTFOLIO_FOUNDRY_PROJECT_ENDPOINTfirst, then theFOUNDRY_PROJECT_ENDPOINTthat Foundry injects into a hosted agent. Ours wins, so exporting it is always decisive; the injected name is what lets the same image run hosted with no extra wiring. Neither set is a fail-fast naming both. - Credential —
AzureCliCredentialon a developer host (constructing it acquires no token;az loginstays your manual step), andManagedIdentityCredentialwhenFOUNDRY_HOSTING_ENVIRONMENTis present, because a hosted container has no Azure CLI and the platform mints it a dedicated Entra identity instead.
Deployment names are never committed: the role→model map ships
REPLACE-WITH-*placeholders that fail fast, andPORTFOLIO_MODEL_MAPpoints at an out-of-tree map that wins over the bundled one. - Endpoint —
-
Tracing: off unless
PORTFOLIO_OTELholds a value, and then it says where the spans go before it emits one.consolewrites them to stderr, so a traced run's stdout is byte-identical to an untraced one;otlpexports them over the network, and only to an endpoint you named in one of the standardOTEL_EXPORTER_OTLP_*_ENDPOINTvariables. Asking forconsolewhile such a variable is set is refused rather than quietly honoured — those exporters are built unconditionally by the framework underneath, so "console" would have been a false statement about where the run's contents went. With the variable unset, no provider is configured at all: spans are still made and discarded, and nothing can leave the process. The OTLP exporter packages are not declared dependencies (they are egress, and heavy in a published wheel); install one yourself if you use that mode. -
Run: the
run.pyCLI has three modes — a documented partition, since one invocation cannot exercise every flag:- Single-project —
PROJECT_ID --docs-dir <dir>, plus optional--bundle-dir,--verdict-dir,--outbox-dir(which requires--run-id),--dimension-config,--semantic-retrieval,--decision/--rationale,--live-dry-run, and--scripted-replies <file>(the offline whole-loop door — see Walk the whole chain offline; mutually exclusive with--live-dry-run, which stops before the first model call rather than answering it), and--explore "<prompt>" --explore-config <file>(opt-in: run an exploration first and let it shape the mandate this run evaluates — see below). - Portfolio —
--portfolio, plus optional--goals,--ledger,--dimension-config,--semantic-retrieval; it stops early and prints agoal reached: …line when the accumulated ledger meets a goal. - Value report (S5.4, read-only) —
--report --ledger <file>rolls up the ledger's realized savings to stdout: per-project totals, the portfolio total, flagged cross-dimension overlaps (each counted once), and per-entry provenance. Add--jsonfor deterministic JSON instead of the human table. It makes no model calls and is mode-exclusive — only--ledger/--jsonare permitted alongside--report;--reportrequires--ledger, and a stray--jsonwithout--reportis refused (rc 1, never silently ignored).
# Single-project, offline drill (builds contracts + clients, stops before the first model call): uv run python -m portfolio_optimiser.run FV42-GSV-E1 --docs-dir <docs> --bundle-dir <bundle> --live-dry-run # Portfolio run with a savings goal checked against an accumulated ledger: uv run python -m portfolio_optimiser.run --portfolio --goals goals.json --ledger ledger.json # Read-only value report over an accumulated ledger (human table; add --json for JSON): uv run python -m portfolio_optimiser.run --report --ledger ledger.json--explore(U4) is opt-in and sits over the eight-step loop, never inside it. Given a prompt and a knowledge base, a Magentic manager decides what to read and which cost-saving directions are worth testing; what leaves that freedom is a mandate, and every number in it is still gated by the same blocking deterministic validator. The exploration writes nothing — no outbox artefact, no wiki entry, no verdict.uv run python -m portfolio_optimiser.run FV42-GSV-E1 --docs-dir <docs> --bundle-dir <bundle> \ --explore "Find the cheapest saving worth testing here" --explore-config exploration.json--explore-configstates the bounds, and every field is required —max_rounds,max_tokens,max_stall_count,max_reset_count,max_plan_revisions,enable_plan_review. None of them has a default, because an omitted cap falls back to an unbounded loop rather than a conservative one.Answering the plan review (
--plan-review). Withenable_plan_reviewset, the exploration stops before the loop is allowed to run and asks you to sign the plan off.--plan-reviewanswers it at your terminal: you are shown the plan, and you typeapproveorrevise <what to change>. A revision goes back to the manager, which replans and asks you again about the new plan;max_plan_revisionsbounds how many revisions are applied. Every round trip is recorded in{run_id}-exploration.jsonwith your words verbatim. Input that ends without an answer is an error, never a sign-off — an autonomous loop must not run on a plan nobody approved. The two flags are refused apart:enable_plan_reviewwithout--plan-reviewwould stop at a review nobody can answer, and--plan-reviewwithout it would build a reviewer nobody ever asks.uv run python -m portfolio_optimiser.run FV42-GSV-E1 --docs-dir <docs> --bundle-dir <bundle> \ --explore "Find the cheapest saving worth testing here" --explore-config exploration.json \ --plan-review --outbox-dir out --run-id r1The review is synchronous: the loop waits on you. That is why the hosted surface refuses it — blocking an HTTP request on a human would also block the event loop that answers
/readiness.Answering it days later (
--checkpoint-dir/--resume). A domain expert is rarely at the terminal when the loop reaches the plan, so the same review can be parked to disk instead.--checkpoint-dirwrites the suspended workflow there and the open question to{run_id}-plan-review.json, and the process exits. Whenever the expert gets to it — another day, in a process that never saw the run — they drop{run_id}-plan-review-answer.jsoninto a review inbox, and--resumepicks it up:# day 1 — park the review and exit uv run python -m portfolio_optimiser.run FV42-GSV-E1 --docs-dir <docs> --bundle-dir <bundle> \ --explore "Find the cheapest saving worth testing here" --explore-config exploration.json \ --checkpoint-dir checkpoints --outbox-dir out --run-id r1 # day N — a fresh process, resuming from what is on disk and nothing else uv run python -m portfolio_optimiser.run FV42-GSV-E1 --docs-dir <docs> --bundle-dir <bundle> \ --outbox-dir out --checkpoint-dir checkpoints --review-inbox inbox --resume r1A
reviseanswered this way does the same thing it does at the terminal: the manager replans and asks again about the new plan. The answer names therequest_idit answers, and a mismatch is refused rather than applied — two reviews of one run share a file name, so an answer left over from the previous round would otherwise sign off a plan the expert never saw. The vocabulary is the same closed one, anything outside it is refused rather than read as approval, andrevisewith nothing to revise is refused too.hitl.pending_plan_reviews(outbox, inbox)lists every review still waiting on somebody.The budget and the revision cap span the suspension — the resumed leg starts from what the parked one already spent, so a park never hands back a fresh budget.
--plan-reviewand--checkpoint-dirare refused together (two doors onto one review), as are--resumeand--explore(two sources of one exploration).--exploreis refused together with--mandate— they are two sources of one mandate, and merging would silently overwrite what you wrote. To seed an exploration with a domain expert's own hypotheses, useexplore(..., seed_approaches=[Approach(...)]); seeds are always preserved and always come first, including when the loop stops early. With--outbox-dir/--run-idthe run also writes{run_id}-exploration.json: the per-round ledger, the plan reviews and the in-loop advisory verdicts, written even when a cap cut the exploration short.The hosted surface takes the same door as
explore_prompt+explore_contractonPOST /invocations.Several knowledge bases (library API). An exploration may be given more than one base (
explore(..., bundle_dirs=[a, b])). Each approach it shapes records which base it belongs to (Approach.bundle_id), andrun_mandate_across_bundles(mandate, bundle_dirs, ...)then runs the pipeline once per base — the ordinaryrun_project, with that base's own sub-mandate, and with each run's project read from that base's ownvalidator-input.json.run_projectitself still takes onebundle_dir, deliberately: it derives the project, the validator's cost baseline, the agents' read context and the retrieval key from the base it is handed, so a second directory on that call would mean silently picking one of them. A hypothesis that names no base is refused when several are configured, rather than routed to a guess. The CLI's--bundle-dirstays single-valued; multi-base is a library door today.Surveying those bases is deliberately cheap.
list_bundlescosts O(bases), never O(corpus): each entry carries the base's id, a bounded verbatim opening of its index (withindex_truncatedbeside it when the opening was cut), how many documents and prior expert verdicts it holds, whether it ships a cost baseline, and how many cross-links could not be followed — never the whole index. The full index stays oneread_file(id, "index.md")away, so the bound is a disclosure level rather than data loss. Measured 2026-08-26 over a real corpus: 112 116 → 362 tokens for three bases, 124 942 → 21 448 for 171 (report).--semantic-retrieval(S3.1) is an opt-in ranking change, off by default. Off, prior verdicts are ranked exactly as before: a structural score over the affected cost-code set, measure type and magnitude bucket, with surface text deliberately excluded. On, that score is blended with a cosine term over the same structural triple, which lets a prior verdict on a different cost-code set outrank one that ties structurally.What this ships is the seam, not better retrieval. The bundled
FakeEmbedderis a deterministic sha256 projection carrying no semantics, so over a structural tie the resulting order is deterministic but arbitrary. Retrieval quality depends entirely on injecting a real embedder —--embedder-configselects one from a closed registry (never an import path; a config file can never name arbitrary code to load), anddocs/extending.mddocuments theEmbedderprotocol. The embedding excludesdescription, matching the structural score and the verdict-id hash, so a flag-on run reads no surface text either.The flag is accepted in both run modes, but in single-project mode it requires
--bundle-dirand--verdict-dir: without them it cannot take effect, and the run is refused rather than silently ignoring the flag. Nothing about a flag-off run changes, and no savings claim depends on it.A bundle may hold verdicts about several candidates, while its
validator-input.jsondescribes only one. Atype: verdictfile therefore may declare its own retrieval key in frontmatter —affected_codes,measure_type,claimed_saving_nok— and is keyed on that; omit them and it falls back to the bundle's candidate, exactly as before. The three are all or nothing: a partial declaration is refused rather than merged with the bundle candidate, since the merge would produce a key belonging to neither.promote_verdictwrites all three, so a promoted verdict about one candidate never surfaces for another.A bundle may also ship a
cost-baseline.json— the project's actual cost lines,{code: {quantity, unit_cost}}— and when it does, the deterministic validator reconciles every affected item of a proposal against it before anything else runs. A cost code the project does not have is rejected, and so is a real code carrying a quantity or unit cost outside the configured tolerance (5% by default, relative to the baseline value). Without it, every stage of the gate reasons only about numbers the proposal supplied itself, so an internally consistent hallucination passes. The reconciliation validates; it never repairs a proposal into the baseline. A bundle that ships no baseline is simply un-anchored and runs exactly as before, while a baseline that is present but malformed is an error rather than a silent fall-back to un-anchored. On the reference-domain (non-bundle) path the project's own cost items are the baseline, so those runs are always anchored.An un-anchored run says so. Every run stamps
provenance.cost_baseline_anchored(a required boolean, so no stamp can omit it), which reaches the outbox in{run_id}-proposal.json; and when a run is un-anchored the CLI prints one line naming the skipped stage — on--live-dry-run, on a full single run, and per project in portfolio mode. An anchored run prints no such line at all: a line for something the run does not have is omitted rather than rendered empty. Anchoring stays optional; this is visibility, not a new refusal.The prior-verdict fold — the learning step — happens only on the
--bundle-dirpath; a plain--docs-dir-only run is single-shot (no fold).--decision/--rationaleapply to the single-project path only and are refused in portfolio and report mode rather than ignored — a portfolio pass takes each project's verdict from its own row, so a run-level verdict flag has nowhere to go, and silently dropping a judgement an expert actually typed is the failure this partition exists to prevent.--outbox-dirmust differ from--verdict-dir: writing the raw outbox into a folder later read as an inbox would re-ingest raw agent output past the promotion gate (self-contamination) — documented here, deliberately not CLI-enforced. Stop criteria and budget caps are required at startup. Try the offline end-to-end proof (no model, no network):uv run python -m portfolio_optimiser.simulation. - Single-project —
-
A global token cap across the whole portfolio, enforced before the call. Per-run caps alone let N projects cost N times that with no ceiling over the pass. Pass a
PortfolioMeter(PortfolioBudget(max_total_tokens=…, max_tokens_per_run=…))torun_portfolioand one ledger bounds the entire pass — and, seeded frombudget.read_spend, a series of passes. It bites in three places: a remainder that cannot fund one run refuses the pass at startup (BudgetRefused); a project that cannot be funded is never started, stopping the pass structurally (budget_stop, completed runs preserved); and a chat call the remainder cannot pay for is refused rather than made (the post-charge check remains, since real usage is only knowable after the response). Spend persists viabudget.write_spend, which takes an explicit stamp and no wall-clock default, so the file is byte-deterministic. Python API only — not yet exposed on the CLI.
What this enables
The reference case is portfolio cost review (the example bundle is a building-energy measure), but the architecture is designed to generalize to any setting with the same shape — candidate measures inside independent projects, numbers a deterministic tool can check, and judgement only an expert has:
- Portfolio reviews — cost savings, energy efficiency, maintenance and procurement measures, proposed per project and validated against the project's own data.
- Compounding organizational memory — approved expert verdicts become navigable knowledge; the next run's hypotheses start from what experts actually decided, including realization gaps no solver can compute.
- Auditable AI — an unbroken provenance chain from expert decision back through proposal, bundle file and text span, and (with ingest) to the source system, query, and timestamp.
- Vendor-neutral knowledge — the same bundles drive two different agent stacks; switching frameworks does not orphan the organization's curated knowledge.
The task API — runnable Python, no wrapper
main.py is the ONE process entry. It serves the hosted-agent runtime contract (port 8088 /
PORT, GET /readiness, POST /invocations, clean SIGTERM exit) from
portfolio_optimiser.hosting, wrapping run_project — never Workflow.as_agent(), which
would bypass the deterministic validator and the rest of the gate chain. No protocol library
is used: the prerelease agent-framework-foundry-hosting requires
agent-framework-core>=1.13.0 while this tree locks 1.9.0, so the contract is a small
stdlib-asyncio server instead (single event loop, no threads — the same concurrency model as
the portfolio waves).
uv sync --frozen --no-dev # the exact locked resolution every measurement ran against
uv run python main.py # serves the task API
git must be on PATH for the install: two dependencies are git-tag-pinned direct references,
and wheel metadata alone cannot fetch them.
A Dockerfile and an azure.yaml shipped here until 14 August 2026 and were removed on an
operator directive after an external trial: what is delivered is runnable Python, and how the
process is containerised, supervised or deployed belongs to whoever runs it. Git history keeps
both files. The raw-text gate that pinned them (--platform linux/amd64, one copy of the start
command) was deleted with them rather than weakened into a check that could only pass — the
start command now has exactly one copy, in DEPLOY.md, and
tests/test_handover_package_loadbearing.py is what keeps it there.
An invocation is a JSON object whitelisted onto run_project's signature — project_id and
docs_dir required; verdict_input, bundle_dir, profile, max_rounds, max_tokens
and top_k optional. verdict_input was required until 1.1.0, which forced an external caller to
invent an expert verdict just to get a run at all; omitting it now means nobody reviewed the run,
and the response's verdict_id is the key one would arrive under. Unknown fields are refused by name (400), never silently dropped.
profile defaults to azure on this surface: the AZURE profile reads its endpoint and
credential from the environment at call time, so the same process runs hosted (managed identity)
and locally (az login) without rewiring.
Handing it to someone else
scripts/make-handover-package.sh builds one archive a receiving organisation can deploy without
cloning this repository or having an account here:
scripts/make-handover-package.sh # → dist/portfolio-optimiser-foundry-<version>.zip
The archive is git archive HEAD — tracked files only, which is why local-only files cannot enter
it and why nothing curates what a receiver sees. DEPLOY.md rides
along inside it and answers the receiver's first questions: what the three roles do, what the
process is end to end, why there is no chat interface, and the two environment variables that decide
whether the first deployment works. Gated by tests/test_handover_package_loadbearing.py.
Docs
- Bestille en kjøring (norsk) — for the domain expert who COMMISSIONS a run: naming the approaches the run must evaluate (and/or asking the system for its own), stating what the run is for, and reading the announcement it prints before spending anything and the settlement it prints afterwards. The commission directs what is evaluated, never what is approved.
- Ekspert-svar (norsk) — for the domain expert who has to deliver the verdict: where a judgement goes, what an approval, an approval-with-correction and a rejection actually look like, and paste-ready examples of each. Marked throughout as AI-authored and not verified professional judgement.
- Building a knowledge base — the team recipe (technical + domain expert) for curating a bundle, with the honest expectation that a good base takes 1–2 weeks of dedicated work.
- Kunnskapsbase for én kjøring (norsk) — how to compose the base for ONE specific run: which categories of knowledge follow the project, the domain and the organisation; a content-type table (owner, delivery form, role in the loop, what happens when it is missing); and a worked road project from the commission to a base that passes the dry-run check. Every technical claim is marked verified or assumed.
- Target picture — the agentic loop + OKF knowledge architecture (north star).
- Prior-art & platform research (incl. implementation register §15).
- Ingest target picture — connectors and the ingest layer (frozen 2026-07-03).
Stack & develop
Python ≥3.10 · MAF via the split GA packages (see pyproject.toml) · uv. Backend profiles:
Azure/Foundry (full) + local (fallback).
uv sync
uv run pytest
uv run ruff check .