feat(cli): console entry points + the demo's stderr damping (P4 pkt. 5 og 2)

Two commands are now part of the install surface a fresh clone gets from `uv sync`:
`portfolio-optimiser` (run:main) and `portfolio-optimiser-demo` (simulation:main).
Deliberately two of five main()s — costsim/hitl/preflight stay module-invoked; every
name here is a name the freeze has to carry. Pinned against the INSTALLED distribution's
metadata, not the TOML: a [project.scripts] line that has never been synced is a claim,
not a command. Measured: stdout is byte-identical across both invocation forms.

stderr (P4 pkt. 2), the session's open decision, resolved by measurement rather than by
preference. Damped: the round-cap notice only, via a filter on the emitting logger, keyed
on the message and installed by main() — never at import, so a library consumer keeps its
own logging config. NOT damped: the two ExperimentalWarnings. They fire while the package
__init__ imports run -> agent_framework, always before simulation's own imports and under
both invocation forms, so silencing them would mean filtering warnings inside the library
package on every consumer's behalf; they are pinned in pkt. 3 instead. A console-script
wrapper was rejected for a second reason: the two forms would then write different stderr,
and a byte-fasit would pin the command rather than the program. stderr 6 -> 4 lines.

A first implementation wrapped simulation's own agent_framework import in a scoped mute.
Measurement showed it can never fire — the package __init__ has already imported
agent_framework by then — so it was removed rather than left as a green-but-dead seam.

Load-bearing MEASURED against the whole suite, five mutations all red + green control:
remove [project.scripts] · typo the target · detach the main() call · make the filter drop
everything · install the filter at import time. The typo mutation also felled a test: the
resolve-assert re-checked the expected constant against itself, and now resolves what the
distribution actually installs. 775 -> 785 passed / 4 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2bxLcCRguxXzpM4priTMn
This commit is contained in:
Kjell Tore Guttormsen 2026-08-09 15:13:09 +02:00
commit ab7f45aa95
6 changed files with 298 additions and 2 deletions

View file

