feat(s36): costsim CLI + no-network + no-hardcoded-price guards
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0145ZKPLMVeqM47z2jxxokym
This commit is contained in:
parent
37625435c4
commit
76d9f793c6
3 changed files with 127 additions and 0 deletions
|
|
@ -19,6 +19,7 @@ kr is formatted only at the display edge.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -243,3 +244,66 @@ def dump_estimate_table(table: dict[str, Any]) -> str:
|
||||||
"""Byte-deterministic JSON serialization of an estimate table (mirrors ``outbox._dump``:
|
"""Byte-deterministic JSON serialization of an estimate table (mirrors ``outbox._dump``:
|
||||||
``sort_keys``, ``indent=2``, trailing LF)."""
|
``sort_keys``, ``indent=2``, trailing LF)."""
|
||||||
return json.dumps(table, sort_keys=True, indent=2) + "\n"
|
return json.dumps(table, sort_keys=True, indent=2) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_table_text(table: dict[str, Any]) -> str:
|
||||||
|
"""Human-readable estimate table for the CLI (honest labels + per-model guidance + kost-mot-verdi
|
||||||
|
+ adoption-path footer)."""
|
||||||
|
lines = [
|
||||||
|
f"Kostnadsestimat (øvre grense, ikke prediksjon) — profil: {table['profile']}, "
|
||||||
|
f"prosjekter: {table['n_projects']}, max_tokens/run: {table['max_tokens']}",
|
||||||
|
f"Prisdata: {table['pricing_source']} ({table['pricing_date']})",
|
||||||
|
"",
|
||||||
|
f"{'rolle':<10} {'modell':<30} {'effort':<10} {'estimat':>16} type",
|
||||||
|
]
|
||||||
|
for row in table["rows"]:
|
||||||
|
est = row["estimat_kr"] if row["estimat_kr"] is not None else "—"
|
||||||
|
lines.append(
|
||||||
|
f"{row['role']:<10} {row['model']:<30} {row['effort']:<10} {est:>16} "
|
||||||
|
f"{row['estimat_type']}"
|
||||||
|
)
|
||||||
|
guidance = row["kvalitets_veiledning"]
|
||||||
|
if guidance is not None:
|
||||||
|
lines.append(f" veiledning (kilde: {guidance['source']}): {guidance['note']}")
|
||||||
|
kmv = table["kost_mot_verdi"]
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"kost-mot-verdi: kjøringen kostet ~{kmv['kjoring_kostet_kr']} (modellert øvre grense); "
|
||||||
|
"kvalitetssikret modellert besparelse fylles av S5.4 verdirapport",
|
||||||
|
"",
|
||||||
|
"Adopsjonssti: start liten (én dimensjon, ett prosjekt, lavt tak) → eskaler med tilliten.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""CLI entry: ``python -m portfolio_optimiser.costsim`` — offline what-if estimate table over the
|
||||||
|
model-map (models × effort levels). No network; fail-fast (rc 1) on missing/invalid config."""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="portfolio_optimiser.costsim",
|
||||||
|
description="Offline kostnadssimulering før kjøring (S3.6) — estimerer kjøringskost per "
|
||||||
|
"modell × effortnivå fra skjema-validert prisdata. Ingen modellkall, ingen nettverk.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--profile", default="local", help="model-map profil (local/azure)")
|
||||||
|
parser.add_argument("--projects", type=int, default=4, help="antall prosjekter i porteføljen")
|
||||||
|
parser.add_argument(
|
||||||
|
"--efforts", default="low,standard,high", help="komma-liste av effortnivåer"
|
||||||
|
)
|
||||||
|
parser.add_argument("--pricing", default=None, help="sti til prisdata-konfig (default: pakket)")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
efforts = [e.strip() for e in args.efforts.split(",") if e.strip()]
|
||||||
|
try:
|
||||||
|
pricing = load_pricing(args.pricing)
|
||||||
|
table = build_estimate_table(args.profile, efforts, pricing, n_projects=args.projects)
|
||||||
|
except (FileNotFoundError, ValueError) as exc:
|
||||||
|
print(f"costsim: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(_format_table_text(table))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - console entry
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
|
||||||
|
|
@ -90,3 +90,18 @@ def test_estimate_table_structure() -> None:
|
||||||
assert isinstance(row["estimat_kr"], str) # edge-formatted string, never a float
|
assert isinstance(row["estimat_kr"], str) # edge-formatted string, never a float
|
||||||
assert table["kost_mot_verdi"]["kjoring_kostet_ore"] > 0
|
assert table["kost_mot_verdi"]["kjoring_kostet_ore"] > 0
|
||||||
assert table["pricing_source"] == "unit-test prices"
|
assert table["pricing_source"] == "unit-test prices"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_prints_estimate_table(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""CLI smoke: ``main`` prints the estimate table + kost-mot-verdi + adoption footer, rc 0."""
|
||||||
|
rc = costsim.main(["--projects", "4", "--profile", "local"])
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert rc == 0
|
||||||
|
assert "Kostnadsestimat" in out
|
||||||
|
assert "kost-mot-verdi" in out
|
||||||
|
assert "Adopsjonssti" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_bad_config_returns_nonzero(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
rc = costsim.main(["--pricing", "/nonexistent/pricing.json"])
|
||||||
|
assert rc == 1
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ proven load-bearing by BOTH the direct-import AST guard (``test_okf_is_maf_free`
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -168,3 +169,50 @@ def test_table_is_byte_deterministic() -> None:
|
||||||
t1 = costsim.build_estimate_table("p", ["low", "high"], pricing, n_projects=3, model_map=mm)
|
t1 = costsim.build_estimate_table("p", ["low", "high"], pricing, n_projects=3, model_map=mm)
|
||||||
t2 = costsim.build_estimate_table("p", ["low", "high"], pricing, n_projects=3, model_map=mm)
|
t2 = costsim.build_estimate_table("p", ["low", "high"], pricing, n_projects=3, model_map=mm)
|
||||||
assert costsim.dump_estimate_table(t1).encode() == costsim.dump_estimate_table(t2).encode()
|
assert costsim.dump_estimate_table(t1).encode() == costsim.dump_estimate_table(t2).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _costsim_ast() -> ast.Module:
|
||||||
|
return ast.parse(_COSTSIM.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_network_path_in_module() -> None:
|
||||||
|
"""SC4: costsim imports NO network library — offline by construction. Detach point: add a
|
||||||
|
``urllib``/``http``/``socket`` import → RED."""
|
||||||
|
net = {"socket", "urllib", "http", "requests", "httpx", "ftplib", "smtplib"}
|
||||||
|
imported: list[str] = []
|
||||||
|
for node in ast.walk(_costsim_ast()):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
imported += [a.name.split(".")[0] for a in node.names]
|
||||||
|
elif isinstance(node, ast.ImportFrom):
|
||||||
|
imported.append((node.module or "").split(".")[0])
|
||||||
|
offenders = sorted(set(imported) & net)
|
||||||
|
assert offenders == [], f"costsim must have no network import, found: {offenders}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_costsim_source_has_no_float_literal() -> None:
|
||||||
|
"""SC3 supporting guard (well-defined): costsim is pure integer-øre, so ANY ``float`` literal in
|
||||||
|
its source is either a stray hardcoded price or a no-float-invariant violation. Detach point:
|
||||||
|
hardcode a float price (e.g. ``0.03``) → RED. The behavioral sentinel test below is the PRIMARY
|
||||||
|
price-from-config seam; this literal check backs it up without the brittle allowlist a
|
||||||
|
'price-shaped number' scan would need."""
|
||||||
|
floats = [
|
||||||
|
node.value
|
||||||
|
for node in ast.walk(_costsim_ast())
|
||||||
|
if isinstance(node, ast.Constant) and isinstance(node.value, float)
|
||||||
|
]
|
||||||
|
assert floats == [], (
|
||||||
|
f"costsim must contain no float literal (integer øre only), found: {floats}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_comes_from_config() -> None:
|
||||||
|
"""SC3 PRIMARY seam (behavioral): a sentinel price in injected config flows into the estimate. A
|
||||||
|
hardcoded price shadowing config would NOT yield the sentinel-derived value → RED."""
|
||||||
|
sentinel = {
|
||||||
|
"source": "sentinel",
|
||||||
|
"date": "2026-07-15",
|
||||||
|
"models": {"m-x": {"ore_per_1k_tokens": 999_999}},
|
||||||
|
}
|
||||||
|
pricing = costsim.PricingContract(**sentinel)
|
||||||
|
expected = 100_000 * 999_999 // 1000 # standard effort (100%), default max_tokens
|
||||||
|
assert costsim.estimate_run_ore("m-x", "standard", pricing) == expected
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue