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`.
## 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`
- Lint: `uv run ruff check .` + `uv run ruff format .`
- 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):
«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.
- **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
`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

View file

@ -26,6 +26,11 @@ uv sync
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.
`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:
```bash
@ -54,7 +59,7 @@ ls shared/examples/bygg-energi-mikro/
demonstrably informed by the first:
```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

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)
]
# 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,
# 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.

View file

@ -21,6 +21,7 @@ client is MAF-side scaffolding; it is NOT part of the framework-neutral ``shared
from __future__ import annotations
import json
import logging
import shutil
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
@ -54,6 +55,56 @@ from portfolio_optimiser.verdicts import (
_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 -------------
# 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
@ -673,6 +724,11 @@ def main(argv: list[str] | None = None) -> int: # pragma: no cover - console tr
import sys
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-")
# 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

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 == []