@ -17,7 +17,13 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
- `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å `ktg/` 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`. - `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å `ktg/` 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`.
## Kommandoer ## Kommandoer
- Sync: `uv sync` - Sync: `uv sync` — installerer to konsoll-kommandoer: `portfolio-optimiser` (CLI, `run:main`) og
`portfolio-optimiser-demo` (offline-beviset, `simulation:main`). `python -m`-formene virker
uendret og er byte-identiske på stdout (målt). Bevisst KUN to av fem `main()`
`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
distribusjonens metadata, ikke mot TOML-en: en `[project.scripts]`-linje som aldri er `uv sync`-et
er en påstand, ikke en kommando.
- Test: `uv run pytest` - Test: `uv run pytest`
- Lint: `uv run ruff check .` + `uv run ruff format .` - Lint: `uv run ruff check .` + `uv run ruff format .`
- Type: `uv run mypy src` - Type: `uv run mypy src`
@ -322,6 +328,23 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
et literal i stedet for det avledede. **Målingen felte TESTEN først** (samme klasse som 08-06): et literal i stedet for det avledede. **Målingen felte TESTEN først** (samme klasse som 08-06):
«ingen kostbaseline erklært» INNEHOLDER «kostbaseline erklært», og `ENERGI-TOTAL-EL` står allerede «ingen kostbaseline erklært» INNEHOLDER «kostbaseline erklært», og `ENERGI-TOTAL-EL` står allerede
i Steg 2-linja — begge assertene overlevde detach-mutasjonen. De to grenene deler nå ingen ordlyd. i Steg 2-linja — begge assertene overlevde detach-mutasjonen. De to grenene deler nå ingen ordlyd.
- **Demoens stderr: rund-taket dempes, `ExperimentalWarning`-paret PINNES (P4 pkt. 2):** målt 08-09
var stderr seks linjer. `quiet_expected_round_cap_notice()` dropper KUN
«reached max_rounds=…; forcing completion» — en hendelse demoen selv provoserer (maker/checker
kjører til taket) — via et filter på den EMITTERENDE loggeren (`ROUND_CAP_LOGGER`, lest ut av MAFs
kilde). Logger-filtre gjelder kun loggeren posten ble logget GJENNOM; en forfars filtre konsulteres
aldri. Filteret installeres i `main()`, ALDRI ved import — en bibliotek-modul skal ikke
omkonfigurere loggingen til en konsument. **De to `ExperimentalWarning`-linjene dempes IKKE:** de
fyrer mens `portfolio_optimiser/__init__.py` importerer `run``agent_framework`, altså alltid FØR
`simulation` sin egen importblokk, under BEGGE kjøreformer — så å dempe dem ville krevd et
warnings-filter inne i bibliotekpakken, dvs. at rammeverket bestemmer hva MAF får si til enhver
konsument. En wrapper bak konsoll-kommandoen ble avvist av en andre grunn: da ville de to
kjøreformene skrevet ULIK stderr, og en byte-fasit ville pinnet kommandoen i stedet for programmet.
**Dempingen er smal ved konstruksjon** — nøklet på meldingen, ikke loggeren — nettopp så pkt. 3-pinnen
fortsatt kan felles av en NY advarsel. Load-bearing MÅLT
(`tests/test_demo_stderr_quiet_loadbearing.py`), fem mutasjoner røde + grønn kontroll: detach
`main()`-kallet (subprosess-testen er ENESTE som fanger det — de tre filter-testene installerer
filteret selv) · la filteret droppe alt · installer ved import · pluss de to entry-point-mutasjonene.
- **Delt ekspert-persona som Agent Skill (§8, framework-nøytral):** ekspert-reviewer-personaen bor i - **Delt ekspert-persona som Agent Skill (§8, framework-nøytral):** ekspert-reviewer-personaen bor i
`shared/skills/expert-reviewer/` (`SKILL.md` + `references/example-verdict.json`) og er den ENE `shared/skills/expert-reviewer/` (`SKILL.md` + `references/example-verdict.json`) og er den ENE
delte artefakten begge stacker instansierer reviewer-en fra. `shared/` forblir REN DATA — MAF-siden delte artefakten begge stacker instansierer reviewer-en fra. `shared/` forblir REN DATA — MAF-siden

View file

@ -26,6 +26,11 @@ uv sync
Clone rather than install into an existing environment: the shared spec, the persona skill and the Clone rather than install into an existing environment: the shared spec, the persona skill and the
example bundles under [`shared/`](shared/README.md) are read from the working tree at run time. example bundles under [`shared/`](shared/README.md) are read from the working tree at run time.
`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: Verify the install by running the whole suite from the clean clone:
```bash ```bash
@ -54,7 +59,7 @@ ls shared/examples/bygg-energi-mikro/
demonstrably informed by the first: demonstrably informed by the first:
```bash ```bash
uv run python -m portfolio_optimiser.simulation 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 Each run shows the refinement step: the proposer's first claim is falsified by the deterministic

View file

@ -30,6 +30,15 @@ dependencies = [
"llm-ingestion-okf", # Door A ingest (§4§6) — the shared implementation of shared/ingest-spec.md; zero runtime deps, MAF-free (D7) "llm-ingestion-okf", # Door A ingest (§4§6) — the shared implementation of shared/ingest-spec.md; zero runtime deps, MAF-free (D7)
] ]
# Console entry points — the install surface a fresh clone gets from `uv sync` (P4 pkt. 5).
# Deliberately TWO, not five: `run` is the framework CLI (three documented modes) and `simulation`
# is the offline end-to-end proof the README points a newcomer at. `costsim` / `hitl` / `preflight`
# keep the `python -m` form — they are operator utilities, not the product's front door, and every
# name here is a name the freeze has to carry. Pinned by tests/test_console_entry_points.py.
[project.scripts]
portfolio-optimiser = "portfolio_optimiser.run:main"
portfolio-optimiser-demo = "portfolio_optimiser.simulation: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
# consumer, uv.lock pins the exact commit behind the tag. Bump the rev on a new library tag. # consumer, uv.lock pins the exact commit behind the tag. Bump the rev on a new library tag.

View file

