Prosjektets første levende kjøring døde på `rounds limit=12 observed=13`, og den
hostede flaten svarte `500 {error_type, error}` — nøyaktig det samme den sier når
modell-endepunktet faller. Nå: 429 med trippelen som STRUKTUR.
Beslutningen er S3.4-invarianten anvendt på transporten: `budget_stop` ble holdt
utenfor `stop_reason` fordi de to stoppene betyr motsatte ting, og å svare
ressurs-utmattelse på krasj-kanalen gjør «det gikk ikke» uleselig på samme måte.
IKKE 200, og det er dét som skiller den fra `Rejection`: en `Rejection` er en
kjøring som KONKLUDERTE og hører i payloaden, mens et uttømt budsjett produserte
ingen proposal — en 2xx ville latt en automatisk kaller bokføre «analysert» for en
kjøring som analyserte ingenting. 429 fordi betingelsen oppstår av en TILDELING
(`max_rounds`/`max_tokens` er whitelistede request-felt), aldri av en serverfeil.
`kind`/`limit`/`observed` legges ut som felt, aldri `str(exc)` (kø-(y));
`error_type` holdes ute — den nøkkelen tilhører feilkanalen. `budget_exhausted` er
ikke foldet inn i `outcome_type` og kunne ikke vært det: `outbox.outcome_payload`
er den ene kopien av den forgreningen og tar `ValidatedProposal | Rejection`.
Ærlighets-grense: ingen `Retry-After` — å vente endrer ingenting.
Iron Law: begge nye tester RØDE før armen fantes. Fem mutasjoner mot HELE suiten,
alle røde med hver sin signatur, grønn kontroll 867/4: detach armen (2 røde) ·
flat streng i stedet for struktur (1 rød — struktur-testen alene) · ekko `limit`
som `observed` (1 rød) · utvid armen til `Exception` (6 røde) · stemple
`error_type` på budsjett-kroppen (1 rød).
500-armens vitne ble BYTTET, ikke slettet: den eksisterende testen brukte
`BudgetExceeded` som sin 500-prøve, så en ny arm alene ville etterlatt
krasj-kanalen uten vitne. Den bærer nå en ekte ikke-budsjett-`RuntimeError`, og er
dét som holder den nye armen smal.
Kjørt, ikke bare testet: `python main.py` startet, `/readiness` 200, ukjent felt →
400 med navnet, 404, SIGTERM → exit 0.
865 → 867 passed / 4 skipped; ruff + format + mypy rene. DEPLOY.md §6 dokumenterer
429 for mottakeren.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1bsX79aDS7fJ5udWGWAEN
10 KiB
Deploying and using this package
This archive is a complete, self-contained copy of portfolio-optimiser. You do not need to clone
anything or have an account anywhere to deploy it. Unzip it and follow this document.
It is written in English because its readers are implementing developers and platform operators.
The two documents that describe the human side of the loop are written in Norwegian and ship in
this same archive: docs/bestille-en-kjoring.md (ordering a run) and
docs/ekspert-svar.md (giving the verdict).
1. What this is — and what it is not
It finds cost savings inside a single project: agents propose candidate measures, a deterministic validator decides whether the numbers hold, a second agent (the "checker") gates the reasoning, and a domain expert gives the final verdict. The system learns from those verdicts.
There is no chat interface, and that is a design decision rather than a missing feature.
The hosted surface is a task API — POST /invocations with a JSON body, GET /readiness — not a
conversation. The system is deliberately not exposed as a Microsoft Foundry agent via
as_agent(), because the validator, the cost-baseline anchoring, the checker gate and the savings
ledger all live outside the agent graph. Wrapping it as a chat agent would route requests around
precisely the components that make an answer trustworthy, and you would get a fluent assistant
instead of a validated result.
The dialogue with humans happens in two places instead, both file-shaped and both auditable:
- before a run, through a mandate file (what should be considered), and
- after a run, through a verdict file (what a qualified person decided).
If you want an interactive front end, build it on top of POST /invocations — but keep the verdict
step human. An approval loop that a model can close by itself is not an approval loop.
2. Who does what
The package has three distinct audiences. Only the first one is technical.
| Role | What they do | Where it is documented |
|---|---|---|
| Platform operator | Installs the package, sets two environment variables, starts the service, verifies with the offline preflight. One-time work. | This document, sections 3–6. |
| Ordering domain expert (bestiller) | Writes the mandate file: the objective, which approaches are worth trying, and why. The why is the part the system cannot read out of cost figures. | docs/bestille-en-kjoring.md |
| Reviewing domain expert (fagperson) | Receives a validated proposal and answers approved or rejected with a written rationale. That verdict is what the system learns from. |
docs/ekspert-svar.md |
The ordering expert steers what is considered, never what is approved. If you ask for something the numbers do not support, the deterministic validator rejects it and the rejection comes back with its reason. That property is the point of the whole design; do not build around it.
3. The process, end to end
- Prepare a knowledge base for the project — cost documents, technical sources, prior
experience — as a document bundle. A working five-document example ships in
shared/examples/bygg-energi-mikro, anddocs/knowledge-base-recipe.mddescribes how to build your own. - Order a run with a mandate file (optional but recommended). See
docs/bestille-en-kjoring.md. - Run it — from the CLI, or by
POST /invocationsagainst the running service (section 6). The run navigates the knowledge base, debates candidate measures, validates the numbers against the project's actual cost baseline, and returns either a validated proposal or a rejection with a reason. A rejection is a successful run: the negative outcome belongs to the answer, not to the transport, and the API returns HTTP 200 for it. - A domain expert judges the outcome and writes a verdict — either inline during the run, or as
a JSON file dropped into an inbox directory afterwards, possibly days later. See
docs/ekspert-svar.md. - Approved verdicts can be promoted into the knowledge base, so the next run reads them as context and does not re-propose something the organisation has already rejected. Promotion is gated: only an approved verdict can be promoted, and raw agent output never enters the knowledge base on its own.
Steps 4 and 5 are what make this a learning loop rather than a report generator.
4. Prerequisites in your own tenant
You need, in Microsoft Foundry:
- A Foundry resource with project management enabled and a project inside it. A Foundry
project is required — an Azure OpenAI resource (
kind: OpenAI) is a different client surface and will not work. - At least one model deployment (any current chat model; a small one is enough).
- The Foundry User role (role definition id
53ca6127-db72-4b80-b1b0-d745d6d5456d) for the identity that will run it, assigned at project scope. AzureOwnerandContributorgrant management permissions only, not the data-plane permissions this needs.
5. Configuration — the two variables that matter
5.1 The project endpoint. Resolved as the first non-empty of:
PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT # yours; wins if set
FOUNDRY_PROJECT_ENDPOINT # injected by the Foundry hosting platform
Where something injects the second one for you — the Foundry hosting platform does — you need to
set nothing. Everywhere else (a laptop, a pipeline, your own runtime) set the first. The value looks
like https://<resource>.services.ai.azure.com/api/projects/<project>.
5.2 The model map — this is the one that catches people. The packaged
src/portfolio_optimiser/data/model_map.json ships placeholders:
"azure": { "default": "REPLACE-WITH-FOUNDRY-DEPLOYMENT", ... }
Deployment names are tenant-specific, so they are deliberately never committed. The code
fail-fasts on a REPLACE-WITH- value rather than guessing. Point PORTFOLIO_MODEL_MAP at your
own file:
{
"local": { "default": "qwen3:4b", "proposer": "qwen3:4b", "checker": "qwen3:4b" },
"azure": { "default": "my-deployment", "proposer": "my-deployment", "checker": "my-deployment" }
}
export PORTFOLIO_MODEL_MAP=/path/to/model_map.json
If you skip this, the service starts and answers /readiness — and fails every invocation. That
is the single most likely first-deployment failure, which is why it has its own section.
5.3 Verify before you pay for a model call:
uv run python -m portfolio_optimiser.preflight --profile azure
Exit code 0 means every offline-checkable precondition holds: endpoint shape, model-map structure, no placeholders left. It is necessary but not sufficient — RBAC (403), token or tenant problems (401) and a well-formed but non-existent deployment (404) can only surface at the live call.
6. Running it
This package is Python and nothing else. There is no image to build and no platform-specific deployment manifest — that was removed deliberately after an external trial, because it forced a container toolchain on receivers who only needed to run the code. How the process is supervised, packaged or placed in your environment is your decision, and this package does not pre-empt it.
Install the locked environment and start the service:
uv sync --frozen --no-dev
uv run python main.py
uv sync --frozen installs the exact resolution every measurement in this repository ran against —
never a fresh resolve, which picks other versions. Two dependencies are git-tag-pinned direct
references, so git must be on PATH for the install to work.
main.py serves port 8088 (override with PORT), answers GET /readiness, accepts
POST /invocations, and exits cleanly on SIGTERM. For a single run without the service, the CLI is
the same code path:
uv run portfolio-optimiser --help
The invocations payload is whitelisted against the run signature — unknown fields are refused by name with HTTP 400, never silently ignored:
| Field | Required | Notes |
|---|---|---|
project_id |
yes | The project to analyse. |
docs_dir |
yes | Source documents. |
verdict_input |
yes | The expert verdict for this run. |
bundle_dir |
no | Knowledge base to navigate. |
profile |
no | Defaults to azure on this HTTP surface. |
max_rounds |
no | Debate round cap. |
max_tokens |
no | Per-invocation token ceiling. Set it. The built-in default is 100 000 tokens per run, which is a ceiling nobody chose for your workload. |
top_k |
no | Retrieval breadth. |
HTTP 400 means the request was malformed; 500 means the run itself failed, with error_type and
error in the body. A rejected proposal is 200 — see section 3.
429 is not a failure. It means the run reached its own allowance — the round cap or the token cap — and stopped, which is the cap doing its job rather than anything breaking. The body carries the ledger's own numbers, so you can tell which cap bound and how far past it the run got:
{"budget_exhausted": {"kind": "rounds", "limit": 12, "observed": 13},
"error": "budget exceeded: rounds limit=12 observed=13"}
There is no error_type (nothing failed) and no Retry-After (waiting changes nothing). Retrying
the same body hits the same cap; the remedies are raising max_rounds/max_tokens for that
invocation, or accepting that this project did not resolve within the allowance you set.
7. Honest limits
- This framework has never been run against a live model in our environment. Everything above is measured against tests and an offline simulation. The first live run in your tenant is the first live run, full stop.
- The system is a technical framework. Data protection assessments, lawful basis and risk
ownership belong to whoever deploys it — see
README.mdandSECURITY.md. - Readiness does not respond during CPU-bound stretches of a run (the optimisation solve).
- Chunked request bodies are not supported.
- Verdict promotion writes to the knowledge base non-atomically; it assumes a single writing process.