fde-framework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fde/__init__.py +3 -0
- fde/architect.py +152 -0
- fde/cli.py +2107 -0
- fde/costing.py +292 -0
- fde/decide.py +297 -0
- fde/decompose.py +54 -0
- fde/deploy.py +371 -0
- fde/emit.py +1309 -0
- fde/evolution.py +265 -0
- fde/factlog.py +369 -0
- fde/framework/approaches/ansible-playbook.md +22 -0
- fde/framework/approaches/assisted-deterministic.md +37 -0
- fde/framework/approaches/audit-only.md +23 -0
- fde/framework/approaches/boundary-and-audit.md +18 -0
- fde/framework/approaches/cascade.md +32 -0
- fde/framework/approaches/classical-ml.md +21 -0
- fde/framework/approaches/compose.md +18 -0
- fde/framework/approaches/decision-log.md +21 -0
- fde/framework/approaches/deterministic-masking.md +18 -0
- fde/framework/approaches/deterministic.md +25 -0
- fde/framework/approaches/direct-call.md +10 -0
- fde/framework/approaches/episodic-store.md +17 -0
- fde/framework/approaches/explainability-record.md +14 -0
- fde/framework/approaches/field-match.md +15 -0
- fde/framework/approaches/finetune.md +29 -0
- fde/framework/approaches/fixed-sequence.md +19 -0
- fde/framework/approaches/gitops.md +18 -0
- fde/framework/approaches/governed-tools.md +18 -0
- fde/framework/approaches/graph-retrieval.md +22 -0
- fde/framework/approaches/judged.md +15 -0
- fde/framework/approaches/keyword-search.md +11 -0
- fde/framework/approaches/kubernetes-manifests.md +19 -0
- fde/framework/approaches/labelled-metrics.md +14 -0
- fde/framework/approaches/llm-extraction.md +26 -0
- fde/framework/approaches/llm-scrubbing.md +20 -0
- fde/framework/approaches/llm.md +23 -0
- fde/framework/approaches/local-embedding.md +28 -0
- fde/framework/approaches/managed-api.md +49 -0
- fde/framework/approaches/managed-embedding.md +22 -0
- fde/framework/approaches/manual-runbook.md +32 -0
- fde/framework/approaches/model-planner.md +19 -0
- fde/framework/approaches/ocr-pipeline.md +13 -0
- fde/framework/approaches/optimisation-reasoning.md +30 -0
- fde/framework/approaches/optimisation.md +19 -0
- fde/framework/approaches/passthrough.md +11 -0
- fde/framework/approaches/role-scoped-authority.md +19 -0
- fde/framework/approaches/segmentation.md +29 -0
- fde/framework/approaches/self-hosted.md +38 -0
- fde/framework/approaches/serverless-gpu.md +27 -0
- fde/framework/approaches/speech-transcription.md +21 -0
- fde/framework/approaches/structured-logs.md +11 -0
- fde/framework/approaches/systemd-unit.md +30 -0
- fde/framework/approaches/terraform-module.md +22 -0
- fde/framework/approaches/text-extraction.md +14 -0
- fde/framework/approaches/traced.md +24 -0
- fde/framework/approaches/vector-search.md +17 -0
- fde/framework/approaches/video-ingestion.md +16 -0
- fde/framework/approaches/windowed-ingestion.md +25 -0
- fde/framework/approaches/working-state.md +13 -0
- fde/framework/cases/churn-scoring.md +45 -0
- fde/framework/cases/route-planning.md +44 -0
- fde/framework/cases/structured-extraction.md +48 -0
- fde/framework/cases/studio-style.md +46 -0
- fde/framework/components/accountability.md +20 -0
- fde/framework/components/deployment.md +19 -0
- fde/framework/components/embedding.md +23 -0
- fde/framework/components/evaluation.md +13 -0
- fde/framework/components/governance.md +19 -0
- fde/framework/components/integration.md +13 -0
- fde/framework/components/memory.md +21 -0
- fde/framework/components/observability.md +13 -0
- fde/framework/components/perception.md +15 -0
- fde/framework/components/planning.md +15 -0
- fde/framework/components/provisioning.md +17 -0
- fde/framework/components/reasoning.md +20 -0
- fde/framework/components/redaction.md +18 -0
- fde/framework/components/representation.md +13 -0
- fde/framework/components/retrieval.md +13 -0
- fde/framework/components/serving.md +20 -0
- fde/framework/dimensions/accelerator.md +29 -0
- fde/framework/dimensions/access_model.md +25 -0
- fde/framework/dimensions/arrival_rate.md +19 -0
- fde/framework/dimensions/availability_target.md +23 -0
- fde/framework/dimensions/cheap_path_coverage.md +21 -0
- fde/framework/dimensions/confidence_calibrated.md +30 -0
- fde/framework/dimensions/container_competence.md +22 -0
- fde/framework/dimensions/corpus_size.md +13 -0
- fde/framework/dimensions/data_residency.md +36 -0
- fde/framework/dimensions/environment_lifetime.md +19 -0
- fde/framework/dimensions/existing_cluster.md +22 -0
- fde/framework/dimensions/existing_iac_tool.md +25 -0
- fde/framework/dimensions/external_systems.md +15 -0
- fde/framework/dimensions/hosting.md +45 -0
- fde/framework/dimensions/human_waiting.md +41 -0
- fde/framework/dimensions/input_format.md +29 -0
- fde/framework/dimensions/interpretability_required.md +24 -0
- fde/framework/dimensions/labelled_count.md +15 -0
- fde/framework/dimensions/latency_budget_ms.md +16 -0
- fde/framework/dimensions/licence_posture.md +28 -0
- fde/framework/dimensions/operates_after_handover.md +25 -0
- fde/framework/dimensions/output_shape.md +29 -0
- fde/framework/dimensions/provisioning_api.md +18 -0
- fde/framework/dimensions/query_pattern.md +25 -0
- fde/framework/dimensions/recall_span.md +22 -0
- fde/framework/dimensions/sensitivity_present.md +22 -0
- fde/framework/interfaces/Generator.md +5 -0
- fde/framework/interfaces/Guard.md +5 -0
- fde/framework/interfaces/Mapper.md +5 -0
- fde/framework/interfaces/ModelServer.md +5 -0
- fde/framework/interfaces/Parser.md +5 -0
- fde/framework/interfaces/Planner.md +5 -0
- fde/framework/interfaces/Retriever.md +5 -0
- fde/framework/interfaces/Scorer.md +5 -0
- fde/framework/interfaces/Store.md +5 -0
- fde/framework/interfaces/ToolBoundary.md +5 -0
- fde/framework/interfaces/Tracer.md +5 -0
- fde/framework/locales/eu-gdpr.md +51 -0
- fde/framework/locales/in-dpdp.md +46 -0
- fde/framework/patterns/ansible-playbook.md +9 -0
- fde/framework/patterns/assisted-deterministic.md +11 -0
- fde/framework/patterns/audit-only.md +9 -0
- fde/framework/patterns/boundary-and-audit.md +12 -0
- fde/framework/patterns/cascade-reasoning.md +10 -0
- fde/framework/patterns/cascade-representation.md +10 -0
- fde/framework/patterns/cascade-retrieval.md +10 -0
- fde/framework/patterns/classical-ml-reasoning.md +15 -0
- fde/framework/patterns/classical-ml.md +13 -0
- fde/framework/patterns/compose.md +9 -0
- fde/framework/patterns/decision-log.md +9 -0
- fde/framework/patterns/deterministic-masking.md +9 -0
- fde/framework/patterns/deterministic.md +12 -0
- fde/framework/patterns/direct-call.md +12 -0
- fde/framework/patterns/episodic-store.md +13 -0
- fde/framework/patterns/explainability-record.md +9 -0
- fde/framework/patterns/field-match.md +12 -0
- fde/framework/patterns/finetune-representation.md +14 -0
- fde/framework/patterns/finetune.md +12 -0
- fde/framework/patterns/fixed-sequence.md +12 -0
- fde/framework/patterns/gitops.md +9 -0
- fde/framework/patterns/governed-tools.md +13 -0
- fde/framework/patterns/graph-retrieval.md +14 -0
- fde/framework/patterns/judged.md +14 -0
- fde/framework/patterns/keyword-search.md +12 -0
- fde/framework/patterns/kubernetes-manifests.md +9 -0
- fde/framework/patterns/labelled-metrics.md +13 -0
- fde/framework/patterns/llm-representation.md +17 -0
- fde/framework/patterns/llm-scrubbing.md +9 -0
- fde/framework/patterns/llm.md +12 -0
- fde/framework/patterns/local-embedding.md +9 -0
- fde/framework/patterns/managed-api.md +12 -0
- fde/framework/patterns/managed-embedding.md +9 -0
- fde/framework/patterns/manual-runbook.md +9 -0
- fde/framework/patterns/model-planner.md +13 -0
- fde/framework/patterns/ocr-pipeline.md +13 -0
- fde/framework/patterns/optimisation-reasoning.md +15 -0
- fde/framework/patterns/optimisation.md +13 -0
- fde/framework/patterns/passthrough.md +12 -0
- fde/framework/patterns/role-scoped-authority.md +9 -0
- fde/framework/patterns/segmentation.md +12 -0
- fde/framework/patterns/self-hosted.md +14 -0
- fde/framework/patterns/serverless-gpu.md +12 -0
- fde/framework/patterns/speech-transcription.md +10 -0
- fde/framework/patterns/structured-logs.md +12 -0
- fde/framework/patterns/systemd-unit.md +9 -0
- fde/framework/patterns/terraform-module.md +9 -0
- fde/framework/patterns/text-extraction.md +12 -0
- fde/framework/patterns/traced.md +13 -0
- fde/framework/patterns/vector-search.md +14 -0
- fde/framework/patterns/video-ingestion.md +9 -0
- fde/framework/patterns/windowed-ingestion.md +12 -0
- fde/framework/patterns/working-state.md +12 -0
- fde/framework/stacks/langgraph.md +9 -0
- fde/framework/stacks/local-judge.md +9 -0
- fde/framework/stacks/mcp.md +9 -0
- fde/framework/stacks/ollama.md +19 -0
- fde/framework/stacks/openai-judge.md +9 -0
- fde/framework/stacks/opentelemetry.md +9 -0
- fde/framework/stacks/ortools.md +9 -0
- fde/framework/stacks/pgvector.md +9 -0
- fde/framework/stacks/plain-python.md +9 -0
- fde/framework/stacks/qdrant.md +9 -0
- fde/framework/stacks/tesseract.md +9 -0
- fde/framework/stacks/vllm.md +9 -0
- fde/framework/stacks/whisper.md +15 -0
- fde/framework/stacks/xgboost.md +9 -0
- fde/framework/templates/accountability/decision-log.plain.py.j2 +64 -0
- fde/framework/templates/accountability/explainability-record.plain.py.j2 +90 -0
- fde/framework/templates/deployment/compose.plain.py.j2 +36 -0
- fde/framework/templates/deployment/kubernetes-manifests.plain.py.j2 +36 -0
- fde/framework/templates/deployment/systemd-unit.plain.py.j2 +36 -0
- fde/framework/templates/embedding/local-embedding.plain.py.j2 +71 -0
- fde/framework/templates/embedding/managed-embedding.plain.py.j2 +76 -0
- fde/framework/templates/evaluation/field-match.plain.py.j2 +146 -0
- fde/framework/templates/evaluation/judged.local-judge.py.j2 +100 -0
- fde/framework/templates/evaluation/judged.openai-judge.py.j2 +70 -0
- fde/framework/templates/evaluation/judged.plain.py.j2 +89 -0
- fde/framework/templates/evaluation/labelled-metrics.plain.py.j2 +64 -0
- fde/framework/templates/evaluation/labelled-metrics.xgboost.py.j2 +72 -0
- fde/framework/templates/governance/audit-only.plain.py.j2 +98 -0
- fde/framework/templates/governance/boundary-and-audit.plain.py.j2 +142 -0
- fde/framework/templates/governance/role-scoped-authority.plain.py.j2 +63 -0
- fde/framework/templates/integration/direct-call.plain.py.j2 +63 -0
- fde/framework/templates/integration/governed-tools.mcp.py.j2 +124 -0
- fde/framework/templates/integration/governed-tools.plain.py.j2 +153 -0
- fde/framework/templates/memory/episodic-store.pgvector.py.j2 +118 -0
- fde/framework/templates/memory/episodic-store.plain.py.j2 +147 -0
- fde/framework/templates/memory/working-state.plain.py.j2 +48 -0
- fde/framework/templates/observability/structured-logs.plain.py.j2 +61 -0
- fde/framework/templates/observability/traced.opentelemetry.py.j2 +65 -0
- fde/framework/templates/observability/traced.plain.py.j2 +132 -0
- fde/framework/templates/perception/ocr-pipeline.plain.py.j2 +71 -0
- fde/framework/templates/perception/ocr-pipeline.tesseract.py.j2 +80 -0
- fde/framework/templates/perception/passthrough.plain.py.j2 +41 -0
- fde/framework/templates/perception/speech-transcription.plain.py.j2 +37 -0
- fde/framework/templates/perception/speech-transcription.whisper.py.j2 +39 -0
- fde/framework/templates/perception/text-extraction.plain.py.j2 +88 -0
- fde/framework/templates/perception/video-ingestion.plain.py.j2 +35 -0
- fde/framework/templates/perception/windowed-ingestion.plain.py.j2 +69 -0
- fde/framework/templates/planning/fixed-sequence.plain.py.j2 +46 -0
- fde/framework/templates/planning/model-planner.langgraph.py.j2 +89 -0
- fde/framework/templates/planning/model-planner.plain.py.j2 +82 -0
- fde/framework/templates/planning/optimisation.ortools.py.j2 +90 -0
- fde/framework/templates/planning/optimisation.plain.py.j2 +71 -0
- fde/framework/templates/provisioning/ansible-playbook.plain.py.j2 +29 -0
- fde/framework/templates/provisioning/gitops.plain.py.j2 +29 -0
- fde/framework/templates/provisioning/manual-runbook.plain.py.j2 +29 -0
- fde/framework/templates/provisioning/terraform-module.plain.py.j2 +29 -0
- fde/framework/templates/reasoning/cascade.plain.py.j2 +86 -0
- fde/framework/templates/reasoning/classical-ml.plain.py.j2 +74 -0
- fde/framework/templates/reasoning/classical-ml.xgboost.py.j2 +104 -0
- fde/framework/templates/reasoning/finetune.plain.py.j2 +73 -0
- fde/framework/templates/reasoning/llm.plain.py.j2 +114 -0
- fde/framework/templates/reasoning/optimisation.ortools.py.j2 +90 -0
- fde/framework/templates/reasoning/optimisation.plain.py.j2 +58 -0
- fde/framework/templates/redaction/deterministic-masking.plain.py.j2 +44 -0
- fde/framework/templates/redaction/llm-scrubbing.plain.py.j2 +40 -0
- fde/framework/templates/representation/assisted.plain.py.j2 +86 -0
- fde/framework/templates/representation/cascade.plain.py.j2 +119 -0
- fde/framework/templates/representation/classical-ml.plain.py.j2 +74 -0
- fde/framework/templates/representation/classical-ml.xgboost.py.j2 +104 -0
- fde/framework/templates/representation/deterministic.plain.py.j2 +101 -0
- fde/framework/templates/representation/finetune.plain.py.j2 +68 -0
- fde/framework/templates/representation/llm.plain.py.j2 +94 -0
- fde/framework/templates/representation/segmentation.plain.py.j2 +68 -0
- fde/framework/templates/retrieval/cascade.plain.py.j2 +86 -0
- fde/framework/templates/retrieval/graph-retrieval.pgvector.py.j2 +88 -0
- fde/framework/templates/retrieval/graph-retrieval.plain.py.j2 +74 -0
- fde/framework/templates/retrieval/graph-retrieval.qdrant.py.j2 +71 -0
- fde/framework/templates/retrieval/keyword-search.plain.py.j2 +104 -0
- fde/framework/templates/retrieval/vector-search.pgvector.py.j2 +100 -0
- fde/framework/templates/retrieval/vector-search.plain.py.j2 +84 -0
- fde/framework/templates/retrieval/vector-search.qdrant.py.j2 +60 -0
- fde/framework/templates/serving/managed-api.plain.py.j2 +74 -0
- fde/framework/templates/serving/self-hosted.ollama.py.j2 +47 -0
- fde/framework/templates/serving/self-hosted.plain.py.j2 +117 -0
- fde/framework/templates/serving/self-hosted.vllm.py.j2 +77 -0
- fde/framework/templates/serving/serverless-gpu.plain.py.j2 +62 -0
- fde/gates.py +480 -0
- fde/graph.py +435 -0
- fde/implement.py +250 -0
- fde/intake/__init__.py +0 -0
- fde/intake/answers.py +154 -0
- fde/intake/documents.py +121 -0
- fde/intake/interview.py +218 -0
- fde/intake/llm_reader.py +336 -0
- fde/intake/prose.py +387 -0
- fde/intake/samples.py +369 -0
- fde/models/__init__.py +0 -0
- fde/models/base.py +86 -0
- fde/models/fact.py +37 -0
- fde/models/profile.py +159 -0
- fde/models/respondent.py +34 -0
- fde/models/schema.py +430 -0
- fde/moves.py +136 -0
- fde/ops.py +349 -0
- fde/predicate.py +106 -0
- fde/realization.py +109 -0
- fde/registry.py +162 -0
- fde/scan.py +479 -0
- fde/space.py +172 -0
- fde/workflow.py +233 -0
- fde_framework-0.1.0.dist-info/METADATA +449 -0
- fde_framework-0.1.0.dist-info/RECORD +286 -0
- fde_framework-0.1.0.dist-info/WHEEL +4 -0
- fde_framework-0.1.0.dist-info/entry_points.txt +2 -0
- fde_framework-0.1.0.dist-info/licenses/LICENSE +202 -0
fde/cli.py
ADDED
|
@@ -0,0 +1,2107 @@
|
|
|
1
|
+
"""The command line.
|
|
2
|
+
|
|
3
|
+
`fde kb validate` is strict by default because CI runs it, and a warning nobody
|
|
4
|
+
reads is not a check. `--lenient` exists for the hour when you are mid-way
|
|
5
|
+
through authoring content and the links do not resolve yet.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from datetime import date
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Annotated
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
from fde.architect import architect as build_architecture
|
|
20
|
+
from fde.emit import BuildRefused, emit
|
|
21
|
+
from fde.evolution import (
|
|
22
|
+
Observation,
|
|
23
|
+
Override,
|
|
24
|
+
Prediction,
|
|
25
|
+
calibration,
|
|
26
|
+
emit_case,
|
|
27
|
+
sweep_triggers,
|
|
28
|
+
)
|
|
29
|
+
from fde.factlog import Session, load_engagement, start_engagement
|
|
30
|
+
from fde.gates import HardGate, input_status, validate_baseline
|
|
31
|
+
from fde.graph import find_gaps, validate_links
|
|
32
|
+
from fde.intake.answers import parse_answer
|
|
33
|
+
from fde.intake.documents import UnreadableDocument, read_document
|
|
34
|
+
from fde.intake.interview import remaining_questions
|
|
35
|
+
from fde.intake.prose import parse_prose, restate
|
|
36
|
+
from fde.intake.samples import (
|
|
37
|
+
ContractConflict,
|
|
38
|
+
assess,
|
|
39
|
+
build_eval_set,
|
|
40
|
+
infer_contract,
|
|
41
|
+
infer_metrics,
|
|
42
|
+
load_pairs,
|
|
43
|
+
samples_to_facts,
|
|
44
|
+
)
|
|
45
|
+
from fde.models.base import Provenance
|
|
46
|
+
from fde.models.fact import Fact
|
|
47
|
+
from fde.models.profile import Profile
|
|
48
|
+
from fde.models.respondent import Respondent, Role
|
|
49
|
+
from fde.predicate import PredicateError, holds
|
|
50
|
+
from fde.registry import KINDS, RegistryError, is_empty, load_registry
|
|
51
|
+
from fde.scan import (
|
|
52
|
+
GPU,
|
|
53
|
+
Hardware,
|
|
54
|
+
detect,
|
|
55
|
+
finetune_feasible,
|
|
56
|
+
fits,
|
|
57
|
+
scan_facts,
|
|
58
|
+
suggest,
|
|
59
|
+
)
|
|
60
|
+
from fde.space import Contradiction, Space
|
|
61
|
+
|
|
62
|
+
app = typer.Typer(help="Take an engagement from problem statement to a runnable project.")
|
|
63
|
+
kb = typer.Typer(help="Inspect the knowledge base in framework/.")
|
|
64
|
+
app.add_typer(kb, name="kb")
|
|
65
|
+
|
|
66
|
+
def _default_registry_root() -> Path:
|
|
67
|
+
"""A checkout's ./framework when present, else the copy in the wheel.
|
|
68
|
+
|
|
69
|
+
Local first, always: a contributor editing the corpus must see their
|
|
70
|
+
edits, not the packaged snapshot. The packaged copy is what makes
|
|
71
|
+
`pip install fde-framework` a working tool rather than a tool with no
|
|
72
|
+
knowledge base.
|
|
73
|
+
"""
|
|
74
|
+
local = Path("framework")
|
|
75
|
+
if local.is_dir():
|
|
76
|
+
return local
|
|
77
|
+
try:
|
|
78
|
+
from importlib.resources import files
|
|
79
|
+
|
|
80
|
+
packaged = Path(str(files("fde") / "framework"))
|
|
81
|
+
if packaged.is_dir():
|
|
82
|
+
return packaged
|
|
83
|
+
except (ImportError, TypeError):
|
|
84
|
+
pass
|
|
85
|
+
return local
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
DEFAULT_ROOT = _default_registry_root()
|
|
89
|
+
|
|
90
|
+
# What `fde retro` writes, and the only shape ingest will treat as a filename.
|
|
91
|
+
CASE_ID = re.compile(r"case-[0-9a-f]{6,32}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@kb.command("validate")
|
|
95
|
+
def kb_validate(
|
|
96
|
+
root: Annotated[Path, typer.Option(help="Registry directory.")] = DEFAULT_ROOT,
|
|
97
|
+
lenient: Annotated[
|
|
98
|
+
bool, typer.Option(help="Report dangling links without failing.")
|
|
99
|
+
] = False,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Check that everything parses and every cross-reference resolves."""
|
|
102
|
+
try:
|
|
103
|
+
registry = load_registry(root)
|
|
104
|
+
except RegistryError as exc:
|
|
105
|
+
typer.echo(str(exc), err=True)
|
|
106
|
+
raise typer.Exit(1) from exc
|
|
107
|
+
|
|
108
|
+
errors = validate_links(registry)
|
|
109
|
+
for error in errors:
|
|
110
|
+
typer.echo(f"{error.source}: {error.message}", err=not lenient)
|
|
111
|
+
|
|
112
|
+
counts = ", ".join(
|
|
113
|
+
f"{len(getattr(registry, kind))} {kind}"
|
|
114
|
+
for kind in KINDS
|
|
115
|
+
if getattr(registry, kind, None)
|
|
116
|
+
)
|
|
117
|
+
typer.echo(f"loaded {counts or 'nothing'}")
|
|
118
|
+
|
|
119
|
+
if errors and not lenient:
|
|
120
|
+
typer.echo(f"{len(errors)} broken reference(s)", err=True)
|
|
121
|
+
raise typer.Exit(1)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command("start")
|
|
125
|
+
def start(
|
|
126
|
+
name: Annotated[str, typer.Argument(help="Engagement name.")],
|
|
127
|
+
base: Annotated[Path, typer.Option(help="Where engagements live.")] = Path("engagements"),
|
|
128
|
+
statement: Annotated[
|
|
129
|
+
str | None, typer.Option(help="The problem, in prose. Optional.")
|
|
130
|
+
] = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Begin an engagement.
|
|
133
|
+
|
|
134
|
+
A statement is optional: an FDE who only answers questions is a supported
|
|
135
|
+
path, and so is pasting prose later.
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
engagement = start_engagement(base, name, statement=statement)
|
|
139
|
+
except FileExistsError as exc:
|
|
140
|
+
typer.echo(str(exc), err=True)
|
|
141
|
+
raise typer.Exit(1) from exc
|
|
142
|
+
|
|
143
|
+
typer.echo(f"started {engagement.root}")
|
|
144
|
+
typer.echo(" facts/ one file per session, append-only")
|
|
145
|
+
typer.echo(" artifacts/ drop specs, schemas and sample pairs here")
|
|
146
|
+
if not statement:
|
|
147
|
+
typer.echo("\nNo statement yet. Add prose later, or start answering questions.")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command("status")
|
|
151
|
+
def status(
|
|
152
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
153
|
+
registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""What is known, what is contested, and who said it."""
|
|
156
|
+
engagement = _engagement(root)
|
|
157
|
+
profile = engagement.profile
|
|
158
|
+
# Lenient on purpose: status is the one command that must answer even
|
|
159
|
+
# with no registry in reach -- the gates fall back rather than fail.
|
|
160
|
+
try:
|
|
161
|
+
registry = load_registry(registry_root)
|
|
162
|
+
except RegistryError:
|
|
163
|
+
registry = None
|
|
164
|
+
|
|
165
|
+
# An empty profile is not an empty engagement: a baseline, a waiver or a
|
|
166
|
+
# restated problem are all state the gates judge, facts or no facts.
|
|
167
|
+
if profile.is_empty():
|
|
168
|
+
typer.echo("no facts recorded yet")
|
|
169
|
+
|
|
170
|
+
resolved = profile.values()
|
|
171
|
+
if resolved:
|
|
172
|
+
typer.echo(f"known ({len(resolved)})")
|
|
173
|
+
# Grouped by scope, so discovery reads as the systematic exercise it
|
|
174
|
+
# is -- and the empty group is as loud as the full one: a design with
|
|
175
|
+
# its functional scope settled and its non-functional scope blank is
|
|
176
|
+
# a specific, familiar kind of trouble.
|
|
177
|
+
by_scope: dict[str, list[str]] = {}
|
|
178
|
+
for dimension in sorted(resolved):
|
|
179
|
+
entry = registry.dimensions.get(dimension) if registry else None
|
|
180
|
+
scope = str(entry.scope) if entry else "other"
|
|
181
|
+
by_scope.setdefault(scope, []).append(dimension)
|
|
182
|
+
order = ("functional", "non_functional", "data", "environment",
|
|
183
|
+
"operational", "commercial", "other")
|
|
184
|
+
labels = {"functional": "functional scope",
|
|
185
|
+
"non_functional": "non-functional scope",
|
|
186
|
+
"data": "data scope", "environment": "environment",
|
|
187
|
+
"operational": "operations", "commercial": "commercial",
|
|
188
|
+
"other": "other"}
|
|
189
|
+
for scope in order:
|
|
190
|
+
dims = by_scope.get(scope)
|
|
191
|
+
if not dims:
|
|
192
|
+
continue
|
|
193
|
+
typer.echo(f" {labels[scope]}:")
|
|
194
|
+
for dimension in dims:
|
|
195
|
+
fact = profile.fact(dimension)
|
|
196
|
+
if fact is None:
|
|
197
|
+
# Peers standing together: several values, each with its
|
|
198
|
+
# own speaker, resolved as the union.
|
|
199
|
+
for peer in profile.peers(dimension):
|
|
200
|
+
shown = " ".join(str(peer.value).split())
|
|
201
|
+
typer.echo(
|
|
202
|
+
f" {dimension} += {shown} [{_who(peer)}]"
|
|
203
|
+
)
|
|
204
|
+
continue
|
|
205
|
+
shown = " ".join(str(fact.value).split())
|
|
206
|
+
typer.echo(f" {dimension} = {shown} [{_who(fact)}]")
|
|
207
|
+
if registry:
|
|
208
|
+
space = Space.from_registry(registry).apply(profile)
|
|
209
|
+
unsettled, implied = {}, []
|
|
210
|
+
for entry in registry.dimensions.values():
|
|
211
|
+
if entry.weight <= 0 or profile.resolved(entry.id):
|
|
212
|
+
continue
|
|
213
|
+
in_space = entry.values and entry.id in space.dimensions()
|
|
214
|
+
surviving = space.surviving(entry.id) if in_space else set()
|
|
215
|
+
if in_space and len(surviving) == 1:
|
|
216
|
+
# Settled by implication: an earlier answer pruned every
|
|
217
|
+
# other value. The interview will never offer it again,
|
|
218
|
+
# so listing it as open sends somebody to schedule a
|
|
219
|
+
# conversation the framework would refuse to have.
|
|
220
|
+
implied.append(f"{entry.id} = {next(iter(surviving))}")
|
|
221
|
+
continue
|
|
222
|
+
unsettled.setdefault(str(entry.scope), []).append(entry.id)
|
|
223
|
+
if implied:
|
|
224
|
+
typer.echo(
|
|
225
|
+
f" settled by implication -- {', '.join(sorted(implied))}"
|
|
226
|
+
)
|
|
227
|
+
gaps_line = " · ".join(
|
|
228
|
+
f"{labels.get(s, s)}: {', '.join(sorted(d))}"
|
|
229
|
+
for s, d in sorted(unsettled.items()) if d
|
|
230
|
+
)
|
|
231
|
+
if gaps_line:
|
|
232
|
+
typer.echo(f" still open -- {gaps_line}")
|
|
233
|
+
|
|
234
|
+
status = _gate_status(engagement, registry)
|
|
235
|
+
typer.echo(f"\n{status.completeness:.0%} of what gets decided is settled")
|
|
236
|
+
|
|
237
|
+
stored = engagement.gate_state().get("overrides", [])
|
|
238
|
+
applied_names = {o.gate for o in status.overridden}
|
|
239
|
+
idle = [w for w in stored if w["gate"] not in applied_names]
|
|
240
|
+
if idle:
|
|
241
|
+
# A waiver on file that does not take is state somebody wrote and
|
|
242
|
+
# nobody can see: progress on a partial baseline once un-waived the
|
|
243
|
+
# gate and nothing anywhere said why build stopped proceeding.
|
|
244
|
+
typer.echo(f"\nwaivers on file, not applied ({len(idle)})")
|
|
245
|
+
for waiver in idle:
|
|
246
|
+
gate_now = next((g for g in status.gates if g.name == waiver["gate"]), None)
|
|
247
|
+
if gate_now is None:
|
|
248
|
+
why = "no such gate"
|
|
249
|
+
elif gate_now.passed:
|
|
250
|
+
why = "the gate passes on its own"
|
|
251
|
+
else:
|
|
252
|
+
why = (f"granted against {waiver.get('against') or 'nothing recorded'!r}; "
|
|
253
|
+
f"the gate now says {gate_now.reason!r} -- waive again "
|
|
254
|
+
f"if the new problem is also accepted")
|
|
255
|
+
typer.echo(f" {waiver['gate']}: {why}")
|
|
256
|
+
|
|
257
|
+
if status.overridden:
|
|
258
|
+
typer.echo(f"\nwaived ({len(status.overridden)})")
|
|
259
|
+
for waived in status.overridden:
|
|
260
|
+
typer.echo(f" {waived.gate}: {waived.reason}")
|
|
261
|
+
|
|
262
|
+
blocking = status.blocked_by()
|
|
263
|
+
if blocking:
|
|
264
|
+
typer.echo(f"\nblocked by {len(blocking)}")
|
|
265
|
+
for name in blocking:
|
|
266
|
+
gate = status.gate(name)
|
|
267
|
+
mark = " [hard] " if gate.hard else " "
|
|
268
|
+
typer.echo(f"{mark}{name}: {gate.reason}")
|
|
269
|
+
if gate.remedy:
|
|
270
|
+
typer.echo(f" -> {gate.remedy}")
|
|
271
|
+
|
|
272
|
+
if status.missing_roles:
|
|
273
|
+
typer.echo(f"\nnobody has spoken for: {', '.join(status.missing_roles)}")
|
|
274
|
+
|
|
275
|
+
# Disagreement is the most valuable thing discovery produces. It goes last so
|
|
276
|
+
# it is the final thing on screen, and it is never summarised away.
|
|
277
|
+
disagreements = profile.disagreements()
|
|
278
|
+
if disagreements:
|
|
279
|
+
typer.echo(f"\nunresolved -- respondents disagree ({len(disagreements)})")
|
|
280
|
+
for d in disagreements:
|
|
281
|
+
typer.echo(f" {d.dimension}")
|
|
282
|
+
for fact in d.facts:
|
|
283
|
+
typer.echo(f" {_who(fact)} says {fact.value}")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _who(fact) -> str:
|
|
287
|
+
"""Name and role together.
|
|
288
|
+
|
|
289
|
+
The role is not decoration: it is what tells an FDE whose answer to weigh
|
|
290
|
+
for which dimension. A sponsor on latency and a user on latency are
|
|
291
|
+
different kinds of claim.
|
|
292
|
+
"""
|
|
293
|
+
role = str(fact.respondent.role)
|
|
294
|
+
name = fact.respondent.name
|
|
295
|
+
return f"{name}, {role}" if name else role
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@app.command("frame")
|
|
299
|
+
def frame(
|
|
300
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
301
|
+
text: Annotated[str | None, typer.Option(help="The brief, inline.")] = None,
|
|
302
|
+
file: Annotated[Path | None, typer.Option(help="A file holding the brief.")] = None,
|
|
303
|
+
reader: Annotated[str, typer.Option(
|
|
304
|
+
help="'deterministic' (default, offline) or 'llm': a model proposes "
|
|
305
|
+
"facts for what the deterministic pass left open, at the weakest "
|
|
306
|
+
"provenance, validated against the registry."
|
|
307
|
+
)] = "deterministic",
|
|
308
|
+
endpoint: Annotated[str | None, typer.Option(
|
|
309
|
+
help="OpenAI-compatible local server for --reader llm (vLLM/Ollama "
|
|
310
|
+
"on this machine). Without it, the hosted model is used -- "
|
|
311
|
+
"which the boundary doctrine only permits when data may leave."
|
|
312
|
+
)] = None,
|
|
313
|
+
model: Annotated[str | None, typer.Option(
|
|
314
|
+
help="Model name for --reader llm."
|
|
315
|
+
)] = None,
|
|
316
|
+
registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Read prose into facts, and play back what was understood."""
|
|
319
|
+
if not text and not file:
|
|
320
|
+
typer.echo("Give me --text or --file.", err=True)
|
|
321
|
+
raise typer.Exit(1)
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
body = read_document(file) if file else (text or "")
|
|
325
|
+
except UnreadableDocument as exc:
|
|
326
|
+
typer.echo(str(exc), err=True)
|
|
327
|
+
raise typer.Exit(1) from exc
|
|
328
|
+
source = file.name if file else "brief"
|
|
329
|
+
registry = _registry(registry_root)
|
|
330
|
+
engagement = _engagement(root)
|
|
331
|
+
|
|
332
|
+
declines: list[str] = []
|
|
333
|
+
facts = parse_prose(body, registry, source=source, declines=declines)
|
|
334
|
+
typer.echo(restate(facts, registry))
|
|
335
|
+
for decline in declines:
|
|
336
|
+
typer.echo(f"\n declined -- {decline}")
|
|
337
|
+
|
|
338
|
+
# The follow-ups, right where the gap appears. The interview is the full
|
|
339
|
+
# instrument; these are the three questions that most change the design,
|
|
340
|
+
# so a statement never dead-ends at "correct anything wrong".
|
|
341
|
+
with_new = _with_all(engagement.profile, facts)
|
|
342
|
+
space_now = Space.from_registry(registry).apply(with_new)
|
|
343
|
+
follow_ups = remaining_questions(space_now, with_new, registry)[:3]
|
|
344
|
+
if follow_ups:
|
|
345
|
+
typer.echo("\nworth asking next (fde ask <eng> --role <who>):")
|
|
346
|
+
for question in follow_ups:
|
|
347
|
+
roles = "/".join(question.roles) if question.roles else "anyone"
|
|
348
|
+
typer.echo(f" - {question.asks} [{roles}]")
|
|
349
|
+
|
|
350
|
+
if reader == "llm":
|
|
351
|
+
from fde.intake.llm_reader import (
|
|
352
|
+
BoundaryRefusal,
|
|
353
|
+
ReaderUnavailable,
|
|
354
|
+
read_with_llm,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
already = dict(engagement.profile.values())
|
|
358
|
+
already.update({f.dimension: f.value for f in facts})
|
|
359
|
+
try:
|
|
360
|
+
proposed, dropped = read_with_llm(
|
|
361
|
+
body, registry, already, endpoint=endpoint, model=model,
|
|
362
|
+
)
|
|
363
|
+
except (BoundaryRefusal, ReaderUnavailable) as exc:
|
|
364
|
+
typer.echo(f"\n{exc}", err=True)
|
|
365
|
+
proposed, dropped = [], []
|
|
366
|
+
if proposed:
|
|
367
|
+
typer.echo(
|
|
368
|
+
"\nThe model also read (weakest provenance -- any stated "
|
|
369
|
+
"answer outranks these; correct anything wrong):"
|
|
370
|
+
)
|
|
371
|
+
for fact in proposed:
|
|
372
|
+
shown = " ".join(str(fact.value).split())
|
|
373
|
+
typer.echo(f" - {fact.dimension} = {shown}")
|
|
374
|
+
facts = facts + proposed
|
|
375
|
+
for reason in dropped:
|
|
376
|
+
typer.echo(f" (refused from the model: {reason})")
|
|
377
|
+
elif reader != "deterministic":
|
|
378
|
+
typer.echo(f"{reader!r} is not a reader. One of: deterministic, llm.",
|
|
379
|
+
err=True)
|
|
380
|
+
raise typer.Exit(1)
|
|
381
|
+
|
|
382
|
+
# An empty session file is noise in an append-only log.
|
|
383
|
+
if not facts:
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
session_id = _next_session_id(engagement, "frame")
|
|
387
|
+
# The brief itself is retained beside the facts. Every fact carries a
|
|
388
|
+
# span pointing into this text; a span into a document nobody kept is a
|
|
389
|
+
# citation to nowhere -- and the (text, facts) pair is the training
|
|
390
|
+
# example a future fine-tuned reader learns from. Client data, in the
|
|
391
|
+
# engagement directory, never committed: same rules as everything here.
|
|
392
|
+
briefs = engagement.artifacts_dir / "briefs"
|
|
393
|
+
briefs.mkdir(parents=True, exist_ok=True)
|
|
394
|
+
(briefs / f"{session_id}.txt").write_text(body)
|
|
395
|
+
|
|
396
|
+
engagement.append(
|
|
397
|
+
Session(
|
|
398
|
+
session_id=session_id,
|
|
399
|
+
respondent=Respondent(role=Role.SYSTEM),
|
|
400
|
+
facts=facts,
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@app.command("samples")
|
|
406
|
+
def samples_cmd(
|
|
407
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
408
|
+
file: Annotated[Path, typer.Option(help="A .jsonl of input/output pairs.")],
|
|
409
|
+
sensitive: Annotated[list[str] | None, typer.Option(
|
|
410
|
+
"--sensitive",
|
|
411
|
+
help="Mark a field as sensitive (repeatable). Declared beats "
|
|
412
|
+
"detected: the masking the build emits reads this list."
|
|
413
|
+
)] = None,
|
|
414
|
+
) -> None:
|
|
415
|
+
"""Read sample pairs: the contract, the metric, and the golden set.
|
|
416
|
+
|
|
417
|
+
The most valuable thing a client hands over. A brief describes the problem;
|
|
418
|
+
these describe the answer.
|
|
419
|
+
"""
|
|
420
|
+
engagement = _engagement(root)
|
|
421
|
+
try:
|
|
422
|
+
body = file.read_text()
|
|
423
|
+
pairs = load_pairs(file)
|
|
424
|
+
contract = infer_contract(pairs)
|
|
425
|
+
except (ContractConflict, ValueError, OSError) as exc:
|
|
426
|
+
typer.echo(str(exc), err=True)
|
|
427
|
+
raise typer.Exit(1) from exc
|
|
428
|
+
|
|
429
|
+
# PII lives in inputs at least as often as in outputs, so a mark may
|
|
430
|
+
# name either side of the pair.
|
|
431
|
+
known_fields = set(contract.fields)
|
|
432
|
+
for pair in pairs:
|
|
433
|
+
if isinstance(pair.get("input"), dict):
|
|
434
|
+
known_fields.update(pair["input"])
|
|
435
|
+
unknown = sorted(set(sensitive or []) - known_fields)
|
|
436
|
+
if unknown:
|
|
437
|
+
typer.echo(
|
|
438
|
+
f"not fields in these pairs: {', '.join(unknown)}. "
|
|
439
|
+
f"Fields: {', '.join(sorted(known_fields))}", err=True,
|
|
440
|
+
)
|
|
441
|
+
raise typer.Exit(1)
|
|
442
|
+
|
|
443
|
+
# Copied before anything is reported, so a failure here cannot arrive
|
|
444
|
+
# after a success message has already scrolled past.
|
|
445
|
+
(engagement.artifacts_dir / "pairs.jsonl").write_text(body)
|
|
446
|
+
# The holdout stays with the engagement and never ships in a delivery:
|
|
447
|
+
# golden.jsonl sits readable beside the exam, so a green that was
|
|
448
|
+
# memorized from it is caught only by cases the implementer never saw.
|
|
449
|
+
from fde.intake.samples import split_pairs
|
|
450
|
+
|
|
451
|
+
holdout_ids = set(split_pairs(pairs).holdout_ids)
|
|
452
|
+
if holdout_ids:
|
|
453
|
+
(engagement.artifacts_dir / "holdout.jsonl").write_text("".join(
|
|
454
|
+
json.dumps(pair) + "\n" for pair in pairs
|
|
455
|
+
if pair.get("id") in holdout_ids
|
|
456
|
+
))
|
|
457
|
+
if sensitive:
|
|
458
|
+
(engagement.artifacts_dir / "sensitive_fields.json").write_text(
|
|
459
|
+
json.dumps(sorted(set(sensitive)))
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
suite = build_eval_set(pairs)
|
|
463
|
+
typer.echo(f"{len(pairs)} pairs, {len(contract.fields)} fields\n")
|
|
464
|
+
for name, entry in sorted(contract.fields.items()):
|
|
465
|
+
marks = " ".join(
|
|
466
|
+
m for m in ("required" if entry.required else "optional", entry.sensitivity or "")
|
|
467
|
+
if m
|
|
468
|
+
)
|
|
469
|
+
typer.echo(f" {name:24} {entry.type:8} {marks}")
|
|
470
|
+
|
|
471
|
+
typer.echo(f"\nmetric: {', '.join(infer_metrics(contract))}")
|
|
472
|
+
typer.echo(
|
|
473
|
+
f"evals: {len(suite.golden)} golden, {len(suite.edge_case)} edge, "
|
|
474
|
+
f"{len(suite.adversarial)} adversarial"
|
|
475
|
+
)
|
|
476
|
+
unverified = sum(1 for pair in pairs if not pair.get("verified"))
|
|
477
|
+
if unverified:
|
|
478
|
+
typer.echo(
|
|
479
|
+
f"\n{unverified} pair(s) carry no `verified: true`, so they were "
|
|
480
|
+
f"kept for mining, never for measurement -- an unchecked example "
|
|
481
|
+
f"cannot be ground truth. Mark the ones a person has actually "
|
|
482
|
+
f"checked and re-run; the golden set is built only from those."
|
|
483
|
+
)
|
|
484
|
+
for warning in assess(pairs):
|
|
485
|
+
typer.echo(f"\n{warning}")
|
|
486
|
+
|
|
487
|
+
facts = samples_to_facts(pairs)
|
|
488
|
+
if sensitive and not any(f.dimension == "sensitivity_present" for f in facts):
|
|
489
|
+
facts.append(Fact("sensitivity_present", True, Provenance.ARTIFACT,
|
|
490
|
+
source="sample pairs, marked by hand"))
|
|
491
|
+
engagement.append(
|
|
492
|
+
Session(
|
|
493
|
+
session_id=_next_session_id(engagement, "samples"),
|
|
494
|
+
respondent=Respondent(role=Role.SYSTEM),
|
|
495
|
+
facts=facts,
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@app.command("ask")
|
|
501
|
+
def ask(
|
|
502
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
503
|
+
role: Annotated[str, typer.Option(help="Who you are talking to.")],
|
|
504
|
+
name: Annotated[str | None, typer.Option(help="Their name, for the record.")] = None,
|
|
505
|
+
scope: Annotated[str | None, typer.Option(
|
|
506
|
+
help="Limit to one scope axis: functional, non_functional, data, "
|
|
507
|
+
"environment, operational, commercial."
|
|
508
|
+
)] = None,
|
|
509
|
+
registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
|
|
510
|
+
) -> None:
|
|
511
|
+
"""Interview one person.
|
|
512
|
+
|
|
513
|
+
Questions are scoped to what this role can answer and ordered by how much
|
|
514
|
+
the answer changes. Press enter to skip anything -- an intake that cannot
|
|
515
|
+
get past an unknown is an intake that stops.
|
|
516
|
+
"""
|
|
517
|
+
registry = _registry(registry_root)
|
|
518
|
+
engagement = _engagement(root)
|
|
519
|
+
try:
|
|
520
|
+
parsed_role = Role(role)
|
|
521
|
+
except ValueError as exc:
|
|
522
|
+
legal = ", ".join(r.value for r in Role if r is not Role.SYSTEM)
|
|
523
|
+
typer.echo(f"{role!r} is not a role here. Interviewable: {legal}", err=True)
|
|
524
|
+
raise typer.Exit(1) from exc
|
|
525
|
+
respondent = Respondent(role=parsed_role, name=name)
|
|
526
|
+
|
|
527
|
+
if scope:
|
|
528
|
+
from fde.models.schema import Scope
|
|
529
|
+
|
|
530
|
+
legal = [s.value for s in Scope]
|
|
531
|
+
if scope not in legal:
|
|
532
|
+
typer.echo(f"{scope!r} is not a scope axis. One of: {', '.join(legal)}",
|
|
533
|
+
err=True)
|
|
534
|
+
raise typer.Exit(1)
|
|
535
|
+
|
|
536
|
+
space = Space.from_registry(registry).apply(engagement.profile)
|
|
537
|
+
profile = engagement.profile
|
|
538
|
+
gathered: list[Fact] = []
|
|
539
|
+
|
|
540
|
+
# Declining to answer means "not from me, not now" -- never "this can never
|
|
541
|
+
# be known". Held beside the space rather than written into it, so a later
|
|
542
|
+
# answer can still settle it by cascade.
|
|
543
|
+
passed_on: set[str] = set()
|
|
544
|
+
|
|
545
|
+
while question := _next(space, profile, registry, role, passed_on, scope):
|
|
546
|
+
if question.contest_of:
|
|
547
|
+
typer.echo(f"\n {question.contest_of} -- confirm, correct, or skip.")
|
|
548
|
+
answer = _put(registry.dimensions[question.resolves], question)
|
|
549
|
+
if answer is None:
|
|
550
|
+
break # end of input: keep what we have
|
|
551
|
+
if question.contest_of:
|
|
552
|
+
# Asked and answered, either way: a confirmation must retire the
|
|
553
|
+
# question, or the same prompt is re-offered the moment the loop
|
|
554
|
+
# comes round -- the holder has not changed.
|
|
555
|
+
passed_on.add(question.resolves)
|
|
556
|
+
if answer.skipped:
|
|
557
|
+
passed_on.add(question.resolves)
|
|
558
|
+
continue
|
|
559
|
+
|
|
560
|
+
dimension_entry = registry.dimensions[question.resolves]
|
|
561
|
+
answered_values = (
|
|
562
|
+
list(answer.value) if isinstance(answer.value, tuple) else [answer.value]
|
|
563
|
+
)
|
|
564
|
+
new_facts = [
|
|
565
|
+
Fact(
|
|
566
|
+
question.resolves,
|
|
567
|
+
value,
|
|
568
|
+
Provenance.INTERVIEW,
|
|
569
|
+
kind=dimension_entry.kind,
|
|
570
|
+
# Stamped now, not only when the session is written: the live
|
|
571
|
+
# profile drives the contest offers, and a fact with no speaker
|
|
572
|
+
# was offered back to its own speaker as "system said X".
|
|
573
|
+
respondent=respondent,
|
|
574
|
+
additive=dimension_entry.multi_valued,
|
|
575
|
+
)
|
|
576
|
+
for value in answered_values
|
|
577
|
+
]
|
|
578
|
+
try:
|
|
579
|
+
# A contested dimension stays out of the space: the space would
|
|
580
|
+
# call the second answer a contradiction, but two people
|
|
581
|
+
# differing is a finding, and the profile records it as one.
|
|
582
|
+
if (question.resolves in space.dimensions() and not question.contest_of
|
|
583
|
+
and not isinstance(answer.value, tuple)
|
|
584
|
+
and not dimension_entry.multi_valued):
|
|
585
|
+
space = space.answer(question.resolves, answer.value)
|
|
586
|
+
except Contradiction as exc:
|
|
587
|
+
typer.echo(f" that conflicts: {exc}")
|
|
588
|
+
continue
|
|
589
|
+
|
|
590
|
+
if question.contest_of:
|
|
591
|
+
_warn_if_impossible(question.resolves, answer.value, profile, registry)
|
|
592
|
+
|
|
593
|
+
gathered.extend(new_facts)
|
|
594
|
+
for new_fact in new_facts:
|
|
595
|
+
profile = _with(profile, new_fact)
|
|
596
|
+
|
|
597
|
+
if not gathered:
|
|
598
|
+
typer.echo("Nothing recorded.")
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
engagement.append(
|
|
602
|
+
Session(
|
|
603
|
+
session_id=_next_session_id(engagement, role),
|
|
604
|
+
respondent=respondent,
|
|
605
|
+
facts=gathered,
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
typer.echo(f"\nRecorded {len(gathered)} answer(s) from {respondent}.")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _with_all(profile, facts):
|
|
612
|
+
for fact in facts:
|
|
613
|
+
profile = _with(profile, fact)
|
|
614
|
+
return profile
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _warn_if_impossible(dimension, value, profile, registry):
|
|
618
|
+
"""A contested answer bypasses the space on purpose -- two people
|
|
619
|
+
differing is a finding. But a contesting value the rest of this
|
|
620
|
+
engagement's own answers rule out is not a difference of view, it is a
|
|
621
|
+
contradiction wearing one, and recording it silently lets a physically
|
|
622
|
+
impossible option stand as an open question."""
|
|
623
|
+
probe_profile = Profile()
|
|
624
|
+
probe_profile.ingest([
|
|
625
|
+
f
|
|
626
|
+
for d in profile.dimensions()
|
|
627
|
+
for f in profile.history(d)
|
|
628
|
+
if d != dimension
|
|
629
|
+
])
|
|
630
|
+
probe = Space.from_registry(registry).apply(probe_profile)
|
|
631
|
+
if dimension in probe.dimensions() and value not in probe.surviving(dimension):
|
|
632
|
+
typer.echo(
|
|
633
|
+
f" recorded as disagreement -- but note: {value!r} is ruled out "
|
|
634
|
+
f"by other answers in this engagement, so one side of this "
|
|
635
|
+
f"disagreement is a contradiction, not a viewpoint."
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _put(dimension, question):
|
|
640
|
+
"""Ask until the answer is usable, or the person declines to give one.
|
|
641
|
+
|
|
642
|
+
End of input ends the interview rather than aborting it: whatever was
|
|
643
|
+
gathered up to that point is still worth recording.
|
|
644
|
+
"""
|
|
645
|
+
while True:
|
|
646
|
+
try:
|
|
647
|
+
reply = typer.prompt(f"\n{question.asks}", default="", show_default=False)
|
|
648
|
+
except (EOFError, typer.Abort):
|
|
649
|
+
return None
|
|
650
|
+
answer = parse_answer(dimension, reply)
|
|
651
|
+
if answer.usable or answer.skipped:
|
|
652
|
+
return answer
|
|
653
|
+
typer.echo(f" {answer.probe}")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _next(space, profile, registry, role, passed_on, scope=None):
|
|
657
|
+
"""The next question this person has not already declined."""
|
|
658
|
+
for question in remaining_questions(space, profile, registry, role=role, scope=scope):
|
|
659
|
+
if question.resolves not in passed_on:
|
|
660
|
+
return question
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _with(profile: Profile, fact: Fact) -> Profile:
|
|
665
|
+
fresh = Profile()
|
|
666
|
+
for dimension in profile.dimensions():
|
|
667
|
+
fresh.ingest(profile.history(dimension))
|
|
668
|
+
fresh.ingest([fact])
|
|
669
|
+
return fresh
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _next_session_id(engagement, label: str) -> str:
|
|
673
|
+
existing = len(list(engagement.facts_dir.glob("*.yaml")))
|
|
674
|
+
return f"{existing + 1:04d}-{label}"
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _says_something(text: str | None) -> bool:
|
|
678
|
+
"""Whether this is a sentence or an empty gesture.
|
|
679
|
+
|
|
680
|
+
str.strip() removes ASCII whitespace and nothing else, so a zero-width
|
|
681
|
+
space passes it -- which was enough to satisfy the one gate the
|
|
682
|
+
framework says cannot be worked around.
|
|
683
|
+
"""
|
|
684
|
+
return bool(text) and any(ch.isalnum() for ch in text)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _registry(root: Path):
|
|
688
|
+
"""Load the registry or say plainly why not.
|
|
689
|
+
|
|
690
|
+
Engagement commands hit this from any working directory; the default
|
|
691
|
+
root is relative, so the classic failure is running from the wrong one
|
|
692
|
+
-- which deserves the one-line answer, not a stack trace.
|
|
693
|
+
"""
|
|
694
|
+
try:
|
|
695
|
+
registry = load_registry(root)
|
|
696
|
+
except RegistryError as exc:
|
|
697
|
+
typer.echo(str(exc), err=True)
|
|
698
|
+
raise typer.Exit(1) from exc
|
|
699
|
+
if is_empty(registry):
|
|
700
|
+
# An entry-less directory is the wrong directory, not a partial
|
|
701
|
+
# registry. Deciding from one produces an architecture of nothing,
|
|
702
|
+
# and retro would rewrite a captured case with it.
|
|
703
|
+
typer.echo(
|
|
704
|
+
f"{root}: no registry entries here, so nothing can be decided "
|
|
705
|
+
f"from it. Point --registry at a registry.", err=True,
|
|
706
|
+
)
|
|
707
|
+
raise typer.Exit(1)
|
|
708
|
+
return registry
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _engagement(root: Path):
|
|
712
|
+
"""Load an engagement or say plainly why not.
|
|
713
|
+
|
|
714
|
+
Every command goes through here: a missing directory or a corrupt session
|
|
715
|
+
file is a one-line explanation, never a traceback -- a stack trace at a
|
|
716
|
+
client site reads as the tool being broken rather than the input.
|
|
717
|
+
"""
|
|
718
|
+
try:
|
|
719
|
+
engagement = load_engagement(root)
|
|
720
|
+
# Read once here so a hand-edited gates.yaml fails as a sentence
|
|
721
|
+
# from whichever command touched it, not as a TypeError deep in the
|
|
722
|
+
# gate logic.
|
|
723
|
+
engagement.gate_state()
|
|
724
|
+
return engagement
|
|
725
|
+
except FileNotFoundError as exc:
|
|
726
|
+
typer.echo(str(exc), err=True)
|
|
727
|
+
raise typer.Exit(1) from exc
|
|
728
|
+
except ValueError as exc:
|
|
729
|
+
typer.echo(f"cannot read the engagement: {exc}", err=True)
|
|
730
|
+
raise typer.Exit(1) from exc
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _reuse(engagement) -> set[str]:
|
|
734
|
+
"""Stacks the client already operates, recorded by `fde reuse`.
|
|
735
|
+
|
|
736
|
+
Reuse beats adoption: a tool somebody already patches and pages for is
|
|
737
|
+
cheaper than the same capability standing beside it. This is the file
|
|
738
|
+
that finally feeds that rule -- the mechanism existed from the start
|
|
739
|
+
and nothing on the user's side could reach it.
|
|
740
|
+
"""
|
|
741
|
+
marker = engagement.root / "reuse"
|
|
742
|
+
if not marker.exists():
|
|
743
|
+
return set()
|
|
744
|
+
return {line.strip() for line in marker.read_text().splitlines() if line.strip()}
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _overrides(engagement) -> dict[str, dict]:
|
|
748
|
+
"""Recorded overrides, last one per component winning.
|
|
749
|
+
|
|
750
|
+
Read wherever an architecture is built, because an override recorded and
|
|
751
|
+
then ignored breaks the promise made when it was recorded.
|
|
752
|
+
"""
|
|
753
|
+
path = engagement.root / "overrides.jsonl"
|
|
754
|
+
if not path.exists():
|
|
755
|
+
return {}
|
|
756
|
+
out: dict[str, dict] = {}
|
|
757
|
+
for line in path.read_text().splitlines():
|
|
758
|
+
if not line.strip():
|
|
759
|
+
continue
|
|
760
|
+
try:
|
|
761
|
+
record = json.loads(line)
|
|
762
|
+
except json.JSONDecodeError:
|
|
763
|
+
continue
|
|
764
|
+
if record.get("component") and record.get("chosen"):
|
|
765
|
+
out[record["component"]] = record
|
|
766
|
+
return out
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _gate_status(engagement, registry=None):
|
|
770
|
+
"""The gates, judged against everything the engagement has recorded.
|
|
771
|
+
|
|
772
|
+
Waivers stored on disk are re-applied here rather than baked into the
|
|
773
|
+
verdict, so a hand-edited waiver of the hard gate simply does not take:
|
|
774
|
+
the gate stays in blocked_by, visibly, instead of quietly vanishing.
|
|
775
|
+
"""
|
|
776
|
+
state = engagement.gate_state()
|
|
777
|
+
licences = None
|
|
778
|
+
if registry is not None:
|
|
779
|
+
# The architecture as it would build now, overrides included --
|
|
780
|
+
# the licence gate judges the combination, and only a built set of
|
|
781
|
+
# realizations knows the combination.
|
|
782
|
+
licences = build_architecture(
|
|
783
|
+
engagement.profile, registry, overrides=_overrides(engagement),
|
|
784
|
+
already_running=_reuse(engagement),
|
|
785
|
+
).licences
|
|
786
|
+
status = input_status(
|
|
787
|
+
engagement.profile,
|
|
788
|
+
baseline=engagement.baseline(),
|
|
789
|
+
data_access=bool(state.get("data_access")),
|
|
790
|
+
security_review=bool(state.get("security_review")),
|
|
791
|
+
registry=registry,
|
|
792
|
+
licences=licences,
|
|
793
|
+
original_statement=(
|
|
794
|
+
engagement.original_statement().text if engagement.original_statement() else None
|
|
795
|
+
),
|
|
796
|
+
current_statement=(
|
|
797
|
+
engagement.current_statement().text if engagement.current_statement() else None
|
|
798
|
+
),
|
|
799
|
+
)
|
|
800
|
+
for waiver in state.get("overrides", []):
|
|
801
|
+
try:
|
|
802
|
+
status.override(
|
|
803
|
+
waiver["gate"], waiver["reason"], against=waiver["against"]
|
|
804
|
+
)
|
|
805
|
+
except (HardGate, ValueError, StopIteration):
|
|
806
|
+
# A waiver that cannot be applied is simply not applied: the
|
|
807
|
+
# gate stays standing, visibly, rather than vanishing.
|
|
808
|
+
continue
|
|
809
|
+
return status
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _refuse_if_blocked(engagement, registry=None, *, warn_only: bool = False) -> None:
|
|
813
|
+
status = _gate_status(engagement, registry)
|
|
814
|
+
blocking = status.blocked_by()
|
|
815
|
+
if not blocking:
|
|
816
|
+
return
|
|
817
|
+
for name in blocking:
|
|
818
|
+
gate = status.gate(name)
|
|
819
|
+
mark = "[hard] " if gate.hard else ""
|
|
820
|
+
typer.echo(f" {mark}{name}: {gate.reason}", err=True)
|
|
821
|
+
if gate.remedy:
|
|
822
|
+
typer.echo(f" -> {gate.remedy}", err=True)
|
|
823
|
+
if warn_only:
|
|
824
|
+
typer.echo(
|
|
825
|
+
"\nproceeding anyway -- a design is thinking, not a deliverable. "
|
|
826
|
+
"`fde build` will refuse until these clear.\n", err=True,
|
|
827
|
+
)
|
|
828
|
+
return
|
|
829
|
+
typer.echo(
|
|
830
|
+
"\nrefused: gates above are unsatisfied. Soft gates take "
|
|
831
|
+
"`fde waive <gate> --reason`; data access has no workaround, only "
|
|
832
|
+
"credentials that return real rows.", err=True,
|
|
833
|
+
)
|
|
834
|
+
raise typer.Exit(1)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _write_compliance(out: Path, locale) -> None:
|
|
838
|
+
"""The jurisdiction's demands, as a checklist with a date.
|
|
839
|
+
|
|
840
|
+
Produce-and-verify items, never rules: the framework decided the
|
|
841
|
+
architecture the same way it would anywhere, and this page says what
|
|
842
|
+
this place additionally requires the engagement to produce.
|
|
843
|
+
"""
|
|
844
|
+
lines = [
|
|
845
|
+
f"# Compliance obligations -- {locale.name}",
|
|
846
|
+
"",
|
|
847
|
+
f"As of {locale.as_of or 'undated'}. Law churns like stacks do: verify "
|
|
848
|
+
f"each item with counsel before relying on it, and re-date this page "
|
|
849
|
+
f"when you do.",
|
|
850
|
+
"",
|
|
851
|
+
]
|
|
852
|
+
for obligation in locale.obligations:
|
|
853
|
+
lines.append(f"## {obligation.id}")
|
|
854
|
+
lines.append("")
|
|
855
|
+
lines.append(obligation.produce)
|
|
856
|
+
if obligation.verify:
|
|
857
|
+
lines.append("")
|
|
858
|
+
lines.append(f"*Verify:* {obligation.verify}")
|
|
859
|
+
lines.append("")
|
|
860
|
+
(out / "COMPLIANCE.md").write_text("\n".join(lines) + "\n")
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
@app.command("baseline")
|
|
864
|
+
def baseline_cmd(
|
|
865
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
866
|
+
file: Annotated[Path, typer.Option(help="A YAML file of the measured fields.")],
|
|
867
|
+
) -> None:
|
|
868
|
+
"""Record the measured baseline: seven fields, sampled, with definitions.
|
|
869
|
+
|
|
870
|
+
Stored even when incomplete -- a partial baseline is honest state, and the
|
|
871
|
+
gate will say exactly what it still lacks.
|
|
872
|
+
"""
|
|
873
|
+
engagement = _engagement(root)
|
|
874
|
+
try:
|
|
875
|
+
fields = yaml.safe_load(file.read_text()) or {}
|
|
876
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
877
|
+
typer.echo(f"cannot read {file}: {exc}", err=True)
|
|
878
|
+
raise typer.Exit(1) from exc
|
|
879
|
+
if not isinstance(fields, dict):
|
|
880
|
+
typer.echo(f"{file}: expected a mapping of field to value", err=True)
|
|
881
|
+
raise typer.Exit(1)
|
|
882
|
+
|
|
883
|
+
engagement.record_baseline(fields)
|
|
884
|
+
result = validate_baseline(fields)
|
|
885
|
+
if result.ok:
|
|
886
|
+
typer.echo("baseline recorded -- re-measurable, sampled, complete")
|
|
887
|
+
else:
|
|
888
|
+
typer.echo(f"recorded, but not yet a baseline: {result.reason}")
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
@app.command("data-access")
|
|
892
|
+
def data_access_cmd(
|
|
893
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
894
|
+
note: Annotated[str, typer.Option(
|
|
895
|
+
help="What was connected to and what came back. Promised access is not access."
|
|
896
|
+
)],
|
|
897
|
+
) -> None:
|
|
898
|
+
"""Attest that credentials returned real data.
|
|
899
|
+
|
|
900
|
+
The note is the evidence: name the system and what it returned. An
|
|
901
|
+
attestation without one is a promise, and the gate exists because promises
|
|
902
|
+
are what cost three weeks.
|
|
903
|
+
"""
|
|
904
|
+
if not _says_something(note):
|
|
905
|
+
typer.echo(
|
|
906
|
+
"the note is the evidence -- say what returned real rows. "
|
|
907
|
+
"(Invisible characters are not a note; str.strip() does not "
|
|
908
|
+
"remove them, so this is checked properly.)", err=True,
|
|
909
|
+
)
|
|
910
|
+
raise typer.Exit(1)
|
|
911
|
+
engagement = _engagement(root)
|
|
912
|
+
engagement.record_data_access(note=note, at=date.today().isoformat())
|
|
913
|
+
typer.echo("data access recorded")
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
@app.command("security-review")
|
|
917
|
+
def security_review_cmd(
|
|
918
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
919
|
+
note: Annotated[str, typer.Option(
|
|
920
|
+
help="Who reviewed it and what they looked at. A meeting that is "
|
|
921
|
+
"scheduled is not a review that happened."
|
|
922
|
+
)],
|
|
923
|
+
) -> None:
|
|
924
|
+
"""Record that the client's security function reviewed the design.
|
|
925
|
+
|
|
926
|
+
Fires only for systems living inside the client's environment or touching
|
|
927
|
+
their systems -- exactly the ones their InfoSec has jurisdiction over, and
|
|
928
|
+
exactly the ones stopped at the door when nobody scheduled the review.
|
|
929
|
+
"""
|
|
930
|
+
if not _says_something(note):
|
|
931
|
+
typer.echo(
|
|
932
|
+
"the note is the evidence -- name who reviewed it and what they "
|
|
933
|
+
"looked at.", err=True,
|
|
934
|
+
)
|
|
935
|
+
raise typer.Exit(1)
|
|
936
|
+
engagement = _engagement(root)
|
|
937
|
+
engagement.record_security_review(note=note, at=date.today().isoformat())
|
|
938
|
+
typer.echo("security review recorded")
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
@app.command("waive")
|
|
942
|
+
def waive_cmd(
|
|
943
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
944
|
+
gate: Annotated[str, typer.Argument(help="Which gate to wave through.")],
|
|
945
|
+
reason: Annotated[str, typer.Option(help="Why. Lands in the risk section.")],
|
|
946
|
+
) -> None:
|
|
947
|
+
"""Override a soft gate, with the reason recorded.
|
|
948
|
+
|
|
949
|
+
You are on site and can see things a checklist cannot. The hard gate is the
|
|
950
|
+
exception: nothing can waive absent credentials.
|
|
951
|
+
"""
|
|
952
|
+
engagement = _engagement(root)
|
|
953
|
+
status = _gate_status(engagement)
|
|
954
|
+
try:
|
|
955
|
+
against = status.gate(gate).reason
|
|
956
|
+
status.override(gate, reason)
|
|
957
|
+
except HardGate as exc:
|
|
958
|
+
typer.echo(str(exc), err=True)
|
|
959
|
+
raise typer.Exit(1) from exc
|
|
960
|
+
except ValueError as exc:
|
|
961
|
+
typer.echo(str(exc), err=True)
|
|
962
|
+
raise typer.Exit(1) from exc
|
|
963
|
+
except StopIteration:
|
|
964
|
+
names = ", ".join(g.name for g in status.gates)
|
|
965
|
+
typer.echo(f"no gate named {gate!r}. The gates: {names}", err=True)
|
|
966
|
+
raise typer.Exit(1) from None
|
|
967
|
+
|
|
968
|
+
engagement.record_waiver(
|
|
969
|
+
gate=gate, reason=reason, at=date.today().isoformat(), against=against
|
|
970
|
+
)
|
|
971
|
+
typer.echo(f"waived {gate} -- recorded, and carried into the project's RISKS.md")
|
|
972
|
+
typer.echo(f" covers: {against}")
|
|
973
|
+
typer.echo(" if this gate blocks for a different reason later, it blocks again")
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
@app.command("restate")
|
|
977
|
+
def restate_cmd(
|
|
978
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
979
|
+
text: Annotated[str | None, typer.Option(help="The problem as now stated.")] = None,
|
|
980
|
+
file: Annotated[Path | None, typer.Option(help="Or a file holding it.")] = None,
|
|
981
|
+
reason: Annotated[str, typer.Option(help="What changed and why.")] = "",
|
|
982
|
+
) -> None:
|
|
983
|
+
"""Record a new version of the problem statement.
|
|
984
|
+
|
|
985
|
+
Version 1 is never edited; drift is measured against it. Restating is how
|
|
986
|
+
the scope-drift gate gets something real to measure.
|
|
987
|
+
"""
|
|
988
|
+
if not text and not file:
|
|
989
|
+
typer.echo("give --text or --file", err=True)
|
|
990
|
+
raise typer.Exit(1)
|
|
991
|
+
if not reason.strip():
|
|
992
|
+
typer.echo(
|
|
993
|
+
"a restatement needs --reason: scope that moves without one is "
|
|
994
|
+
"drift by definition", err=True,
|
|
995
|
+
)
|
|
996
|
+
raise typer.Exit(1)
|
|
997
|
+
|
|
998
|
+
engagement = _engagement(root)
|
|
999
|
+
body = text or file.read_text()
|
|
1000
|
+
engagement.revise_statement(body.strip(), reason=reason)
|
|
1001
|
+
typer.echo(
|
|
1002
|
+
f"statement v{len(engagement.statements)} recorded -- drift is still "
|
|
1003
|
+
f"measured against v1"
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
@app.command("reuse")
|
|
1008
|
+
def reuse_cmd(
|
|
1009
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
1010
|
+
stacks: Annotated[list[str], typer.Argument(help="Stack ids the client already runs.")],
|
|
1011
|
+
registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
|
|
1012
|
+
) -> None:
|
|
1013
|
+
"""Record what the client already operates, so reuse can beat adoption.
|
|
1014
|
+
|
|
1015
|
+
A stack somebody already patches, backs up and pages for is cheaper than
|
|
1016
|
+
the same capability standing beside it -- the tenth workload on it costs
|
|
1017
|
+
almost nothing. Realization prefers these over anything newly adopted.
|
|
1018
|
+
"""
|
|
1019
|
+
registry = _registry(registry_root)
|
|
1020
|
+
unknown = [s for s in stacks if s not in registry.stacks]
|
|
1021
|
+
if unknown:
|
|
1022
|
+
typer.echo(
|
|
1023
|
+
f"not stacks in this registry: {', '.join(unknown)}. Known: "
|
|
1024
|
+
f"{', '.join(sorted(registry.stacks))}", err=True,
|
|
1025
|
+
)
|
|
1026
|
+
raise typer.Exit(1)
|
|
1027
|
+
|
|
1028
|
+
engagement = _engagement(root)
|
|
1029
|
+
running = sorted(_reuse(engagement) | set(stacks))
|
|
1030
|
+
(engagement.root / "reuse").write_text("\n".join(running) + "\n")
|
|
1031
|
+
typer.echo(f"recorded as already running: {', '.join(running)}")
|
|
1032
|
+
typer.echo(" realization will prefer these wherever a pattern offers them")
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
@app.command("locale")
|
|
1036
|
+
def locale_cmd(
|
|
1037
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
1038
|
+
locale_id: Annotated[str, typer.Argument(help="A locale pack from the registry.")],
|
|
1039
|
+
registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
|
|
1040
|
+
) -> None:
|
|
1041
|
+
"""Apply a jurisdiction pack: presets at the weakest provenance, and
|
|
1042
|
+
obligations the build will carry into COMPLIANCE.md.
|
|
1043
|
+
|
|
1044
|
+
Presets are INFERRED, so anything anybody actually says outranks them --
|
|
1045
|
+
geography seeds answers, it never overrides people.
|
|
1046
|
+
"""
|
|
1047
|
+
registry = _registry(registry_root)
|
|
1048
|
+
locale = registry.locales.get(locale_id)
|
|
1049
|
+
if locale is None:
|
|
1050
|
+
known = ", ".join(sorted(registry.locales)) or "none in this registry"
|
|
1051
|
+
typer.echo(f"{locale_id!r} is not a locale pack. Available: {known}", err=True)
|
|
1052
|
+
raise typer.Exit(1)
|
|
1053
|
+
|
|
1054
|
+
engagement = _engagement(root)
|
|
1055
|
+
facts = [
|
|
1056
|
+
Fact(dimension, value, Provenance.INFERRED, source=f"locale:{locale_id}")
|
|
1057
|
+
for dimension, value in locale.presets.items()
|
|
1058
|
+
]
|
|
1059
|
+
if facts:
|
|
1060
|
+
engagement.append(
|
|
1061
|
+
Session(
|
|
1062
|
+
session_id=_next_session_id(engagement, f"locale-{locale_id}"),
|
|
1063
|
+
respondent=Respondent(role=Role.SYSTEM),
|
|
1064
|
+
facts=facts,
|
|
1065
|
+
)
|
|
1066
|
+
)
|
|
1067
|
+
(engagement.root / "locale").write_text(locale_id + "\n")
|
|
1068
|
+
|
|
1069
|
+
typer.echo(f"applied {locale.name} ({locale_id}), as of {locale.as_of or 'undated'}")
|
|
1070
|
+
if facts:
|
|
1071
|
+
typer.echo(f" presets ({len(facts)}, weakest provenance -- any stated "
|
|
1072
|
+
f"answer outranks them):")
|
|
1073
|
+
for fact in facts:
|
|
1074
|
+
typer.echo(f" {fact.dimension} = {fact.value}")
|
|
1075
|
+
typer.echo(f" obligations carried into the build: {len(locale.obligations)}")
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
@app.command("architect")
|
|
1079
|
+
def architect_cmd(
|
|
1080
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
1081
|
+
registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
|
|
1082
|
+
) -> None:
|
|
1083
|
+
"""Decide the design, and say what is still open."""
|
|
1084
|
+
registry = _registry(registry_root)
|
|
1085
|
+
engagement = _engagement(root)
|
|
1086
|
+
_refuse_if_blocked(engagement, registry, warn_only=True)
|
|
1087
|
+
overrides = _overrides(engagement)
|
|
1088
|
+
architecture = build_architecture(
|
|
1089
|
+
engagement.profile, registry, overrides=overrides,
|
|
1090
|
+
already_running=_reuse(engagement),
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
typer.echo(f"topology {architecture.topology} [{architecture.fingerprint()}]\n")
|
|
1094
|
+
for component, decision in sorted(architecture.decisions.decided().items()):
|
|
1095
|
+
realization = architecture.realizations.get(component)
|
|
1096
|
+
via = f" via {realization.stack}" if realization else ""
|
|
1097
|
+
mark = " [overridden]" if component in overrides else ""
|
|
1098
|
+
typer.echo(f" {component:16} {decision.approach}{via}{mark}")
|
|
1099
|
+
|
|
1100
|
+
if architecture.decisions.undecided():
|
|
1101
|
+
typer.echo(f"\nnot decided: {', '.join(architecture.decisions.undecided())}")
|
|
1102
|
+
if architecture.disagreements:
|
|
1103
|
+
typer.echo(f"\nunresolved: {', '.join(d.dimension for d in architecture.disagreements)}")
|
|
1104
|
+
if architecture.copyleft_licences:
|
|
1105
|
+
typer.echo(f"\ncopyleft: {', '.join(architecture.copyleft_licences)}")
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
@app.command("override")
|
|
1109
|
+
def override_cmd(
|
|
1110
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
1111
|
+
component: Annotated[str, typer.Option(help="Which component.")],
|
|
1112
|
+
choose: Annotated[str, typer.Option(help="What to use instead.")],
|
|
1113
|
+
because: Annotated[str, typer.Option(help="Why. Recorded, never argued with.")],
|
|
1114
|
+
registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
|
|
1115
|
+
) -> None:
|
|
1116
|
+
"""Choose differently from the recommendation.
|
|
1117
|
+
|
|
1118
|
+
Never warns and never blocks. You are on site and know things the rules do
|
|
1119
|
+
not -- what is recorded is which rule was overridden, because that is the
|
|
1120
|
+
signal, and arguing with you would teach the framework nothing.
|
|
1121
|
+
"""
|
|
1122
|
+
registry = _registry(registry_root)
|
|
1123
|
+
engagement = _engagement(root)
|
|
1124
|
+
from fde.decide import base_component
|
|
1125
|
+
|
|
1126
|
+
if base_component(component) not in registry.components:
|
|
1127
|
+
typer.echo(
|
|
1128
|
+
f"{component!r} is not a component in this registry. Components: "
|
|
1129
|
+
f"{', '.join(sorted(registry.components))} (an instance of a "
|
|
1130
|
+
f"fanned component works too, e.g. perception:images)", err=True,
|
|
1131
|
+
)
|
|
1132
|
+
raise typer.Exit(1)
|
|
1133
|
+
if choose not in registry.approaches:
|
|
1134
|
+
# A typo here silently turned a working component into one that
|
|
1135
|
+
# raises, and reported success at every step.
|
|
1136
|
+
for_component = sorted(
|
|
1137
|
+
a.id for a in registry.approaches.values()
|
|
1138
|
+
if not a.components or component in a.components
|
|
1139
|
+
)
|
|
1140
|
+
typer.echo(
|
|
1141
|
+
f"{choose!r} is not an approach in this registry. For "
|
|
1142
|
+
f"{component}: {', '.join(for_component) or 'nothing registered'}",
|
|
1143
|
+
err=True,
|
|
1144
|
+
)
|
|
1145
|
+
raise typer.Exit(1)
|
|
1146
|
+
chosen_serves = registry.approaches[choose].components
|
|
1147
|
+
if chosen_serves and component not in chosen_serves:
|
|
1148
|
+
# A real approach for the wrong slot is the same silent breakage as
|
|
1149
|
+
# a typo: the component becomes unrealizable with a success message.
|
|
1150
|
+
for_component = sorted(
|
|
1151
|
+
a.id for a in registry.approaches.values()
|
|
1152
|
+
if not a.components or component in a.components
|
|
1153
|
+
)
|
|
1154
|
+
typer.echo(
|
|
1155
|
+
f"{choose!r} serves {', '.join(chosen_serves)}, not {component}. "
|
|
1156
|
+
f"For {component}: {', '.join(for_component) or 'nothing registered'}",
|
|
1157
|
+
err=True,
|
|
1158
|
+
)
|
|
1159
|
+
raise typer.Exit(1)
|
|
1160
|
+
|
|
1161
|
+
# Against live state, overrides included: computing "what was
|
|
1162
|
+
# recommended" from a world where earlier overrides do not exist files a
|
|
1163
|
+
# revert as an override of the rule it agrees with.
|
|
1164
|
+
existing = _overrides(engagement)
|
|
1165
|
+
architecture = build_architecture(engagement.profile, registry, overrides=existing,
|
|
1166
|
+
already_running=_reuse(engagement))
|
|
1167
|
+
|
|
1168
|
+
decision = architecture.decisions.get(component)
|
|
1169
|
+
recommended = decision.approach if decision else None
|
|
1170
|
+
|
|
1171
|
+
# Conflicts come from what the registry declares, not from a list kept
|
|
1172
|
+
# here: the chosen approach's own avoid_when conditions, evaluated
|
|
1173
|
+
# against this profile. A new rule in the registry is flagged without a
|
|
1174
|
+
# code change.
|
|
1175
|
+
conflicts = []
|
|
1176
|
+
chosen_entry = registry.approaches.get(choose)
|
|
1177
|
+
if chosen_entry:
|
|
1178
|
+
for predicate in chosen_entry.avoid_when:
|
|
1179
|
+
try:
|
|
1180
|
+
if holds(predicate, engagement.profile, registry):
|
|
1181
|
+
conflicts.append(predicate)
|
|
1182
|
+
except PredicateError as exc:
|
|
1183
|
+
typer.echo(f" cannot evaluate {predicate!r}: {exc}", err=True)
|
|
1184
|
+
|
|
1185
|
+
record = Override(
|
|
1186
|
+
component=component, recommended=recommended or "nothing", chosen=choose,
|
|
1187
|
+
because=because, overrode_rule=recommended or "none", conflicts_with=conflicts,
|
|
1188
|
+
)
|
|
1189
|
+
# No pseudo-dimension fact. overrides.jsonl is the record -- append-only,
|
|
1190
|
+
# carrying the reason and what it overrode -- and `override.<component>`
|
|
1191
|
+
# in the fact log put a non-dimension beside real answers in `status`
|
|
1192
|
+
# and in the profile a future corpus would match engagements against.
|
|
1193
|
+
with (engagement.root / "overrides.jsonl").open("a") as handle:
|
|
1194
|
+
handle.write(json.dumps(record.__dict__) + "\n")
|
|
1195
|
+
|
|
1196
|
+
if recommended:
|
|
1197
|
+
typer.echo(f"recorded: {component} {recommended} -> {choose}")
|
|
1198
|
+
typer.echo(" honoured: `fde architect` and `fde build` now use your choice")
|
|
1199
|
+
else:
|
|
1200
|
+
# Nothing was recommended, so nothing was overridden. Still worth
|
|
1201
|
+
# recording: a component chosen where the framework had no opinion is
|
|
1202
|
+
# a gap in the corpus, not a disagreement with it.
|
|
1203
|
+
typer.echo(
|
|
1204
|
+
f"recorded: {component} -> {choose}\n"
|
|
1205
|
+
f" nothing was recommended here, so this is a gap in the corpus "
|
|
1206
|
+
f"rather than a disagreement with it"
|
|
1207
|
+
)
|
|
1208
|
+
if conflicts:
|
|
1209
|
+
# Flagged, not refused. It goes in the risk section rather than in the
|
|
1210
|
+
# way.
|
|
1211
|
+
typer.echo(f" conflicts with {', '.join(conflicts)} -- noted in the risks")
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
@app.command("observe")
|
|
1215
|
+
def observe_cmd(
|
|
1216
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
1217
|
+
trigger: Annotated[str, typer.Option(help="Which trigger fired, e.g. serving.graduate.")],
|
|
1218
|
+
measured: Annotated[list[str] | None, typer.Option(
|
|
1219
|
+
help="What was measured, as key=value. Repeatable."
|
|
1220
|
+
)] = None,
|
|
1221
|
+
today: Annotated[str, typer.Option(help="When it fired, for reproducibility.")] = "",
|
|
1222
|
+
) -> None:
|
|
1223
|
+
"""Record that a predicted trigger actually fired.
|
|
1224
|
+
|
|
1225
|
+
Trigger calibration is the strongest signal the framework collects,
|
|
1226
|
+
precisely because there is no counterfactual -- a trigger fired when
|
|
1227
|
+
predicted or it did not, and both are observable. But only if somebody
|
|
1228
|
+
writes the firing down.
|
|
1229
|
+
"""
|
|
1230
|
+
engagement = _engagement(root)
|
|
1231
|
+
|
|
1232
|
+
stamp = today or date.today().isoformat()
|
|
1233
|
+
try:
|
|
1234
|
+
date.fromisoformat(stamp)
|
|
1235
|
+
except ValueError as exc:
|
|
1236
|
+
# Written unchecked, this lands in an append-only log and every later
|
|
1237
|
+
# retro dies on it, with no repair command.
|
|
1238
|
+
typer.echo(f"--today {stamp!r} is not a date (YYYY-MM-DD): {exc}", err=True)
|
|
1239
|
+
raise typer.Exit(1) from exc
|
|
1240
|
+
|
|
1241
|
+
values = {}
|
|
1242
|
+
for item in measured or []:
|
|
1243
|
+
key, sep, value = item.partition("=")
|
|
1244
|
+
if not sep or not key.strip() or not value.strip():
|
|
1245
|
+
typer.echo(
|
|
1246
|
+
f"--measured {item!r} is not key=value with both halves. A "
|
|
1247
|
+
f"measurement dropped silently is worse than one refused.", err=True,
|
|
1248
|
+
)
|
|
1249
|
+
raise typer.Exit(1)
|
|
1250
|
+
values[key.strip()] = value.strip()
|
|
1251
|
+
|
|
1252
|
+
# Warned, not refused: build may not have run yet. But a misspelled
|
|
1253
|
+
# trigger that is stored and then silently never counted is the shape of
|
|
1254
|
+
# a signal nobody knows they lost.
|
|
1255
|
+
predicted = {p["trigger"] for p in _jsonl(engagement.root / "predictions.jsonl")}
|
|
1256
|
+
if predicted and trigger not in predicted:
|
|
1257
|
+
typer.echo(
|
|
1258
|
+
f"warning: {trigger!r} was never predicted here, so it will not "
|
|
1259
|
+
f"be counted. Predicted: {', '.join(sorted(predicted)) or 'nothing'}",
|
|
1260
|
+
err=True,
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
record = {
|
|
1264
|
+
"trigger": trigger,
|
|
1265
|
+
"observed_at": stamp,
|
|
1266
|
+
"measured": values,
|
|
1267
|
+
}
|
|
1268
|
+
with (engagement.root / "observations.jsonl").open("a") as handle:
|
|
1269
|
+
handle.write(json.dumps(record) + "\n")
|
|
1270
|
+
typer.echo(f"observed: {trigger} fired")
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def _jsonl(path: Path) -> list[dict]:
|
|
1274
|
+
if not path.exists():
|
|
1275
|
+
return []
|
|
1276
|
+
out = []
|
|
1277
|
+
for line in path.read_text().splitlines():
|
|
1278
|
+
if line.strip():
|
|
1279
|
+
try:
|
|
1280
|
+
out.append(json.loads(line))
|
|
1281
|
+
except json.JSONDecodeError:
|
|
1282
|
+
continue
|
|
1283
|
+
return out
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
@app.command("retro")
|
|
1287
|
+
def retro_cmd(
|
|
1288
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
1289
|
+
outcome: Annotated[str, typer.Option(help="What actually happened.")] = "",
|
|
1290
|
+
days: Annotated[int, typer.Option(help="How long it took.")] = 0,
|
|
1291
|
+
today: Annotated[str, typer.Option(help="Sweep date, for reproducibility.")] = "",
|
|
1292
|
+
registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
|
|
1293
|
+
) -> None:
|
|
1294
|
+
"""What this engagement taught. Capture only -- no rule is changed here.
|
|
1295
|
+
|
|
1296
|
+
Rules cannot be revised until engagements have outcomes, and pretending to
|
|
1297
|
+
revise on a handful would be borrowing rigour rather than having it. What
|
|
1298
|
+
this does is make sure nothing is lost in the meantime.
|
|
1299
|
+
"""
|
|
1300
|
+
registry = _registry(registry_root)
|
|
1301
|
+
engagement = _engagement(root)
|
|
1302
|
+
overrides = _overrides(engagement)
|
|
1303
|
+
architecture = build_architecture(
|
|
1304
|
+
engagement.profile, registry, overrides=overrides,
|
|
1305
|
+
already_running=_reuse(engagement),
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
stamp = today or date.today().isoformat()
|
|
1309
|
+
try:
|
|
1310
|
+
date.fromisoformat(stamp)
|
|
1311
|
+
except ValueError as exc:
|
|
1312
|
+
typer.echo(f"--today {stamp!r} is not a date (YYYY-MM-DD)", err=True)
|
|
1313
|
+
raise typer.Exit(1) from exc
|
|
1314
|
+
|
|
1315
|
+
# A retrospective on an engagement that never cleared its gates is worth
|
|
1316
|
+
# capturing -- "we never got data access" is a finding. What it must not
|
|
1317
|
+
# do is enter the corpus looking like a delivered engagement.
|
|
1318
|
+
blocked = _gate_status(engagement, registry).blocked_by()
|
|
1319
|
+
if blocked:
|
|
1320
|
+
typer.echo(
|
|
1321
|
+
f"note: {', '.join(blocked)} never cleared, so this case records "
|
|
1322
|
+
f"an engagement that was never built.", err=True,
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
# Predictions date from when the build made them, where a build happened.
|
|
1326
|
+
# A prediction invented at sweep time is always "pending" and calibrates
|
|
1327
|
+
# nothing, which is how the strongest signal used to always read zero.
|
|
1328
|
+
recorded = {
|
|
1329
|
+
p["trigger"]: p
|
|
1330
|
+
for p in _jsonl(engagement.root / "predictions.jsonl")
|
|
1331
|
+
if isinstance(p.get("trigger"), str)
|
|
1332
|
+
}
|
|
1333
|
+
predictions = [
|
|
1334
|
+
Prediction(
|
|
1335
|
+
trigger=f"{component}.graduate",
|
|
1336
|
+
condition=decision.rationale,
|
|
1337
|
+
predicted_at=recorded.get(f"{component}.graduate", {}).get(
|
|
1338
|
+
"predicted_at", stamp
|
|
1339
|
+
),
|
|
1340
|
+
horizon_days=90,
|
|
1341
|
+
)
|
|
1342
|
+
for component, decision in architecture.decisions.decided().items()
|
|
1343
|
+
]
|
|
1344
|
+
by_trigger = {p.trigger: p for p in predictions}
|
|
1345
|
+
observations = []
|
|
1346
|
+
for number, record in enumerate(
|
|
1347
|
+
_jsonl(engagement.root / "observations.jsonl"), start=1
|
|
1348
|
+
):
|
|
1349
|
+
# Hand-editing the log IS the repair path, so a hand-edited record
|
|
1350
|
+
# is skipped by name rather than dying three modules later.
|
|
1351
|
+
if record.get("trigger") not in by_trigger:
|
|
1352
|
+
continue
|
|
1353
|
+
observed_at = record.get("observed_at")
|
|
1354
|
+
try:
|
|
1355
|
+
date.fromisoformat(str(observed_at))
|
|
1356
|
+
except (TypeError, ValueError):
|
|
1357
|
+
typer.echo(
|
|
1358
|
+
f"observations.jsonl:{number}: observed_at {observed_at!r} is "
|
|
1359
|
+
f"not a date -- skipped", err=True,
|
|
1360
|
+
)
|
|
1361
|
+
continue
|
|
1362
|
+
observations.append(
|
|
1363
|
+
Observation.fired(by_trigger[record["trigger"]], at=observed_at,
|
|
1364
|
+
measured=record.get("measured", {}))
|
|
1365
|
+
)
|
|
1366
|
+
swept = sweep_triggers(predictions, observations=observations, today=stamp)
|
|
1367
|
+
report = calibration(swept)
|
|
1368
|
+
|
|
1369
|
+
case = emit_case(
|
|
1370
|
+
engagement=root.name,
|
|
1371
|
+
profile=engagement.profile.values(),
|
|
1372
|
+
decisions={c: d.approach for c, d in architecture.decisions.decided().items()},
|
|
1373
|
+
observations=swept,
|
|
1374
|
+
outcome=outcome or "not stated",
|
|
1375
|
+
days=days or None,
|
|
1376
|
+
reused=sorted({r.stack for r in architecture.realizations.values()}),
|
|
1377
|
+
# Every override, in order -- not the last per component. A revert is
|
|
1378
|
+
# a signal about the rule too, and keeping only the survivor drops
|
|
1379
|
+
# the interesting half of the pair.
|
|
1380
|
+
overrides=_jsonl(engagement.root / "overrides.jsonl"),
|
|
1381
|
+
blocked_gates=blocked,
|
|
1382
|
+
)
|
|
1383
|
+
|
|
1384
|
+
# Never silently over an earlier capture: case.json is the only place a
|
|
1385
|
+
# retrospective lives, and a typo'd --registry once rewrote a six-decision
|
|
1386
|
+
# case with a zero-decision one, exit 0 both times.
|
|
1387
|
+
case_path = engagement.root / "case.json"
|
|
1388
|
+
if case_path.exists():
|
|
1389
|
+
try:
|
|
1390
|
+
previous = json.loads(case_path.read_text() or "{}")
|
|
1391
|
+
except json.JSONDecodeError as exc:
|
|
1392
|
+
typer.echo(
|
|
1393
|
+
f"refused: {case_path} exists and cannot be read ({exc}). "
|
|
1394
|
+
f"Move it aside before capturing again.", err=True,
|
|
1395
|
+
)
|
|
1396
|
+
raise typer.Exit(1) from exc
|
|
1397
|
+
if not isinstance(previous, dict):
|
|
1398
|
+
previous = {}
|
|
1399
|
+
if len(previous.get("decisions", {})) > len(case["decisions"]):
|
|
1400
|
+
typer.echo(
|
|
1401
|
+
f"refused: {case_path} already records "
|
|
1402
|
+
f"{len(previous['decisions'])} decisions and this run found "
|
|
1403
|
+
f"{len(case['decisions'])}. Check --registry before "
|
|
1404
|
+
f"overwriting a fuller capture.", err=True,
|
|
1405
|
+
)
|
|
1406
|
+
raise typer.Exit(1)
|
|
1407
|
+
# The outcome and duration are the two fields a corpus actually
|
|
1408
|
+
# needs. Re-running retro with the flags forgotten once blanked both,
|
|
1409
|
+
# exit 0 -- so an earlier answer is kept unless a new one is given.
|
|
1410
|
+
if case["outcome"] == "not stated" and previous.get("outcome") not in (
|
|
1411
|
+
None, "not stated",
|
|
1412
|
+
):
|
|
1413
|
+
case["outcome"] = previous["outcome"]
|
|
1414
|
+
typer.echo(f" outcome kept from the earlier capture: {case['outcome']}")
|
|
1415
|
+
if case["practice"].get("days") is None and isinstance(
|
|
1416
|
+
previous.get("practice"), dict
|
|
1417
|
+
) and previous["practice"].get("days") is not None:
|
|
1418
|
+
case["practice"]["days"] = previous["practice"]["days"]
|
|
1419
|
+
case_path.write_text(json.dumps(case, indent=2, default=str))
|
|
1420
|
+
|
|
1421
|
+
typer.echo(f"case {case['id']} ({len(case['decisions'])} decisions)")
|
|
1422
|
+
typer.echo(f" triggers: {report['fired']} fired, "
|
|
1423
|
+
f"{report['expired_unfired']} expired unfired")
|
|
1424
|
+
typer.echo(f" evidence: {report['strength']} -- {report['why']}")
|
|
1425
|
+
if case["overrides"]:
|
|
1426
|
+
typer.echo(f" overrides: {len(case['overrides'])} carried into the case")
|
|
1427
|
+
if report.get("impossible"):
|
|
1428
|
+
typer.echo(
|
|
1429
|
+
f" ignored: {len(report['impossible'])} observation(s) dated "
|
|
1430
|
+
f"before the prediction they answer"
|
|
1431
|
+
)
|
|
1432
|
+
typer.echo("\nNothing in framework/ was changed. Revision needs a corpus -- "
|
|
1433
|
+
"review case.json, then `fde kb ingest-case` after sanitisation.")
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
@app.command("build")
|
|
1437
|
+
def build_cmd(
|
|
1438
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
1439
|
+
out: Annotated[Path, typer.Option(help="Where to write the project.")],
|
|
1440
|
+
registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
|
|
1441
|
+
) -> None:
|
|
1442
|
+
"""Emit the project. Refuses before writing anything if it would be unsound."""
|
|
1443
|
+
registry = _registry(registry_root)
|
|
1444
|
+
engagement = _engagement(root)
|
|
1445
|
+
_refuse_if_blocked(engagement, registry)
|
|
1446
|
+
architecture = build_architecture(
|
|
1447
|
+
engagement.profile, registry, overrides=_overrides(engagement),
|
|
1448
|
+
already_running=_reuse(engagement),
|
|
1449
|
+
)
|
|
1450
|
+
try:
|
|
1451
|
+
# Only waivers that actually applied at build time. Shipping every
|
|
1452
|
+
# stored waiver once told a client a risk was accepted that had in
|
|
1453
|
+
# fact been retired -- the baseline was on disk and complete.
|
|
1454
|
+
status = _gate_status(engagement, registry)
|
|
1455
|
+
applied = {o.gate for o in status.overridden}
|
|
1456
|
+
waivers = [
|
|
1457
|
+
w for w in engagement.gate_state().get("overrides", [])
|
|
1458
|
+
if w["gate"] in applied
|
|
1459
|
+
]
|
|
1460
|
+
# Applied-only, exactly like waivers: an override that did not take
|
|
1461
|
+
# effect in THIS build (the component decided under different keys,
|
|
1462
|
+
# or a later override superseded it) must not appear in the client
|
|
1463
|
+
# document as a design change that was made.
|
|
1464
|
+
recorded_overrides = _jsonl(engagement.root / "overrides.jsonl")
|
|
1465
|
+
applied_overrides = [
|
|
1466
|
+
o for o in recorded_overrides
|
|
1467
|
+
if any(
|
|
1468
|
+
(key == o.get("component")
|
|
1469
|
+
or key.startswith(str(o.get("component")) + ":"))
|
|
1470
|
+
and d.approach == o.get("chosen")
|
|
1471
|
+
for key, d in architecture.decisions.items()
|
|
1472
|
+
)
|
|
1473
|
+
]
|
|
1474
|
+
report = emit(architecture, out, registry=registry,
|
|
1475
|
+
templates=Path(registry_root) / "templates",
|
|
1476
|
+
pairs_path=Path(root) / "artifacts" / "pairs.jsonl",
|
|
1477
|
+
waivers=waivers,
|
|
1478
|
+
overrides=applied_overrides)
|
|
1479
|
+
except BuildRefused as exc:
|
|
1480
|
+
typer.echo(f"refused: {exc}", err=True)
|
|
1481
|
+
raise typer.Exit(1) from exc
|
|
1482
|
+
|
|
1483
|
+
# Predictions date from the build that made them. Recorded once per
|
|
1484
|
+
# trigger: the first build's claim is the one calibration judges.
|
|
1485
|
+
predictions_path = engagement.root / "predictions.jsonl"
|
|
1486
|
+
already = {p["trigger"] for p in _jsonl(predictions_path)}
|
|
1487
|
+
with predictions_path.open("a") as handle:
|
|
1488
|
+
for component in architecture.decisions.decided():
|
|
1489
|
+
trigger = f"{component}.graduate"
|
|
1490
|
+
if trigger not in already:
|
|
1491
|
+
handle.write(json.dumps(
|
|
1492
|
+
{"trigger": trigger, "predicted_at": date.today().isoformat()}
|
|
1493
|
+
) + "\n")
|
|
1494
|
+
|
|
1495
|
+
locale_marker = engagement.root / "locale"
|
|
1496
|
+
if locale_marker.exists():
|
|
1497
|
+
locale_id = locale_marker.read_text().strip()
|
|
1498
|
+
locale = registry.locales.get(locale_id)
|
|
1499
|
+
if locale is None:
|
|
1500
|
+
# Silence here ships a project without the obligations page an
|
|
1501
|
+
# engagement believes it has -- compliance-grade silence. The
|
|
1502
|
+
# marker names a pack; the registry must know it or say so.
|
|
1503
|
+
typer.echo(
|
|
1504
|
+
f"refused after writing code: this engagement applied locale "
|
|
1505
|
+
f"{locale_id!r} and this registry does not know it. Re-run "
|
|
1506
|
+
f"`fde locale` with a known pack, or delete the engagement's "
|
|
1507
|
+
f"`locale` file if no jurisdiction applies.", err=True,
|
|
1508
|
+
)
|
|
1509
|
+
raise typer.Exit(1)
|
|
1510
|
+
_write_compliance(Path(out), locale)
|
|
1511
|
+
|
|
1512
|
+
typer.echo(f"wrote {out}")
|
|
1513
|
+
# The delivery is finishable, and the finishing move is one command.
|
|
1514
|
+
holdout_path = engagement.root / "artifacts" / "holdout.jsonl"
|
|
1515
|
+
hint = f"fde implement {out}"
|
|
1516
|
+
if holdout_path.exists():
|
|
1517
|
+
hint += f" --holdout {holdout_path}"
|
|
1518
|
+
typer.echo(f"next: {hint}")
|
|
1519
|
+
if architecture.decisions.undecided():
|
|
1520
|
+
typer.echo(
|
|
1521
|
+
f" {len(architecture.decisions.undecided())} component(s) raise on use -- "
|
|
1522
|
+
f"see ARCHITECTURE.md"
|
|
1523
|
+
)
|
|
1524
|
+
if architecture.unrealizable:
|
|
1525
|
+
typer.echo(
|
|
1526
|
+
f" unrealizable: {', '.join(sorted(architecture.unrealizable))} -- "
|
|
1527
|
+
f"raise on use, reasons in ARCHITECTURE.md"
|
|
1528
|
+
)
|
|
1529
|
+
if report.scaffolded:
|
|
1530
|
+
typer.echo(
|
|
1531
|
+
f" scaffolded (template missing): {', '.join(report.scaffolded)} -- "
|
|
1532
|
+
f"contracts fixed, bodies to write"
|
|
1533
|
+
)
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
@app.command("scan")
|
|
1537
|
+
def scan_cmd(
|
|
1538
|
+
root: Annotated[Path | None, typer.Argument(help="Engagement to record into.")] = None,
|
|
1539
|
+
params_b: Annotated[float, typer.Option("--model-b", help="Model size in billions.")] = 8.0,
|
|
1540
|
+
precision: Annotated[str, typer.Option(help="bf16, int8 or int4.")] = "bf16",
|
|
1541
|
+
vram: Annotated[float | None, typer.Option(help="Per-card VRAM, if not on the box.")] = None,
|
|
1542
|
+
gpus: Annotated[int, typer.Option(help="How many such cards.")] = 1,
|
|
1543
|
+
) -> None:
|
|
1544
|
+
"""Whether this hardware runs that model, and what it supports.
|
|
1545
|
+
|
|
1546
|
+
Detects by default. The flags describe a machine you have been told about
|
|
1547
|
+
rather than one you are on -- useful for sizing a client's box from your own
|
|
1548
|
+
laptop, and never recorded as fact, because a specification somebody quoted
|
|
1549
|
+
is not a measurement and the framework decides by provenance.
|
|
1550
|
+
"""
|
|
1551
|
+
if vram is None:
|
|
1552
|
+
detection = detect()
|
|
1553
|
+
hardware, measured = detection.hardware, detection.measured
|
|
1554
|
+
if detection.note:
|
|
1555
|
+
typer.echo(f" {detection.note}")
|
|
1556
|
+
else:
|
|
1557
|
+
hardware = Hardware(gpus=[GPU(f"card-{i}", vram_gb=vram) for i in range(gpus)])
|
|
1558
|
+
measured = False
|
|
1559
|
+
|
|
1560
|
+
if hardware.gpus:
|
|
1561
|
+
for gpu in hardware.gpus:
|
|
1562
|
+
typer.echo(f" {gpu.model} {gpu.vram_gb:.0f}GB sm {gpu.sm}")
|
|
1563
|
+
elif measured:
|
|
1564
|
+
typer.echo(" no accelerator")
|
|
1565
|
+
typer.echo(f" {hardware.total_vram_gb:.0f}GB total"
|
|
1566
|
+
f"{'' if vram is None else ' (stated, not measured)'}")
|
|
1567
|
+
|
|
1568
|
+
fit = fits(hardware, params_b, precision=precision)
|
|
1569
|
+
if not hardware.gpus:
|
|
1570
|
+
# Against no accelerator the fit arithmetic answers a question nobody
|
|
1571
|
+
# asked. What is wanted here is the size, and where it would have to run.
|
|
1572
|
+
typer.echo(
|
|
1573
|
+
f"\n{params_b:g}B at {precision}: {fit.weights_gb:.0f}GB of weights, "
|
|
1574
|
+
f"nothing to load them onto\n"
|
|
1575
|
+
f" -> quantise and run on the {hardware.ram_gb:.0f}GB of host memory "
|
|
1576
|
+
f"if nobody is waiting, or serve it somewhere else"
|
|
1577
|
+
)
|
|
1578
|
+
else:
|
|
1579
|
+
verdict = "fits" if fit.ok else f"does not fit, short {fit.shortfall_gb:.0f}GB"
|
|
1580
|
+
typer.echo(
|
|
1581
|
+
f"\n{params_b:g}B at {precision}: {verdict}\n"
|
|
1582
|
+
f" {fit.weights_gb:.0f}GB weights + {fit.kv_cache_gb:.0f}GB cache "
|
|
1583
|
+
f"against {fit.available_gb:.0f}GB usable"
|
|
1584
|
+
)
|
|
1585
|
+
if not fit.ok:
|
|
1586
|
+
typer.echo(" -> quantise, shrink the model, or add cards")
|
|
1587
|
+
|
|
1588
|
+
typer.echo("\nsupported here")
|
|
1589
|
+
for option in suggest(hardware):
|
|
1590
|
+
typer.echo(f" {option.id}\n {option.reason}\n costs: {option.cost}")
|
|
1591
|
+
|
|
1592
|
+
if hardware.gpus:
|
|
1593
|
+
adapt = finetune_feasible(hardware, params_b, method="full")
|
|
1594
|
+
if not adapt.ok:
|
|
1595
|
+
typer.echo(f"\nfull finetune: no -- {adapt.reason}")
|
|
1596
|
+
|
|
1597
|
+
# Which local models this box serves, for the judge, the reader, and
|
|
1598
|
+
# the implement loop -- sized from what was measured, dated like every
|
|
1599
|
+
# costing figure, because model releases move monthly.
|
|
1600
|
+
from fde.scan import MODEL_GUIDANCE_AS_OF, recommend_local_models
|
|
1601
|
+
|
|
1602
|
+
plan = recommend_local_models(hardware)
|
|
1603
|
+
typer.echo(f"\nlocal models (guidance as of {MODEL_GUIDANCE_AS_OF} -- "
|
|
1604
|
+
f"releases move monthly, verify before install)")
|
|
1605
|
+
typer.echo(f" runtime: {plan.runtime} -- {plan.runtime_reason}")
|
|
1606
|
+
typer.echo(f" usable memory budget: ~{plan.budget_gb}GB")
|
|
1607
|
+
typer.echo(f" judge + frame reader: {plan.judge_model} "
|
|
1608
|
+
f"({plan.serve_hint.format(model=plan.judge_model)})")
|
|
1609
|
+
typer.echo(f" implement-loop coder: {plan.coder_model} "
|
|
1610
|
+
f"({plan.serve_hint.format(model=plan.coder_model)})")
|
|
1611
|
+
typer.echo(f" then: export LLM_ENDPOINT={plan.endpoint}")
|
|
1612
|
+
typer.echo(
|
|
1613
|
+
" the judge is sized by the calibration gate, not the leaderboard: "
|
|
1614
|
+
"the smallest model whose agreement with your graders clears 0.8 on "
|
|
1615
|
+
"your data is the right one"
|
|
1616
|
+
)
|
|
1617
|
+
for note in plan.notes:
|
|
1618
|
+
typer.echo(f" note: {note}")
|
|
1619
|
+
|
|
1620
|
+
if root is None:
|
|
1621
|
+
return
|
|
1622
|
+
if not measured:
|
|
1623
|
+
# Two ways to get here, one message discipline: a stated spec is not a
|
|
1624
|
+
# measurement, and neither is a probe that could not read the machine.
|
|
1625
|
+
typer.echo(
|
|
1626
|
+
"\nnot recorded: only a successful measurement earns detected "
|
|
1627
|
+
"provenance"
|
|
1628
|
+
)
|
|
1629
|
+
return
|
|
1630
|
+
|
|
1631
|
+
engagement = _engagement(root)
|
|
1632
|
+
engagement.append(
|
|
1633
|
+
Session(
|
|
1634
|
+
session_id=_next_session_id(engagement, "scan"),
|
|
1635
|
+
respondent=Respondent(role=Role.SYSTEM),
|
|
1636
|
+
facts=scan_facts(hardware),
|
|
1637
|
+
)
|
|
1638
|
+
)
|
|
1639
|
+
typer.echo("\nrecorded as detected -- outranks anything stated about this box")
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
@app.command("implement")
|
|
1643
|
+
def implement_cmd(
|
|
1644
|
+
project: Annotated[Path, typer.Argument(
|
|
1645
|
+
help="An emitted project directory (holds evals/ and app/)."
|
|
1646
|
+
)],
|
|
1647
|
+
agent_cmd: Annotated[str, typer.Option(
|
|
1648
|
+
"--agent-cmd",
|
|
1649
|
+
help="The coding agent, as a command reading its brief on stdin. "
|
|
1650
|
+
"Default: claude -p --permission-mode acceptEdits",
|
|
1651
|
+
)] = "claude -p --permission-mode acceptEdits",
|
|
1652
|
+
max_rounds: Annotated[int, typer.Option(
|
|
1653
|
+
help="The step cap. The loop is bounded, like everything this "
|
|
1654
|
+
"framework emits."
|
|
1655
|
+
)] = 5,
|
|
1656
|
+
check: Annotated[str | None, typer.Option(
|
|
1657
|
+
help="The command that decides green. Default: the same harness "
|
|
1658
|
+
"invocation the emitted CI runs."
|
|
1659
|
+
)] = None,
|
|
1660
|
+
holdout: Annotated[Path | None, typer.Option(
|
|
1661
|
+
help="A jsonl of pairs the delivery never shipped (fde samples "
|
|
1662
|
+
"writes <eng>/artifacts/holdout.jsonl). Green golden beside "
|
|
1663
|
+
"red holdout means the golden file was memorized."
|
|
1664
|
+
)] = None,
|
|
1665
|
+
) -> None:
|
|
1666
|
+
"""Drive a coding agent until the emitted evals pass, inside guardrails.
|
|
1667
|
+
|
|
1668
|
+
The harness is the stop condition; the evals, boundary, controls and
|
|
1669
|
+
decision documents are the fence -- hashed first, restored and loudly
|
|
1670
|
+
reported if the agent touches them. Every round lands in
|
|
1671
|
+
ops/implement-log.md.
|
|
1672
|
+
"""
|
|
1673
|
+
from fde.implement import run_loop
|
|
1674
|
+
|
|
1675
|
+
project = Path(project)
|
|
1676
|
+
if not (project / "evals").is_dir() or not (project / "app").is_dir():
|
|
1677
|
+
typer.echo(
|
|
1678
|
+
f"{project}: not an emitted project (no evals/ and app/). "
|
|
1679
|
+
f"`fde build` writes one.", err=True,
|
|
1680
|
+
)
|
|
1681
|
+
raise typer.Exit(1)
|
|
1682
|
+
|
|
1683
|
+
report = run_loop(project, agent_cmd=agent_cmd, max_rounds=max_rounds,
|
|
1684
|
+
check=check, holdout=holdout)
|
|
1685
|
+
(project / "ops").mkdir(exist_ok=True)
|
|
1686
|
+
(project / "ops" / "implement-log.md").write_text(report.log())
|
|
1687
|
+
|
|
1688
|
+
for entry in report.rounds:
|
|
1689
|
+
state = "green" if entry.check_passed else "red"
|
|
1690
|
+
extras = f" -- {entry.violation}" if entry.violation else ""
|
|
1691
|
+
changed = f" ({len(entry.changed)} file(s) changed)" if entry.changed else ""
|
|
1692
|
+
typer.echo(f"round {entry.number}: {state}{changed}{extras}")
|
|
1693
|
+
typer.echo(f"\nstopped by: {report.stopped_by}. Log: ops/implement-log.md")
|
|
1694
|
+
if not report.done:
|
|
1695
|
+
raise typer.Exit(1)
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
@app.command("triage")
|
|
1699
|
+
def triage_cmd(
|
|
1700
|
+
statement: Annotated[list[str], typer.Option(
|
|
1701
|
+
"--statement", help="A candidate problem, in prose (repeatable)."
|
|
1702
|
+
)],
|
|
1703
|
+
registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
|
|
1704
|
+
) -> None:
|
|
1705
|
+
"""Rank candidate problems by what discovery can already decide.
|
|
1706
|
+
|
|
1707
|
+
Upstream of `fde start`, and honest about what it ranks: decidability,
|
|
1708
|
+
not business value. A statement that names its shape, its boundary and
|
|
1709
|
+
its numbers gives discovery a running start; one that names none of them
|
|
1710
|
+
costs a discovery phase before anything can be compared. The business
|
|
1711
|
+
case still comes from the baseline -- this only says which candidate is
|
|
1712
|
+
closest to being buildable as stated.
|
|
1713
|
+
"""
|
|
1714
|
+
registry = _registry(registry_root)
|
|
1715
|
+
if len(statement) < 2:
|
|
1716
|
+
typer.echo("triage compares -- give at least two --statement candidates.",
|
|
1717
|
+
err=True)
|
|
1718
|
+
raise typer.Exit(1)
|
|
1719
|
+
|
|
1720
|
+
from fde.decompose import decompose
|
|
1721
|
+
from fde.gates import completeness
|
|
1722
|
+
from fde.intake.prose import parse_prose
|
|
1723
|
+
|
|
1724
|
+
rows = []
|
|
1725
|
+
for text in statement:
|
|
1726
|
+
profile = Profile()
|
|
1727
|
+
profile.ingest(parse_prose(text, registry))
|
|
1728
|
+
values = profile.values()
|
|
1729
|
+
components = decompose(profile, registry).components
|
|
1730
|
+
boundary = any(
|
|
1731
|
+
values.get(d) in entry.boundary_when
|
|
1732
|
+
for d, entry in registry.dimensions.items() if entry.boundary_when
|
|
1733
|
+
)
|
|
1734
|
+
lowered = text.lower()
|
|
1735
|
+
bundled = any(marker in lowered for marker in (
|
|
1736
|
+
"two workflows", "two functions", "two groups", "two teams",
|
|
1737
|
+
"two agentic", "two capabilities", "serving two", "for two ",
|
|
1738
|
+
))
|
|
1739
|
+
rows.append({
|
|
1740
|
+
"text": text,
|
|
1741
|
+
"facts": len(values),
|
|
1742
|
+
"settled": completeness(profile, registry),
|
|
1743
|
+
"components": len(list(components)),
|
|
1744
|
+
"boundary": boundary,
|
|
1745
|
+
"bundled": bundled,
|
|
1746
|
+
})
|
|
1747
|
+
|
|
1748
|
+
rows.sort(key=lambda r: (-r["settled"], -r["facts"]))
|
|
1749
|
+
typer.echo("ranked by what the statement already settles:\n")
|
|
1750
|
+
for rank, row in enumerate(rows, 1):
|
|
1751
|
+
shown = row["text"] if len(row["text"]) <= 64 else row["text"][:61] + "..."
|
|
1752
|
+
typer.echo(f" {rank}. {shown}")
|
|
1753
|
+
typer.echo(
|
|
1754
|
+
f" {row['facts']} fact(s) read, {row['settled']:.0%} of what "
|
|
1755
|
+
f"gets decided settled, {row['components']} component(s) in scope"
|
|
1756
|
+
+ (", crosses a data boundary" if row["boundary"] else "")
|
|
1757
|
+
)
|
|
1758
|
+
if row["bundled"]:
|
|
1759
|
+
typer.echo(
|
|
1760
|
+
" reads like more than one workflow in one statement -- "
|
|
1761
|
+
"worth splitting into separate engagements before fde start, "
|
|
1762
|
+
"since one pipeline gets built per engagement"
|
|
1763
|
+
)
|
|
1764
|
+
typer.echo(
|
|
1765
|
+
"\nThis ranks decidability, not value: the business case comes from "
|
|
1766
|
+
"the baseline, and the baseline comes after `fde start`. A candidate "
|
|
1767
|
+
"ranked low is under-described, not unworthy."
|
|
1768
|
+
)
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
@app.command("cost")
|
|
1772
|
+
def cost_cmd(
|
|
1773
|
+
requests_per_day: Annotated[int | None, typer.Option(
|
|
1774
|
+
help="Expected daily volume. Read from the engagement's arrival_rate "
|
|
1775
|
+
"when --root is given and the interview settled it."
|
|
1776
|
+
)] = None,
|
|
1777
|
+
root: Annotated[Path | None, typer.Option(
|
|
1778
|
+
"--root", help="An engagement directory to read arrival_rate and "
|
|
1779
|
+
"human_waiting from, so discovery is not re-typed at the prompt."
|
|
1780
|
+
)] = None,
|
|
1781
|
+
params_b: Annotated[float, typer.Option("--model-b", help="Model size in billions.")] = 8.0,
|
|
1782
|
+
human_waiting: Annotated[
|
|
1783
|
+
bool | None, typer.Option(help="Is somebody waiting on each request?")
|
|
1784
|
+
] = None,
|
|
1785
|
+
today: Annotated[str, typer.Option(help="For staleness checks; defaults to today.")] = "",
|
|
1786
|
+
price_per_seat: Annotated[float | None, typer.Option(
|
|
1787
|
+
help="Monthly price per seat: adds the unit-economics check -- "
|
|
1788
|
+
"whether a seat earns more than it burns."
|
|
1789
|
+
)] = None,
|
|
1790
|
+
workflows_per_day: Annotated[float, typer.Option(
|
|
1791
|
+
help="Workflows one seat runs daily, for the unit-economics check."
|
|
1792
|
+
)] = 8.0,
|
|
1793
|
+
steps: Annotated[int, typer.Option(
|
|
1794
|
+
help="Agent-loop steps per workflow. Unbounded loops price like this "
|
|
1795
|
+
"number being large."
|
|
1796
|
+
)] = 5,
|
|
1797
|
+
) -> None:
|
|
1798
|
+
"""Size the fleet and compare hosting, with every figure dated.
|
|
1799
|
+
|
|
1800
|
+
The naive figure is shown beside the real one because the gap is the
|
|
1801
|
+
finding: redundancy, peak and prefill multiply a fleet, and pricing each
|
|
1802
|
+
replica as one card quotes a large model at a third of its cost.
|
|
1803
|
+
"""
|
|
1804
|
+
from fde.costing import compare_hosting, size_for
|
|
1805
|
+
|
|
1806
|
+
stamp = today or date.today().isoformat()
|
|
1807
|
+
if root is not None:
|
|
1808
|
+
values = _engagement(root).profile.values()
|
|
1809
|
+
if requests_per_day is None and values.get("arrival_rate") is not None:
|
|
1810
|
+
requests_per_day = int(values["arrival_rate"])
|
|
1811
|
+
typer.echo(f"arrival_rate from the engagement: {requests_per_day:,}/day")
|
|
1812
|
+
if human_waiting is None and values.get("human_waiting") is not None:
|
|
1813
|
+
human_waiting = values["human_waiting"] != "no"
|
|
1814
|
+
if requests_per_day is None:
|
|
1815
|
+
typer.echo(
|
|
1816
|
+
"no volume to size for -- pass --requests-per-day, or --root an "
|
|
1817
|
+
"engagement whose interview settled arrival_rate.", err=True,
|
|
1818
|
+
)
|
|
1819
|
+
raise typer.Exit(1)
|
|
1820
|
+
if human_waiting is None:
|
|
1821
|
+
human_waiting = True
|
|
1822
|
+
|
|
1823
|
+
plan = size_for(requests_per_day, params_b, today=stamp)
|
|
1824
|
+
comparison = compare_hosting(
|
|
1825
|
+
requests_per_day, params_b, human_waiting=human_waiting, today=stamp
|
|
1826
|
+
)
|
|
1827
|
+
|
|
1828
|
+
typer.echo(
|
|
1829
|
+
f"{params_b:g}B at {requests_per_day:,}/day"
|
|
1830
|
+
f"{' (interactive)' if human_waiting else ' (batch, nobody waiting)'}\n"
|
|
1831
|
+
)
|
|
1832
|
+
typer.echo(f" naive: {plan['naive_replicas']} replica(s)")
|
|
1833
|
+
typer.echo(
|
|
1834
|
+
f" real: {plan['replicas']} replica(s) x {plan['gpus_per_replica']} "
|
|
1835
|
+
f"card(s) = {plan['gpus']} cards"
|
|
1836
|
+
)
|
|
1837
|
+
for name, why in plan["factors"].items():
|
|
1838
|
+
typer.echo(f" {name}: {why}")
|
|
1839
|
+
|
|
1840
|
+
typer.echo(
|
|
1841
|
+
f"\n self-hosted ${comparison['self_hosted_monthly']:,.0f}/mo\n"
|
|
1842
|
+
f" managed ${comparison['managed_monthly']:,.0f}/mo\n"
|
|
1843
|
+
f" -> {comparison['recommendation']}: {comparison['why']}"
|
|
1844
|
+
)
|
|
1845
|
+
typer.echo(
|
|
1846
|
+
f"\n as of {plan['as_of']} -- {plan['rederive']}"
|
|
1847
|
+
)
|
|
1848
|
+
|
|
1849
|
+
if price_per_seat is not None:
|
|
1850
|
+
from fde.costing import unit_economics
|
|
1851
|
+
|
|
1852
|
+
coverage = None
|
|
1853
|
+
if root is not None:
|
|
1854
|
+
coverage = _engagement(root).profile.values().get("cheap_path_coverage")
|
|
1855
|
+
economics = unit_economics(
|
|
1856
|
+
workflows_per_day, price_per_seat, steps_per_workflow=steps,
|
|
1857
|
+
cheap_path_coverage=coverage, today=stamp,
|
|
1858
|
+
)
|
|
1859
|
+
typer.echo(
|
|
1860
|
+
f"\nunit economics at ${price_per_seat:.2f}/seat, "
|
|
1861
|
+
f"{workflows_per_day:g} workflows/day, {steps} step(s):"
|
|
1862
|
+
)
|
|
1863
|
+
typer.echo(f" ${economics['cost_per_workflow']:.4f}/workflow -> "
|
|
1864
|
+
f"${economics['cost_per_seat_month']:.2f}/seat-month in model spend")
|
|
1865
|
+
drowned = " -- UNDERWATER: every new user costs money"
|
|
1866
|
+
typer.echo(f" margin: ${economics['margin_per_seat']:.2f}/seat"
|
|
1867
|
+
+ (drowned if economics["underwater"] else ""))
|
|
1868
|
+
for reason, new_margin in economics["levers"]:
|
|
1869
|
+
typer.echo(f" lever: {reason} -> margin ${new_margin:.2f}")
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
@kb.command("ingest-case")
|
|
1873
|
+
def kb_ingest_case(
|
|
1874
|
+
case_file: Annotated[Path, typer.Argument(help="A case.json from `fde retro`.")],
|
|
1875
|
+
root: Annotated[Path, typer.Option(help="Registry directory.")] = DEFAULT_ROOT,
|
|
1876
|
+
) -> None:
|
|
1877
|
+
"""Bring a captured case into the corpus -- as pending, never as reviewed.
|
|
1878
|
+
|
|
1879
|
+
This is the step that stops every engagement being a dead end. It is
|
|
1880
|
+
human-gated on purpose: the file lands with sanitization: pending, and
|
|
1881
|
+
nothing pending should ever reach a public repository. Review every field
|
|
1882
|
+
for anything identifying, then set sanitization: reviewed by hand.
|
|
1883
|
+
"""
|
|
1884
|
+
try:
|
|
1885
|
+
case = json.loads(case_file.read_text())
|
|
1886
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1887
|
+
typer.echo(f"cannot read {case_file}: {exc}", err=True)
|
|
1888
|
+
raise typer.Exit(1) from exc
|
|
1889
|
+
|
|
1890
|
+
if not isinstance(case, dict):
|
|
1891
|
+
typer.echo(
|
|
1892
|
+
f"{case_file}: expected a JSON object, found "
|
|
1893
|
+
f"{type(case).__name__} -- is this a case.json from retro?", err=True,
|
|
1894
|
+
)
|
|
1895
|
+
raise typer.Exit(1)
|
|
1896
|
+
case_id = case.get("id")
|
|
1897
|
+
if not case_id:
|
|
1898
|
+
typer.echo(f"{case_file}: no id field -- is this a case.json from retro?", err=True)
|
|
1899
|
+
raise typer.Exit(1)
|
|
1900
|
+
if not CASE_ID.fullmatch(str(case_id)):
|
|
1901
|
+
# The id becomes a filename. Untrusted JSON deciding where a file
|
|
1902
|
+
# lands is how `../` and absolute paths write outside the registry
|
|
1903
|
+
# -- and a case that arrives from elsewhere is exactly the untrusted
|
|
1904
|
+
# input this command exists to accept.
|
|
1905
|
+
typer.echo(
|
|
1906
|
+
f"{case_file}: {case_id!r} is not a case id. Expected the "
|
|
1907
|
+
f"anonymised form `fde retro` writes (case-<hex>).", err=True,
|
|
1908
|
+
)
|
|
1909
|
+
raise typer.Exit(1)
|
|
1910
|
+
|
|
1911
|
+
cases_dir = Path(root) / "cases"
|
|
1912
|
+
if not cases_dir.is_dir():
|
|
1913
|
+
# Never conjure a registry: a typo'd --root once created a whole
|
|
1914
|
+
# tree from nothing and reported success.
|
|
1915
|
+
typer.echo(
|
|
1916
|
+
f"{root}: not a registry (no cases/ directory). Point --root at "
|
|
1917
|
+
f"one rather than at a path to be created.", err=True,
|
|
1918
|
+
)
|
|
1919
|
+
raise typer.Exit(1)
|
|
1920
|
+
|
|
1921
|
+
target = cases_dir / f"{case_id}.md"
|
|
1922
|
+
if target.exists():
|
|
1923
|
+
typer.echo(f"{target}: already in the corpus. Cases are append-only; "
|
|
1924
|
+
f"a new retrospective makes a new case.", err=True)
|
|
1925
|
+
raise typer.Exit(1)
|
|
1926
|
+
|
|
1927
|
+
front = {k: v for k, v in case.items() if k != "sanitization"}
|
|
1928
|
+
front["sanitization"] = "pending"
|
|
1929
|
+
target.write_text(
|
|
1930
|
+
f"---\n{yaml.safe_dump(front, sort_keys=False)}---\n"
|
|
1931
|
+
f"Ingested from an engagement retrospective, not yet reviewed.\n\n"
|
|
1932
|
+
f"Before this can be committed anywhere: read every field for anything\n"
|
|
1933
|
+
f"that identifies a client, re-express what does, then set\n"
|
|
1934
|
+
f"`sanitization: reviewed` by hand. Pending cases are refused by the\n"
|
|
1935
|
+
f"sanitisation gate.\n"
|
|
1936
|
+
)
|
|
1937
|
+
typer.echo(f"wrote {target} [sanitization: pending]")
|
|
1938
|
+
typer.echo("review it, then set sanitization: reviewed -- the gate refuses "
|
|
1939
|
+
"pending cases")
|
|
1940
|
+
|
|
1941
|
+
|
|
1942
|
+
@kb.command("export-training")
|
|
1943
|
+
def kb_export_training(
|
|
1944
|
+
root: Annotated[Path, typer.Argument(help="The engagement directory.")],
|
|
1945
|
+
out: Annotated[Path, typer.Option(help="Where to write the .jsonl.")],
|
|
1946
|
+
) -> None:
|
|
1947
|
+
"""Export (brief, facts) pairs -- the fine-tune flywheel.
|
|
1948
|
+
|
|
1949
|
+
Every retained brief beside the facts it yielded, one JSON object per
|
|
1950
|
+
session. This is the corpus a fine-tuned reader learns from -- and the
|
|
1951
|
+
corpus's own doctrine for clients applies to the framework itself: a
|
|
1952
|
+
fine-tune earns adoption when the pairs cross a real threshold AND the
|
|
1953
|
+
measured hit rate of the base model falls short, not before. The output
|
|
1954
|
+
is client data; it belongs wherever the engagement does, never in a
|
|
1955
|
+
repository.
|
|
1956
|
+
"""
|
|
1957
|
+
engagement = _engagement(root)
|
|
1958
|
+
briefs = engagement.root / "artifacts" / "briefs"
|
|
1959
|
+
if not briefs.is_dir():
|
|
1960
|
+
typer.echo(
|
|
1961
|
+
"no retained briefs here -- pairs come from `fde frame` runs "
|
|
1962
|
+
"made after briefs began to be retained. Re-frame the brief "
|
|
1963
|
+
"and re-export.", err=True,
|
|
1964
|
+
)
|
|
1965
|
+
raise typer.Exit(1)
|
|
1966
|
+
|
|
1967
|
+
sessions = {
|
|
1968
|
+
path.stem: Session.from_yaml(path.read_text(), path.stem)
|
|
1969
|
+
for path in sorted(engagement.facts_dir.glob("*.yaml"))
|
|
1970
|
+
}
|
|
1971
|
+
rows = []
|
|
1972
|
+
for brief_path in sorted(briefs.glob("*.txt")):
|
|
1973
|
+
session = sessions.get(brief_path.stem)
|
|
1974
|
+
if session is None:
|
|
1975
|
+
continue
|
|
1976
|
+
rows.append({
|
|
1977
|
+
"text": brief_path.read_text(),
|
|
1978
|
+
"facts": [
|
|
1979
|
+
{"dimension": f.dimension, "value": f.value,
|
|
1980
|
+
"span": list(f.span) if f.span else None}
|
|
1981
|
+
for f in session.facts
|
|
1982
|
+
],
|
|
1983
|
+
})
|
|
1984
|
+
if not rows:
|
|
1985
|
+
typer.echo("no (brief, facts) pairs found", err=True)
|
|
1986
|
+
raise typer.Exit(1)
|
|
1987
|
+
out.write_text("".join(json.dumps(r) + "\n" for r in rows))
|
|
1988
|
+
typer.echo(
|
|
1989
|
+
f"wrote {len(rows)} pair(s) to {out}\n"
|
|
1990
|
+
f"doctrine: fine-tune the reader when the pairs number in the "
|
|
1991
|
+
f"thousands AND the measured base-model hit rate falls short -- "
|
|
1992
|
+
f"the same bar the corpus holds clients to."
|
|
1993
|
+
)
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
@kb.command("suggest")
|
|
1997
|
+
def kb_suggest(
|
|
1998
|
+
text: Annotated[str | None, typer.Option(help="The brief, inline.")] = None,
|
|
1999
|
+
file: Annotated[Path | None, typer.Option(help="A file holding the brief.")] = None,
|
|
2000
|
+
endpoint: Annotated[str | None, typer.Option(
|
|
2001
|
+
help="OpenAI-compatible local model server. Without it the hosted "
|
|
2002
|
+
"model is used -- refused unless the text itself may leave."
|
|
2003
|
+
)] = None,
|
|
2004
|
+
model: Annotated[str | None, typer.Option(help="Model name.")] = None,
|
|
2005
|
+
registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
|
|
2006
|
+
) -> None:
|
|
2007
|
+
"""Mine a brief for recogniser gaps -- the vocabulary treadmill, automated.
|
|
2008
|
+
|
|
2009
|
+
Runs the deterministic reader and a model reader over the same text and
|
|
2010
|
+
proposes recogniser phrases for exactly the delta: facts the model found,
|
|
2011
|
+
validated against the registry's declared values, that the vocabulary
|
|
2012
|
+
missed. Output is a review-ready diff for framework/dimensions/ -- it
|
|
2013
|
+
never edits the registry, because a recogniser is a content change that
|
|
2014
|
+
deserves a human eye and a test.
|
|
2015
|
+
"""
|
|
2016
|
+
if not text and not file:
|
|
2017
|
+
typer.echo("Give me --text or --file.", err=True)
|
|
2018
|
+
raise typer.Exit(1)
|
|
2019
|
+
body = file.read_text() if file else (text or "")
|
|
2020
|
+
registry = _registry(registry_root)
|
|
2021
|
+
|
|
2022
|
+
from fde.intake.llm_reader import (
|
|
2023
|
+
BoundaryRefusal,
|
|
2024
|
+
ReaderUnavailable,
|
|
2025
|
+
suggest_recognisers,
|
|
2026
|
+
)
|
|
2027
|
+
|
|
2028
|
+
try:
|
|
2029
|
+
suggestions, dropped = suggest_recognisers(
|
|
2030
|
+
body, registry, endpoint=endpoint, model=model,
|
|
2031
|
+
)
|
|
2032
|
+
except (BoundaryRefusal, ReaderUnavailable) as exc:
|
|
2033
|
+
typer.echo(str(exc), err=True)
|
|
2034
|
+
raise typer.Exit(1) from exc
|
|
2035
|
+
|
|
2036
|
+
if not suggestions:
|
|
2037
|
+
typer.echo("the model found nothing the vocabulary missed")
|
|
2038
|
+
return
|
|
2039
|
+
typer.echo("recogniser candidates -- review, test, then edit the "
|
|
2040
|
+
"dimension file by hand:\n")
|
|
2041
|
+
for s in suggestions:
|
|
2042
|
+
typer.echo(f" framework/dimensions/{s['dimension']}.md")
|
|
2043
|
+
typer.echo(f" {s['value']}: add phrase {s['phrase']!r}")
|
|
2044
|
+
typer.echo(f" evidence: {s['evidence']!r}\n")
|
|
2045
|
+
for reason in dropped:
|
|
2046
|
+
typer.echo(f" (refused: {reason})")
|
|
2047
|
+
|
|
2048
|
+
|
|
2049
|
+
@kb.command("sweep")
|
|
2050
|
+
def kb_sweep(
|
|
2051
|
+
root: Annotated[Path, typer.Option(help="Registry directory.")] = DEFAULT_ROOT,
|
|
2052
|
+
samples: Annotated[int, typer.Option(help="Fully specified profiles to try.")] = 300,
|
|
2053
|
+
seed: Annotated[int, typer.Option(help="Deterministic sampling seed.")] = 0,
|
|
2054
|
+
) -> None:
|
|
2055
|
+
"""Find profiles the registry cannot serve. Work items -- always exits 0.
|
|
2056
|
+
|
|
2057
|
+
`kb gaps` checks that approaches exist; this checks that one can fire.
|
|
2058
|
+
They disagree exactly where it hurts: a component with five approaches,
|
|
2059
|
+
all ruled out by one combination of honest answers, counts as covered
|
|
2060
|
+
and is undecidable.
|
|
2061
|
+
"""
|
|
2062
|
+
try:
|
|
2063
|
+
registry = load_registry(root)
|
|
2064
|
+
except RegistryError as exc:
|
|
2065
|
+
typer.echo(str(exc), err=True)
|
|
2066
|
+
raise typer.Exit(1) from exc
|
|
2067
|
+
|
|
2068
|
+
from fde.graph import sweep_dead_zones
|
|
2069
|
+
|
|
2070
|
+
result = sweep_dead_zones(registry, samples=samples, seed=seed)
|
|
2071
|
+
dead = result["dead"]
|
|
2072
|
+
if not dead:
|
|
2073
|
+
typer.echo(f"{samples} fully specified profiles, every component decidable")
|
|
2074
|
+
return
|
|
2075
|
+
|
|
2076
|
+
typer.echo(f"{samples} profiles; components undecidable in some of them:\n")
|
|
2077
|
+
for component, entry in dead.items():
|
|
2078
|
+
typer.echo(f" {component:16} {entry['rate']:.1%}")
|
|
2079
|
+
example = ", ".join(f"{k}={v}" for k, v in sorted(entry["example"].items()))
|
|
2080
|
+
typer.echo(f" e.g. {example}")
|
|
2081
|
+
typer.echo(
|
|
2082
|
+
"\nSome are honest contradictions the design should surface, not fill. "
|
|
2083
|
+
"`fde architect` names the conflicting facts for any specific profile."
|
|
2084
|
+
)
|
|
2085
|
+
|
|
2086
|
+
|
|
2087
|
+
@kb.command("gaps")
|
|
2088
|
+
def kb_gaps(
|
|
2089
|
+
root: Annotated[Path, typer.Option(help="Registry directory.")] = DEFAULT_ROOT,
|
|
2090
|
+
) -> None:
|
|
2091
|
+
"""Report what the corpus is missing. Work items, not errors -- always exits 0."""
|
|
2092
|
+
try:
|
|
2093
|
+
registry = load_registry(root)
|
|
2094
|
+
except RegistryError as exc:
|
|
2095
|
+
typer.echo(str(exc), err=True)
|
|
2096
|
+
raise typer.Exit(1) from exc
|
|
2097
|
+
|
|
2098
|
+
gaps = find_gaps(registry, templates=root / "templates")
|
|
2099
|
+
for gap in gaps:
|
|
2100
|
+
typer.echo(f"{gap.kind}: {gap.detail}")
|
|
2101
|
+
typer.echo(f"{len(gaps)} gap(s)")
|
|
2102
|
+
|
|
2103
|
+
|
|
2104
|
+
if __name__ == "__main__": # pragma: no cover
|
|
2105
|
+
app()
|
|
2106
|
+
|
|
2107
|
+
|