@ -21,6 +21,7 @@ client is MAF-side scaffolding; it is NOT part of the framework-neutral ``shared
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import shutil import shutil
from collections.abc import Awaitable, Callable, Mapping, Sequence from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
@ -54,6 +55,56 @@ from portfolio_optimiser.verdicts import (
_PROJECT_ID = "BYGG-KONTOR-NORD" _PROJECT_ID = "BYGG-KONTOR-NORD"
# --- P4 pkt. 2: demo stderr discipline -----------------------------------------------------------
# Measured 2026-08-09, the demo wrote six stderr lines: two ``ExperimentalWarning``s from
# ``agent_framework``, two round-cap notices from the orchestrator, a blank line and the
# ``arbeidskopi:`` path. The last two are ours and stay (the path is the one deliberately
# non-deterministic value, which is exactly why it is on stderr and not on stdout).
#
# **Damped here: the round-cap notices only.** They are emitted DURING the demo's own run, by an
# event the demo deliberately provokes — the maker/checker debate is configured to run to its cap.
#
# **NOT damped: the two ``ExperimentalWarning``s — they are pinned in pkt. 3 instead.** Measured,
# not assumed: they fire while ``portfolio_optimiser/__init__.py`` imports ``run``, which imports
# ``agent_framework`` — always before this module's own import block, under BOTH invocation forms.
# Silencing them would therefore mean putting a warnings filter inside the library package, i.e.
# letting this framework decide what MAF is allowed to tell every consumer that imports it. That is
# a library-behaviour change for stderr nobody projects, days before a freeze. A wrapper that
# muted them only behind the console script was rejected for a second reason: it would make
# ``uv run portfolio-optimiser-demo`` and ``uv run python -m portfolio_optimiser.simulation`` write
# different stderr, and a byte-fasit would then pin the command rather than the program.
#
# The damping that IS here is NARROW by construction, which is the load-bearing part: a different
# orchestration warning still reaches stderr and trips the pkt. 3 pin. A filter that could only
# ever say "drop" would leave a pin that can no longer fail for the reason it exists.
#: The logger that emits the round-cap notice, read out of MAF's source (``logger.warning`` in
#: ``_base_group_chat_orchestrator``), not guessed. Logger filters apply only to the logger the
#: record was logged THROUGH — an ancestor's filters are never consulted — so this must be the
#: emitting module's own name. If MAF moves the call, the notice simply reappears on stderr and the
#: pkt. 3 pin says so; the failure mode is visible, not silent.
ROUND_CAP_LOGGER = "agent_framework_orchestrations._base_group_chat_orchestrator"
class _ExpectedRoundCapFilter(logging.Filter):
"""Drops ONLY the "round limit reached" notice, which the demo reaches by design (the
maker/checker debate runs to its cap). Keyed on the event, not on the configured number: the
cap is a demo setting, while the notice is the thing we have decided is expected."""
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
return not ("reached max_rounds=" in message and "forcing completion" in message)
def quiet_expected_round_cap_notice() -> logging.Filter:
"""Install the round-cap filter on the emitting logger and return it (so a caller — a test —
can remove it again). Runtime state, installed by ``main()``: importing this module must not
reconfigure logging for a library consumer."""
installed = _ExpectedRoundCapFilter()
logging.getLogger(ROUND_CAP_LOGGER).addFilter(installed)
return installed
# --- Step 7, the LONG loop: what an expert drops into the inbox between the two runs ------------- # --- Step 7, the LONG loop: what an expert drops into the inbox between the two runs -------------
# A SECOND marker, deliberately distinct from the persona's (Step 8, promotion). The two mechanisms # A SECOND marker, deliberately distinct from the persona's (Step 8, promotion). The two mechanisms
# both end in Run B's hypothesis prompt, so a single shared marker would let either path carry it # both end in Run B's hypothesis prompt, so a single shared marker would let either path carry it
@ -673,6 +724,11 @@ def main(argv: list[str] | None = None) -> int: # pragma: no cover - console tr
import sys import sys
import tempfile import tempfile
# P4 pkt. 2: runtime half of the stderr discipline (the import-time half wrapped the
# ``agent_framework`` import above). Installed here rather than at import time so that a
# library consumer of this module keeps its own logging configuration.
quiet_expected_round_cap_notice()
work = tempfile.mkdtemp(prefix="po-sim-") work = tempfile.mkdtemp(prefix="po-sim-")
# THE call site (P4 pkt. 0): the demo runs the ANCHORED reserve — the shared bundle plus the # THE call site (P4 pkt. 0): the demo runs the ANCHORED reserve — the shared bundle plus the
# cost baseline it cannot be given in place. On GO day these two lines point at the delivered # cost baseline it cannot be given in place. On GO day these two lines point at the delivered

View file

@ -0,0 +1,87 @@
"""P4 pkt. 5 — the console entry points are part of the FROZEN install surface.
The README's central claim is "download -> run". Until now every documented invocation went
through ``uv run python -m portfolio_optimiser.<module>``, which works but is not an install
surface: nothing in the distribution metadata promised a command. This test pins the two commands
that the demo and the framework CLI are reached by.
Why read ``importlib.metadata`` and not ``pyproject.toml``: a ``[project.scripts]`` line that has
never been ``uv sync``-ed is a claim, not a command. The distribution metadata is what a fresh
clone materializes after ``uv sync``, so it is the only reading that can fail when the surface is
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.
Scope, stated so it is a decision and not an oversight: exactly TWO commands are exposed. ``run``
is the framework CLI (three documented modes) and ``simulation`` is the offline end-to-end proof
the README points a newcomer at. ``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
import importlib
import importlib.metadata
from pathlib import Path
import pytest
_DIST = "portfolio-optimiser"
# command name -> "module:function" target, verbatim as it must appear in the metadata.
_EXPECTED: dict[str, str] = {
"portfolio-optimiser": "portfolio_optimiser.run:main",
"portfolio-optimiser-demo": "portfolio_optimiser.simulation:main",
}
def _console_scripts() -> dict[str, str]:
"""The installed distribution's console scripts, as ``{name: "module:function"}``."""
return {
ep.name: ep.value
for ep in importlib.metadata.distribution(_DIST).entry_points
if ep.group == "console_scripts"
}
@pytest.mark.parametrize(("name", "target"), sorted(_EXPECTED.items()))
def test_console_script_is_installed(name: str, target: str) -> None:
"""T-P4.5a: the command exists in the INSTALLED distribution and points at the right target.
RED when the ``[project.scripts]`` entry is removed (or when it is added to the TOML without a
re-sync which is the same failure a fresh clone would hit for real).
"""
scripts = _console_scripts()
assert name in scripts, (
f"console script {name!r} is not installed; found {sorted(scripts)}. "
"Declare it under [project.scripts] in pyproject.toml and re-run `uv sync`."
)
assert scripts[name] == target
@pytest.mark.parametrize("name", sorted(_EXPECTED))
def test_console_script_target_resolves(name: str) -> None:
"""T-P4.5b: the INSTALLED target actually imports and is callable — a typo'd module or function
name installs a command that only fails when the operator runs it, which on demo day is on
stage. Resolves what the distribution says, not what this file expects: resolving ``_EXPECTED``
would only ever re-check a constant against itself."""
target = _console_scripts()[name]
module_name, _, func_name = target.partition(":")
module = importlib.import_module(module_name)
entry = getattr(module, func_name, None)
assert callable(entry), f"{target} (behind {name}) does not resolve to a callable"
def test_pyproject_declares_exactly_these_scripts() -> None:
"""T-P4.5c: the declaration in ``pyproject.toml`` matches the installed set exactly.
Guards the drift direction the metadata assert cannot see: a script installed from an older
sync but since deleted from the TOML would leave a command that a fresh clone never gets.
"""
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python 3.10 has no tomllib
pytest.skip("tomllib is 3.11+; the installed-metadata asserts cover the same surface")
declared = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["scripts"]
assert declared == _EXPECTED

View file

@ -0,0 +1,116 @@
"""P4 pkt. 2 — the demo's round-cap notice is damped, and the damping is NARROW by construction.
Measured on 2026-08-09, the demo wrote six stderr lines: two ``ExperimentalWarning``s from
``agent_framework`` (import time), two ``GroupChatOrchestrator reached max_rounds=3; forcing
completion.`` notices (``logging``, reaching stderr via ``logging.lastResort``), a blank line, and
the deliberately non-deterministic ``arbeidskopi:`` line.
**Only the round-cap notices are damped, and this file only tests those.** The two import-time
warnings fire while ``portfolio_optimiser/__init__.py`` imports ``run`` always before
``simulation``'s own imports, under both invocation forms — so damping them would mean filtering
warnings inside the library package on every consumer's behalf. They are pinned in pkt. 3 instead.
Measured, not assumed: see the decision recorded at the top of ``simulation.py``.
**Why narrowness is the property under test, not the silence.** Plan P4 pkt. 3 pins stderr to a
byte-fasit so a new warning after a MAF bump or a subtree pull TRIPS the pin. A damping keyed on the
logger rather than the message would swallow that new warning too, leaving a pin that can no longer
fail for the reason it exists. So the drop-assert here is paired with a control proving an
unmeasured message from the very same logger still gets through a filter that can only ever say
"drop" proves nothing.
"""
from __future__ import annotations
import logging
import subprocess
import sys
from portfolio_optimiser.simulation import ROUND_CAP_LOGGER, quiet_expected_round_cap_notice
_REAL_ROUND_CAP_MESSAGE = "GroupChatOrchestrator reached max_rounds=3; forcing completion."
class _Recorder(logging.Handler):
"""Collects whatever survives the logger's own filters."""
def __init__(self) -> None:
super().__init__()
self.messages: list[str] = []
def emit(self, record: logging.LogRecord) -> None:
self.messages.append(record.getMessage())
def _record_through_real_logger(message: str, *, quiet: bool) -> list[str]:
"""Log ``message`` through the REAL emitting logger and return what reached a handler.
Logger-level filters run in ``Logger.handle`` BEFORE ``callHandlers``, so a dropped record never
reaches the recorder the same point at which ``logging.lastResort`` would otherwise have
written it to stderr in the demo process.
"""
logger = logging.getLogger(ROUND_CAP_LOGGER)
recorder = _Recorder()
logger.addHandler(recorder)
installed: logging.Filter | None = None
try:
if quiet:
installed = quiet_expected_round_cap_notice()
logger.warning("%s", message)
finally:
logger.removeHandler(recorder)
if installed is not None:
logger.removeFilter(installed)
return recorder.messages
def test_round_cap_notice_reaches_stderr_without_the_damping() -> None:
"""T-P4.2a (the RED-proof for the test itself): the notice really is emitted through this
logger name. The negative assert below is worthless unless the event provably happens first."""
assert _record_through_real_logger(_REAL_ROUND_CAP_MESSAGE, quiet=False) == [
_REAL_ROUND_CAP_MESSAGE
]
def test_round_cap_notice_is_dropped_by_the_damping() -> None:
"""T-P4.2b: with the damping installed, the expected round-cap notice never reaches a handler."""
assert _record_through_real_logger(_REAL_ROUND_CAP_MESSAGE, quiet=True) == []
def test_unrelated_warning_from_the_same_logger_still_surfaces() -> None:
"""T-P4.2c (control): the damping is keyed on the message, not on the logger. A different
warning from the very same logger still gets through otherwise the pin in pkt. 3 could never
catch a genuine new orchestration problem."""
other = "GroupChatOrchestrator: participant 'checker' returned no message."
assert _record_through_real_logger(other, quiet=True) == [other]
def test_the_demo_run_emits_no_round_cap_notice() -> None:
"""T-P4.2e: the damping is WIRED — the real demo process writes no round-cap line.
Without this, the three asserts above would all pass with the ``main()`` call detached: they
install the filter themselves, so they measure the filter and not the demo. Runs the module form
(``-m``) rather than the console script, because that needs no assumption about PATH; both forms
were measured to write identical stderr when the entry point was added, and the console script
has its own tests in ``test_console_entry_points``.
"""
proc = subprocess.run(
[sys.executable, "-m", "portfolio_optimiser.simulation"],
capture_output=True,
text=True,
check=False,
)
assert proc.returncode == 0, proc.stderr
assert "forcing completion" not in proc.stderr, (
"the round-cap notice reached stderr; is quiet_expected_round_cap_notice() still called in "
f"main()? stderr was:\n{proc.stderr}"
)
# Control on the same output: the run really did happen, so the absence above is a damped line
# and not an unrun demo.
assert "LÆRINGSSLØYFA ER LUKKET" in proc.stdout
def test_damping_is_not_installed_at_import_time() -> None:
"""T-P4.2d: importing the module must not reconfigure logging for a library consumer — the
filter is runtime state installed by ``main()``. RED if the install call is moved to module
scope."""
assert logging.getLogger(ROUND_CAP_LOGGER).filters == []