The hole: `read_http`'s default transport is the library's `urllib_get`, which invokes the
stdlib opener with no `timeout=`. urllib's documented fallback is then the process-wide default
socket timeout — `None` out of the box — so an http source that accepts a connection and never
answers hangs a run indefinitely. That contradicts the invariant that nothing runs unbounded.
The spec text for S2.4 ("a timeout parameter on `_urllib_get`") could NOT be followed literally:
that function is UPSTREAM library code (`llm_ingestion_okf.connectors`, pinned v0.3.1, pull-only),
signature `(url, credential) -> str` — measured, not assumed. Same failure class as S2.2's
"implement it in `ingest.py`": spec text that says "change X" has to be checked against whether
X is ours at all.
So the fix goes in FRONT of the library: `timeout_get` scopes `socket.setdefaulttimeout` around
a delegate call to the library's own `urllib_get`, and `materialize` now hands the library that
wrapped transport instead of letting it resolve its own untimed default. This meets S2.4's own
verification criterion — a bound WITHOUT a second socket path — and avoids duplicating the
credential-header logic. An explicitly injected `http_get` is passed through UNWRAPPED: a
caller-owned transport (MCP fronts a subprocess with its own `timeout_seconds`) keeps its own
policy, and a process-global side effect is not ours to impose on it.
Honest limit, carried in the code comment, the test docstring and `docs/extending.md`, not just
in the commit: the default socket timeout is PROCESS-global. Under `concurrency=k` the runner is
asyncio on one thread, so the scoping holds; driving `read_http` from a thread-pool executor
would make it unsafe.
Half of S2.4's scope was already delivered upstream — transport failures are categorised as
`SourceError(code="http_transport")`. Coarser than the plan envisaged, but not ours to rewrite.
Two pre-existing guards went red on the first pass, both on PROSE only: `ingest.py` must not
contain "urlopen" (no forked connector) or "ingest_mcp" (AST-guarded mcp-free). No code violated
either — my docstrings merely named them. The guards were left exactly as strict as they were and
the prose was reworded; weakening a real guard to save a comment is the trade this repo refuses.
578 -> 583 tests. Five mutations MEASURED red (restored from scratchpad + `shasum -c` each time,
never `git checkout`):
1. remove the timeout scoping entirely -> RED
2. apply the bound AFTER the delegate call -> RED
3. set the bound but never restore it (no finally)-> RED (the unconditional control)
4. hand the library a bare None again (pre-S2.4) -> RED (the wiring)
5. make the wrapping unconditional -> RED (the conditional control)
Mutations 1 and 2 take ~10s to fail rather than failing instantly: that is the loopback test's
join deadline expiring. It is the measurement that the bound actually BITES — a black-hole
listener on 127.0.0.1 that completes the handshake and never answers, run on a daemon thread so
a detached seam fails an assertion instead of hanging the suite forever. Every other assertion
here only proves we set a global; that one proves the global does something.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdLGwd33vqhkToh98Ym34P
251 lines
17 KiB
Markdown
251 lines
17 KiB
Markdown
# Extending the framework (extension points)
|
|
|
|
portfolio-optimiser is a generic core with explicit **config seams** (D4/D5, 90 %-prinsippet):
|
|
you onboard a new project, a new data source, or a new model-map **without editing the core
|
|
`src/portfolio_optimiser/*.py`**. The three guides below name the exact seam for each.
|
|
|
|
> **Honesty note (rent teknisk rammeverk).** The bundled reference domain
|
|
> (`data/reference_projects.json` + `data/docs/<id>/`) is a set of **SYNTHETIC, AI-authored
|
|
> fixtures** — fictional construction-cost projects, dummy estimates, and placeholder
|
|
> `verdict_input` decisions. They are flagged in each file's `_note`. A production deployer
|
|
> **replaces the data source** with their own and supplies **Layer-2 verdicts via real HITL**
|
|
> (fageksperter), not static config. The static `verdict_input` field is a test-fixture
|
|
> convenience that stands in for the durable HITL verdict in the offline synthetic framework.
|
|
|
|
## Legg til eget prosjekt
|
|
|
|
A new project is **config + docs only** — no code change (this is exercised by the SC1 test
|
|
`test_e_new_project_flows_through_via_config_only` + the `test_f_no_hardcoded_project_ids_in_src`
|
|
guard, which fails if any project id leaks into `src/`).
|
|
|
|
1. Append an entry to `src/portfolio_optimiser/data/reference_projects.json` with the full key
|
|
set: `id`, `name`, `description`, `currency`, `cost_items`, **`docs_dir`**, and
|
|
**`verdict_input`** (`{"decision", "rationale"}`).
|
|
- `docs_dir` is a path **relative to the package `data/` root** (e.g. `"docs/MY-PROJECT"`);
|
|
the loader (`reference_domain.load_reference_projects`) resolves it to an absolute path.
|
|
- `verdict_input` carries the (synthetic) Layer-2 decision/rationale; flag it in the file's
|
|
`_note` as synthetic if it is not a real expert verdict.
|
|
2. Create the bundled docs folder `src/portfolio_optimiser/data/docs/<id>/` with at least one
|
|
text file whose content names the cost-saving measure/terms (so `retrieve_chunks` returns at
|
|
least one citable chunk).
|
|
3. Run the project: `run_portfolio(["MY-PROJECT"], "local", client_factory=...)` (or include it
|
|
in the default fan-out by passing no `project_ids`).
|
|
|
|
## Legg til egen datakilde
|
|
|
|
The retriever (`retrieval.py` / `datasource.py`) reads a **local docs folder** per project,
|
|
selected by the project's `docs_dir` in `reference_projects.json`. To point a project at your own
|
|
data, change its `docs_dir` to your folder and drop your cost documentation there — the citation
|
|
seam (`{file, locator, snippet, score}`) is identical on the in-process tool and MCP paths. The
|
|
folder is boundary-checked (fail-closed) against path traversal, so keep documents inside the
|
|
configured `docs_dir`. A real deployer swaps the bundled synthetic docs for their own source.
|
|
|
|
## Legg til egen modell-map
|
|
|
|
Model choice is **config, not code** (B12): `src/portfolio_optimiser/data/model_map.json` maps
|
|
`profile -> role -> model/deployment` (`resolve_model(profile, role)`). To use your own models:
|
|
|
|
- Edit the `local` block to your local model ids (Ollama/LM Studio), and/or
|
|
- Edit the `azure` block to your Foundry deployment names (the placeholders
|
|
`REPLACE-WITH-FOUNDRY-DEPLOYMENT` are tenant-specific — replace them or supply via env).
|
|
|
|
The role keys (`proposer`, `checker`, `default`) let you assign a distinct model per debate role;
|
|
`default` is the fallback when a role is unmapped.
|
|
|
|
## Legg til en ingest-kilde (http-familien som worked example)
|
|
|
|
The **ingest layer** (`ingest.py`, spec `shared/ingest-spec.md`) is a second, distinct source
|
|
seam from the retriever above: one JSON **manifest** per source coupling declares a `source.type`
|
|
and a list of extractions; `materialize(...)` runs the connector for that type and writes an OKF
|
|
bundle.
|
|
|
|
> **Where to change it (2026-07-20):** `ingest.py` is a thin adapter — Door A is implemented by
|
|
> the shared [`llm-ingestion-okf`](https://git.fromaitochitta.com/open/llm-ingestion-okf) library
|
|
> (git-pinned to `v0.3.1`), so connectors, materialization and index generation improve in ONE
|
|
> place across every consumer. `shared/ingest-spec.md` remains the normative spec — the library
|
|
> implements it, it does not replace it, and spec changes go via commons. The API below is
|
|
> unchanged; only the implementation moved. Note that Door A is **ungated**: it calls no
|
|
> security guard before writing to disk, so gating untrusted content is the caller's
|
|
> responsibility (see `docs/plan/2026-07-16-llm-ingestion-guard-inclusion.md`). The spec ships three source families — `file`/CSV (I2), `sql` (I4), and **`http`** (I6) —
|
|
and `http` is the framework's **worked extension-point example**: it shows exactly how a third,
|
|
network-transport family plugs into the same connector / materialization / gate contracts.
|
|
|
|
The `http` manifest contract (spec §4):
|
|
|
|
- `source.base_url` — the endpoint root; it **must not embed credentials** (userinfo is rejected
|
|
at schema validation).
|
|
- `source.credential_ref` — the **name of an environment variable** holding the secret, resolved
|
|
at run time (sent as `Authorization: Bearer <secret>`); the secret is never read from the
|
|
manifest, never logged, never stamped in the generated frontmatter. `null` → no auth.
|
|
- each extraction's `query` is joined onto `base_url` and its response body is rendered
|
|
**verbatim inside a fenced code block** (not a markdown table — a raw `|` or `\` survives
|
|
un-escaped); `max_rows` caps the response line count, fail-fast (never silent truncation).
|
|
|
|
**Network is a per-run grant, never a manifest field (spec §8).** `materialize` refuses an `http`
|
|
source unless the caller passes `allow_network=True`:
|
|
|
|
```python
|
|
# refused fail-fast — the manifest cannot grant itself network access:
|
|
materialize(manifest, bundle_dir, ingested_at="2026-07-04T12:00:00Z")
|
|
# opted in explicitly by the operator for this run:
|
|
materialize(manifest, bundle_dir, ingested_at="2026-07-04T12:00:00Z", allow_network=True)
|
|
```
|
|
|
|
This is the **local-only default, no silent egress** principle made mechanical: configuration is
|
|
data that cannot escalate its own authority; only the runtime `allow_network` grant can. The
|
|
transport itself is an **injectable seam** — `materialize(..., http_get=<callable>)` swaps the GET
|
|
implementation (the default `_urllib_get` is the only socket path). The golden case
|
|
(`examples/ingest-golden-http/`) and every test inject a canned `get` over committed fixture
|
|
payloads, so the suite runs offline against a **local mock** — no live source, no credentials.
|
|
|
|
**The default transport is time-bounded (S2.4).** The library's `_urllib_get` calls the stdlib
|
|
opener without a timeout, which falls back to the process-wide default socket timeout — `None`
|
|
out of the box — so a source that accepts a connection and then never answers would hang a run,
|
|
contradicting the invariant that nothing runs unbounded. `materialize` therefore hands the library
|
|
that same socket path wrapped in `ingest.timeout_get`, which scopes `socket.setdefaulttimeout`
|
|
(`HTTP_TIMEOUT_SECONDS`, 30s) around the delegate call: the bound applies **without** a second
|
|
socket path and **without** duplicating the credential header, so the pinned library stays
|
|
untouched. An explicitly injected `http_get` is passed through **unwrapped** — a caller-supplied
|
|
transport owns its own timeout policy.
|
|
|
|
**Honest limit:** the default socket timeout is *process-global*. Under `concurrency=k` the runner
|
|
is asyncio on a single thread, so the scoping holds. Driving `read_http` from a thread-pool
|
|
executor would make it unsafe, and the bound would have to move to a per-call timeout argument —
|
|
i.e. to owning a socket path locally.
|
|
|
|
### D7 sibling hook — MCP as an extension of this family
|
|
|
|
The spec (§4) documents an **MCP-based connector as an extension of the `http` family**, not a new
|
|
client wired into the optimiser run path — that stays a **Non-Goal** here: the in-process
|
|
`FunctionTool` seam is the default in the run path, and MCP is demonstrated (via `build_mcp_server`
|
|
in `datasource.py`), not wired in.
|
|
|
|
**The connector (S2.2, 2026-08-02).** `ingest_mcp.py` implements it. There is **no fourth source
|
|
family and no schema change**: an MCP source is an ordinary `type: "http"` source whose transport is
|
|
declared by the `base_url` scheme, and whose two parts fall straight out of the join the library
|
|
already performs (`base_url` + `/` + `query`):
|
|
|
|
```json
|
|
{ "type": "http", "id": "docs", "base_url": "mcp+stdio://PORTFOLIO_DOCS_MCP" }
|
|
```
|
|
|
|
```python
|
|
from portfolio_optimiser.ingest import materialize
|
|
from portfolio_optimiser.ingest_mcp import mcp_get, stdio_call_tool
|
|
|
|
materialize(manifest, bundle_dir, ingested_at="2026-08-02T00:00:00Z",
|
|
allow_network=True, http_get=mcp_get(stdio_call_tool()))
|
|
```
|
|
|
|
`server_ref` is the **name of an environment variable** holding the server command — mirroring the
|
|
`sql` family's `connection_ref`, so the manifest carries a reference and never an executable path —
|
|
and the extraction's `query` is the tool name. Parsing is string-based, not `urlsplit`-based:
|
|
`urlsplit().hostname` lowercases the host, which would silently break a case-sensitive env lookup.
|
|
|
|
Two properties are worth stating because they are **inherited, not written**: the §8 network grant
|
|
covers MCP for free (an MCP source *is* `http`, so it is refused before any tool call unless
|
|
`allow_network=True`), as do the `max_rows` cap, verbatim fenced rendering, and the §7 provenance
|
|
stamp. Had MCP become a fourth family, each of those would have been ours to write — and ours to
|
|
forget. The transport discriminator **gates rather than labels**: `mcp_get` refuses a URL it does
|
|
not own, so an MCP transport can never quietly serve a plain `https://` manifest and leave the
|
|
bundle's provenance claiming a transport that was never used.
|
|
|
|
**Not verified against a live MCP server.** Every test injects a canned tool call, so the suite
|
|
spawns no subprocess and opens no socket (cost discipline). `stdio_call_tool` — the real stdio path
|
|
— is therefore **written but never executed end to end**; a deployer using it should expect to shake
|
|
it out. The seam it plugs into (`mcp_get`, the discriminator, the gate) *is* measured, including six
|
|
detach mutations. There is no `examples/ingest-golden-mcp/` fixture, and MCP remains **unwired in
|
|
the optimiser run path** — the in-process `FunctionTool` seam stays the default there.
|
|
|
|
**Where the D7 sibling stands (målbilde §11 boundary).** The Claude Agent SDK sibling built the
|
|
**file/CSV and SQL** connectors — mirroring I3/I5 — with bit-identical golden extractions. **HTTP
|
|
and MCP are implemented on the MAF side only**; the sibling ships no network connector and no
|
|
live-source integration. `ingest_mcp.py` is deliberately MAF-free (it imports the open `mcp`
|
|
protocol client, never `agent_framework`), so the seam is portable to D7 unchanged. On D7 the
|
|
in-process server hook is `create_sdk_mcp_server(name, version="1.0.0", tools=...) ->
|
|
McpSdkServerConfig` (package `claude-agent-sdk`) — verified 2026-07-04 against the official Claude
|
|
Agent SDK Python docs — but that is a **documented hook a deployer would reach for, not a shipped D7
|
|
connector**; no D7 MCP session is planned. Nothing here contacts a live endpoint.
|
|
|
|
## Bytt ut henteren (Embedder / Retriever, S3.1)
|
|
|
|
How prior verdicts are ranked is a seam, not a hard-coded sort. `semretrieval.py` declares two
|
|
protocols and the store delegates to them:
|
|
|
|
- **`Retriever`** — `rank(query, candidates, k) -> list[Verdict]`. `VerdictStore.retriever`
|
|
defaults to `None`, which means `StructuralRetriever`: the same weighted structural score and
|
|
`(-similarity, id)` ordering the store used before the seam existed. Assign your own object
|
|
with a `rank` method to replace ranking wholesale.
|
|
- **`Embedder`** — `__call__(features) -> np.ndarray`. `HybridRanker(embedder, similarity, weight)`
|
|
blends `weight * cosine + (1 - weight) * structural`; both terms live in `[0, 1]`, so `weight`
|
|
means what it reads as (`SEMANTIC_WEIGHT_DEFAULT = 0.5`).
|
|
|
|
```python
|
|
store.retriever = HybridRanker(MyEmbedder(), similarity, weight=0.3)
|
|
```
|
|
|
|
Note that `similarity` is **injected**, not imported by `semretrieval`. That is deliberate:
|
|
`verdicts.py` imports `agent_framework`, and injecting the score keeps the retrieval layer free
|
|
of it (guarded by `tests/test_semretrieval_loadbearing.py`, which ranks in a subprocess and then
|
|
asserts `verdicts` never entered `sys.modules`). Keep that property if you extend the module.
|
|
|
|
**A real embeddings client is a code-level extension point — it is not built here.** Selection goes
|
|
through a CLOSED registry: `--embedder-config` names a type, `build_embedder` dispatches on it, and
|
|
an unknown type is refused rather than resolved. A dotted `module:Class` import path is
|
|
deliberately *not* supported — that would be arbitrary code execution at config-load time and would
|
|
hand a config file the ability to import something that opens a socket, routing around the
|
|
no-network guard (which is scoped to `semretrieval.py` and blind to a third module by
|
|
construction). Adding a real embedder therefore means adding a registry branch in code, plus any
|
|
capability opt-in as a factory kwarg — the same discipline `NotifierConfig` applies to egress. The
|
|
shipped
|
|
`FakeEmbedder` is a deterministic sha256 projection with no semantics; it exists so the seam is
|
|
exercisable offline at zero cost, and the load-bearing proof is that removing the cosine term
|
|
flips the ranking, not that the projection is meaningful. A deployer supplying a real client owns
|
|
its network egress, cost, and the fact that it embeds proposal **text** — which is why the hybrid
|
|
is opt-in (`--semantic-retrieval`) rather than the default. If you wire one in, note that
|
|
`semretrieval` currently imports no network module at all, and a guard test asserts exactly that;
|
|
a real client belongs behind the `Embedder` protocol in *your* module, not inside this one.
|
|
|
|
**Optional persistence — an unwired authoring primitive.** `save_vector_store(dir, verdicts,
|
|
embedder)` / `load_vector_store(dir)` write and read a byte-deterministic `vectors.npy` +
|
|
`vectors.jsonl` pair (sorted by verdict id, atomic replace). A missing store loads as `None`; a
|
|
row/line mismatch raises rather than silently mis-ranking. `*.npy` is gitignored.
|
|
|
|
They have **no caller in `src/`**, and ranking never reads a persisted matrix: `HybridRanker.rank`
|
|
re-invokes the embedder for every candidate on every call, so a persisted store would be bypassed
|
|
even if one existed. They are offered to extenders, on the same footing as `write_verdict`,
|
|
`promote_verdict` and `build_mcp_server` — public primitives the core deliberately does not wire
|
|
into `run_project`. Wiring the cache in only becomes worth anything once the ranker is changed to
|
|
consult it, which is a redesign rather than a hookup.
|
|
|
|
*Known limitation:* the empty-store branch hardcodes `EMBED_DIM`, so a third-party `Embedder` of a
|
|
different dimension writes a shape-inconsistent empty store. Stated, not fixed.
|
|
|
|
## Bevisst ikke bygget (90 %-kuttlista)
|
|
|
|
Per the design philosophy (a ~90 % generic core with clear extension points — we do not chase
|
|
the last 10 %), the following are **deliberate cuts**, not roadmap debt. Each is extension
|
|
territory for a deployer, with the seam named:
|
|
|
|
- **B10 — full verdict-conflict taxonomy.** Chosen minimal semantics (documented in
|
|
`verdicts.py` + README): the in-memory store is first-write-wins per verdict id; the disk
|
|
layers (`write_verdict`, `promote_verdict`) are last-write-wins per file. The full taxonomy
|
|
(rejection categories + a rule for conflicting expert verdicts) is deferred until real experts
|
|
produce conflicting verdicts.
|
|
- **B11 — expert notification.** `run_project(notify=...)` remains a plain-callable seam
|
|
(`run.py` auto-wires no default notifier), but the core now ships the declared `Notifier`
|
|
contract (`notify.py`, exported from the package top) with three implementations:
|
|
`ConsoleNotifier`, `FileNotifier` (byte-deterministic JSONL), and `WebhookNotifier` — plus a
|
|
fail-fast `build_notifier(config, *, allow_egress=...)` factory. The webhook is the ONLY egress
|
|
point and is fail-closed behind an explicit per-run `allow_egress=True` opt-in (a code kwarg,
|
|
never a config field — mirroring the ingest layer's `allow_network`). SSRF guards, HMAC
|
|
signing, and auth headers remain deployer-owned extension points on the injectable
|
|
`WebhookPost` transport seam. Webhook URLs must start with `http://` or `https://` — a
|
|
scheme-less URL is rejected fail-fast at config construction.
|
|
- **U12 — checkpointing / crash-survival of a run.** A run either completes or is re-run; the
|
|
async verdict inbox (step 7) is the resumable boundary, not intra-run state.
|
|
- **U14 — OpenTelemetry / observability.** Provenance stamping is the audit trail the core
|
|
ships; OTEL wiring is a deployer concern.
|
|
- **Concurrent fan-out.** `run_portfolio` iterates projects sequentially by design (fresh
|
|
per-project execution state; one threaded `VerdictStore`); parallel orchestration is left to
|
|
the deployer and would need budget-cap coordination.
|