prabodha 1.0.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.
prabodha/__init__.py ADDED
File without changes
prabodha/cli.py ADDED
@@ -0,0 +1,49 @@
1
+ """prabodha CLI — the H4 application surface: one entrypoint over the toolkit.
2
+ Concept: the doctrine as a usable instrument (clean external surface, RULES R8 — the
3
+ engineering-gloss column only). Subcommands route to the tested module CLIs.
4
+ Source: docs/h4_plugin_architecture.md phase 1.
5
+ Primitive: `prabodha <cmd> [args...]` -> module main(argv). Stdlib dispatch only.
6
+
7
+ prabodha lens-fit --model M.yaml --lens L.yaml --out lens.pt [--resume]
8
+ prabodha lens-eval --model M.yaml --lens-file lens.pt --exp E.yaml --out gate.json
9
+ prabodha lens-vis --model M.yaml --lens-file lens.pt --prompt "..." --out page.html
10
+ prabodha steer --model M.yaml --mid-lens lens.pt --exp E.yaml --out gate.json
11
+ prabodha research [--menu menu.yaml] [--propose]
12
+ prabodha figures (regenerate paper/HTML figures from gates)
13
+ """
14
+ import sys
15
+
16
+
17
+ def main() -> None:
18
+ if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
19
+ print(__doc__)
20
+ raise SystemExit(0)
21
+ cmd, argv = sys.argv[1], sys.argv[2:]
22
+ if cmd == "lens-fit":
23
+ from prabodha.lens import fit_cli
24
+ sys.argv = ["fit_cli"] + argv
25
+ fit_cli.main()
26
+ elif cmd == "lens-eval":
27
+ from prabodha.lens import e1_cli
28
+ e1_cli.main(argv)
29
+ elif cmd == "lens-vis":
30
+ from prabodha.lens import vis_cli
31
+ vis_cli.main(argv)
32
+ elif cmd == "steer":
33
+ from prabodha.steering import e4_cli
34
+ e4_cli.main(argv)
35
+ elif cmd == "research":
36
+ from prabodha.efe import runner
37
+ runner.main(argv)
38
+ elif cmd == "figures":
39
+ import runpy
40
+ from pathlib import Path
41
+ script = Path(__file__).resolve().parents[2] / "scripts/tools/make_figures.py"
42
+ runpy.run_path(str(script), run_name="__main__")
43
+ else:
44
+ print(f"unknown command: {cmd}\n{__doc__}")
45
+ raise SystemExit(2)
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
prabodha/config.py ADDED
@@ -0,0 +1,15 @@
1
+ """Config loader — config over constants (CLAUDE.md).
2
+ Concept: āgama-śāsana — the written rule governs the run.
3
+ Primitive: YAML -> dict with required-key validation.
4
+ """
5
+ from __future__ import annotations
6
+ from pathlib import Path
7
+ import yaml
8
+
9
+ def load(path: str | Path, required: tuple[str, ...] = ()) -> dict:
10
+ with open(path) as f:
11
+ cfg = yaml.safe_load(f)
12
+ missing = [k for k in required if k not in cfg]
13
+ if missing:
14
+ raise KeyError(f"config {path} missing required keys: {missing}")
15
+ return cfg
File without changes
@@ -0,0 +1,33 @@
1
+ """Dual-closure gate schema (RULES R1).
2
+ Concept: dvāra (gate) with ubhaya-siddhi — both accomplishments required.
3
+ Source: neo-fm five-point gate; prabhasa closure.py; scoping doc §7.
4
+ Primitive: pydantic model serialized to gates/gate_<loop>.json.
5
+ """
6
+ from __future__ import annotations
7
+ from typing import Literal, Optional
8
+ from pydantic import BaseModel, model_validator
9
+
10
+ Verdict = Literal["pass", "fail", "pruned", "pending"]
11
+
12
+ class GateSide(BaseModel):
13
+ verdict: Verdict
14
+ evidence: str
15
+ deviations: list[str] = []
16
+
17
+ class GateReport(BaseModel):
18
+ loop: str
19
+ status: Literal["open", "closed"]
20
+ closed_at: Optional[str] = None
21
+ code_gate: GateSide
22
+ domain_gate: GateSide
23
+ signoff: str = "pending"
24
+
25
+ @model_validator(mode="after")
26
+ def _dual_closure(self) -> "GateReport":
27
+ if self.status == "closed":
28
+ ok = {"pass", "pruned"}
29
+ if not (self.code_gate.verdict in ok and self.domain_gate.verdict in ok):
30
+ raise ValueError("R1 violation: closed loop requires BOTH gates pass|pruned")
31
+ if self.domain_gate.verdict == "pruned" and not self.domain_gate.evidence:
32
+ raise ValueError("R5: pruned closure requires evidence (diagnosis)")
33
+ return self
@@ -0,0 +1,44 @@
1
+ """Steering episode trace — the shared record consumed by app replay, Pages, and gateway.
2
+
3
+ Concept: sākṣāt-darśana (direct seeing) extended to the steering episode as a whole.
4
+ Source: paper §steering; gate_L9_alignconf.json (arm/seed semantics).
5
+ Primitive: trace emission (pure record; no behavior).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ SCHEMA_VERSION = 1
12
+
13
+
14
+ class TraceToken(BaseModel):
15
+ t: int # decode step index (0-based)
16
+ token: str # decoded token text
17
+ entropy: float # per-token predictive entropy in nats, measured pre-write
18
+ gated: bool # True iff a sphuraṭṭā-gated write was applied at this step
19
+ write_norm: float | None = None # L2 norm of the applied write (None when not gated)
20
+ band_topk: list[str] | None = None # band-lens readout top-k tokens (None if not sampled at this step)
21
+
22
+
23
+ class ReadbackResult(BaseModel):
24
+ verdict: str # "accepted" | "rejected"
25
+ top_m: int
26
+ gain: float
27
+ concept_rank: int | None = None
28
+
29
+
30
+ class SteerTrace(BaseModel):
31
+ schema_version: int = SCHEMA_VERSION
32
+ model_id: str # e.g. "Qwen/Qwen3-4B"
33
+ prompt: str
34
+ concept: str # e.g. "fire"
35
+ arm: str # baseline|prefill|entropy_gated|rate_matched|continuous|trained_bridge
36
+ seed: int
37
+ alpha: float
38
+ tau_percentile: int
39
+ site_layer: int
40
+ tokens: list[TraceToken] = Field(default_factory=list)
41
+ readback: ReadbackResult | None = None
42
+ behavioral_hit: bool | None = None
43
+ gate_ref: str | None = None # repo-relative path of the gate JSON this run fed, if any
44
+ created_at: str # ISO-8601 UTC
@@ -0,0 +1,7 @@
1
+ """prabodha.efe — auto-research: EFE selection over the experiment menu (L5).
2
+ Concept: the selector proposes the next experiment by expected information gain per
3
+ GPU-hour; the agent disposes (operator standing authorization 2026-07-09); gates record.
4
+ Source: prabhasa application/efe (ported per docs/efe_port_plan.md; math preserved exactly);
5
+ SPEC §7. Primitive: menu (configs/efe_menu.yaml) -> EFESelector.rank/select -> Proposal;
6
+ gate_to_obs replays closed gates into beliefs; EFELedger (JSONL) makes beliefs re-entrant.
7
+ """
prabodha/efe/agent.py ADDED
@@ -0,0 +1,245 @@
1
+ """Analytic Expected-Free-Energy selector over experiment candidates.
2
+ Concept: iccha-jnana-kriya as auto-research — wanting (pragmatic preference), knowing
3
+ (epistemic information gain), doing (the proposed action) balanced per GPU-hour.
4
+ Source: PORTED from prabhasa application/efe/agent.py (docs/efe_port_plan.md; likelihood
5
+ matrix, entropy, normalise, and all EFESelector methods preserved EXACTLY — only the
6
+ observation tier field names are generalized: bpb_tier->primary_tier, frt_tier->
7
+ secondary_tier, since prabodha's observations are gate outcomes, not token-efficiency).
8
+ Primitive: Candidate/Action/Observation/Proposal + EFESelector (belief/update/score/
9
+ select/rank). Stdlib only.
10
+
11
+ Original prabhasa docstring (design rationale) follows:
12
+ Analytic Expected-Free-Energy selector over experiment candidates.
13
+
14
+ State / observation discretisation
15
+ ----------------------------------
16
+ Each candidate carries a belief over a latent **value** factor with four levels
17
+ (``negligible, low, moderate, high``) — "how much does running this config help
18
+ the product?". Observations are discretised tiers of the measured improvement
19
+ over the matched baseline:
20
+
21
+ * ``bpb`` tier in 0..3 (token-efficiency gain),
22
+ * ``frt`` tier in 0..3 (round-trip / probe gain; optional).
23
+
24
+ Actions modulate cost vs information resolution (separation by action, mirroring
25
+ the source agent's B-matrix entropy design):
26
+
27
+ * ``smoke`` — cheap, low-information (explore),
28
+ * ``partial`` — moderate,
29
+ * ``full`` — expensive, high-information (confirm / exploit).
30
+
31
+ EFE
32
+ ---
33
+ For a candidate ``c`` and action ``a`` the Expected Free Energy is
34
+
35
+ G(c, a) = -( w_epi * epistemic(c, a) + w_prag * pragmatic(c) )
36
+
37
+ * ``epistemic`` = expected information gain ~ belief entropy * action resolution,
38
+ divided by action cost (information per GPU-hour);
39
+ * ``pragmatic`` = expected preference satisfaction E_b[U(value)] minus a cost
40
+ penalty.
41
+
42
+ The selector returns the ``(candidate, action)`` minimising ``G`` (equivalently
43
+ maximising the negative-EFE the source agent reports). EXPLORE->CONFIRM emerges:
44
+ high-entropy candidates pull a cheap ``smoke``; high-belief candidates pull a
45
+ ``full`` run.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import math
51
+ from collections.abc import Sequence
52
+ from dataclasses import dataclass, field
53
+
54
+ VALUE_LEVELS = 4
55
+ VALUE_LABELS = ("negligible", "low", "moderate", "high")
56
+ # Utility of each value level (pragmatic preference; prefer high value).
57
+ _UTILITY = (-1.0, 0.0, 1.0, 2.0)
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Action:
62
+ """A way to run a candidate: cost (GPU-hours) and information resolution."""
63
+
64
+ name: str
65
+ gpu_hours: float
66
+ resolution: float # 0..1; how much of the candidate's uncertainty it resolves
67
+
68
+
69
+ # Default action set (cheap-smoke -> full-run); EXPLORE..CONFIRM spectrum.
70
+ SMOKE = Action("smoke", gpu_hours=0.1, resolution=0.25)
71
+ PARTIAL = Action("partial", gpu_hours=1.0, resolution=0.6)
72
+ FULL = Action("full", gpu_hours=5.0, resolution=0.95)
73
+ DEFAULT_ACTIONS: tuple[Action, ...] = (SMOKE, PARTIAL, FULL)
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class Candidate:
78
+ """One experiment candidate: a knob/config change to evaluate."""
79
+
80
+ id: str
81
+ description: str
82
+ knobs: dict[str, object] = field(default_factory=dict)
83
+ # Optional prior nudge in 0..3 (e.g. strong prior it is "moderate" value).
84
+ prior_value_hint: int | None = None
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Observation:
89
+ """Discretised measured outcome of a trial (tiers in 0..3)."""
90
+
91
+ primary_tier: int
92
+ secondary_tier: int | None = None
93
+
94
+ def __post_init__(self) -> None:
95
+ for name, v in (("primary_tier", self.primary_tier), ("secondary_tier", self.secondary_tier)):
96
+ if v is not None and not 0 <= v <= 3:
97
+ raise ValueError(f"{name} must be in 0..3, got {v}")
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Proposal:
102
+ """The selector's recommendation for the next micro-experiment."""
103
+
104
+ candidate: Candidate
105
+ action: Action
106
+ efe: float
107
+ epistemic: float
108
+ pragmatic: float
109
+ belief: tuple[float, ...]
110
+
111
+
112
+ def _normalise(weights: Sequence[float]) -> list[float]:
113
+ total = sum(weights)
114
+ if total <= 0:
115
+ n = len(weights)
116
+ return [1.0 / n] * n
117
+ return [w / total for w in weights]
118
+
119
+
120
+ def _entropy(belief: Sequence[float]) -> float:
121
+ return -sum(p * math.log(p + 1e-12) for p in belief if p > 0)
122
+
123
+
124
+ # Likelihood P(observed tier | latent value level). Higher value -> higher tiers.
125
+ # Rows index latent value level (0..3), columns the observed tier (0..3).
126
+ _LIKELIHOOD: tuple[tuple[float, ...], ...] = (
127
+ (0.70, 0.20, 0.07, 0.03), # negligible
128
+ (0.30, 0.45, 0.20, 0.05), # low
129
+ (0.07, 0.23, 0.45, 0.25), # moderate
130
+ (0.03, 0.07, 0.30, 0.60), # high
131
+ )
132
+
133
+
134
+ class EFESelector:
135
+ """Expected-Free-Energy selector over a fixed candidate set.
136
+
137
+ Beliefs are categorical over the four latent value levels, one belief per
138
+ candidate id, updated by Bayes rule from each observation's tiers.
139
+ """
140
+
141
+ def __init__(
142
+ self,
143
+ *,
144
+ epistemic_weight: float = 1.0,
145
+ pragmatic_weight: float = 1.0,
146
+ cost_penalty: float = 0.15,
147
+ actions: Sequence[Action] = DEFAULT_ACTIONS,
148
+ ) -> None:
149
+ self.epistemic_weight = float(epistemic_weight)
150
+ self.pragmatic_weight = float(pragmatic_weight)
151
+ self.cost_penalty = float(cost_penalty)
152
+ self.actions = tuple(actions)
153
+ self._beliefs: dict[str, list[float]] = {}
154
+
155
+ # -- belief management --------------------------------------------------
156
+ def _prior(self, candidate: Candidate) -> list[float]:
157
+ # Default prior: most configs are low value; nudge if a hint is given.
158
+ base = [0.40, 0.30, 0.20, 0.10]
159
+ if candidate.prior_value_hint is not None:
160
+ hint = max(0, min(3, candidate.prior_value_hint))
161
+ base = [0.1, 0.1, 0.1, 0.1]
162
+ base[hint] = 0.7
163
+ return _normalise(base)
164
+
165
+ def belief(self, candidate: Candidate) -> list[float]:
166
+ return list(self._beliefs.get(candidate.id, self._prior(candidate)))
167
+
168
+ def posterior(self, candidate_id: str) -> list[float] | None:
169
+ """The updated belief for a candidate id, or ``None`` if never observed."""
170
+ belief = self._beliefs.get(candidate_id)
171
+ return list(belief) if belief is not None else None
172
+
173
+ def update(
174
+ self, candidate_id: str, obs: Observation, prior: list[float] | None = None
175
+ ) -> list[float]:
176
+ """Bayesian belief update for a candidate from one observation."""
177
+ if prior is not None:
178
+ belief = prior
179
+ else:
180
+ belief = self._beliefs.get(candidate_id, [0.40, 0.30, 0.20, 0.10])
181
+ tiers = [obs.primary_tier] + ([obs.secondary_tier] if obs.secondary_tier is not None else [])
182
+ post = list(belief)
183
+ for tier in tiers:
184
+ post = [post[v] * _LIKELIHOOD[v][tier] for v in range(VALUE_LEVELS)]
185
+ post = _normalise(post)
186
+ self._beliefs[candidate_id] = post
187
+ return post
188
+
189
+ # -- EFE scoring --------------------------------------------------------
190
+ def _epistemic(self, belief: Sequence[float], action: Action) -> float:
191
+ """Expected info gain per GPU-hour: belief entropy * resolution / cost."""
192
+ return _entropy(belief) * action.resolution / max(action.gpu_hours, 1e-6)
193
+
194
+ def _pragmatic(self, belief: Sequence[float], action: Action) -> float:
195
+ """Expected preference satisfaction minus a cost penalty."""
196
+ utility = sum(p * u for p, u in zip(belief, _UTILITY, strict=True))
197
+ return utility - self.cost_penalty * action.gpu_hours
198
+
199
+ def score(self, candidate: Candidate, action: Action) -> Proposal:
200
+ belief = self.belief(candidate)
201
+ epi = self._epistemic(belief, action)
202
+ prag = self._pragmatic(belief, action)
203
+ efe = -(self.epistemic_weight * epi + self.pragmatic_weight * prag)
204
+ return Proposal(
205
+ candidate=candidate,
206
+ action=action,
207
+ efe=efe,
208
+ epistemic=epi,
209
+ pragmatic=prag,
210
+ belief=tuple(belief),
211
+ )
212
+
213
+ def select(
214
+ self,
215
+ candidates: Sequence[Candidate],
216
+ *,
217
+ budget_gpu_hours: float = float("inf"),
218
+ exclude: Sequence[str] = (),
219
+ ) -> Proposal:
220
+ """Pick the ``(candidate, action)`` minimising EFE within budget."""
221
+ if not candidates:
222
+ raise ValueError("no candidates to select from")
223
+ excluded = set(exclude)
224
+ best: Proposal | None = None
225
+ for cand in candidates:
226
+ if cand.id in excluded:
227
+ continue
228
+ for action in self.actions:
229
+ if action.gpu_hours > budget_gpu_hours:
230
+ continue
231
+ prop = self.score(cand, action)
232
+ if best is None or prop.efe < best.efe:
233
+ best = prop
234
+ if best is None:
235
+ raise ValueError("no (candidate, action) fits the GPU-hour budget")
236
+ return best
237
+
238
+ def rank(self, candidates: Sequence[Candidate]) -> list[Proposal]:
239
+ """Best action per candidate, sorted by ascending EFE (best first)."""
240
+ out = []
241
+ for cand in candidates:
242
+ props = [self.score(cand, a) for a in self.actions]
243
+ out.append(min(props, key=lambda p: p.efe))
244
+ out.sort(key=lambda p: p.efe)
245
+ return out
@@ -0,0 +1,34 @@
1
+ """gate_to_obs — discretise prabodha GateReports into EFE Observations.
2
+ Concept: gates are the selector's senses — each closed gate becomes a tiered observation
3
+ updating the belief about the experiment family it informs.
4
+ Source: docs/efe_port_plan.md adaptation #2 (prabhasa observation_from_gate was M3-gate
5
+ specific and is rewritten here for GateReport, src/prabodha/contracts/closure.py).
6
+ Primitive: gate JSON -> primary tier 0..3 from the domain verdict and the registered
7
+ hypotheses' value/threshold margins:
8
+ 3 = pass with headroom (all hypotheses pass, min margin >= 1.5x threshold)
9
+ 2 = pass (all hypotheses pass)
10
+ 1 = near-miss fail (best failing hypothesis reached >= 80% of its threshold)
11
+ 0 = fail (otherwise)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ from prabodha.efe.agent import Observation
19
+
20
+
21
+ def observation_from_gate(gate_path: str | Path) -> Observation:
22
+ g = json.loads(Path(gate_path).read_text(encoding="utf-8"))
23
+ ev = json.loads(g["domain_gate"]["evidence"])
24
+ summary = ev["summary"]
25
+ if g["domain_gate"]["verdict"] == "pass":
26
+ ratios = [v["value"] / v["threshold"] for v in summary.values()
27
+ if isinstance(v.get("threshold"), (int, float)) and v["threshold"]]
28
+ tier = 3 if ratios and min(ratios) >= 1.5 else 2
29
+ else:
30
+ failing = [v for v in summary.values() if not v["pass"]]
31
+ ratios = [v["value"] / v["threshold"] for v in failing
32
+ if isinstance(v.get("threshold"), (int, float)) and v["threshold"] > 0]
33
+ tier = 1 if ratios and max(ratios) >= 0.8 else 0
34
+ return Observation(primary_tier=tier)
prabodha/efe/ledger.py ADDED
@@ -0,0 +1,53 @@
1
+ """ledger — append-only JSONL record of proposals, observations, spends, skips.
2
+ Concept: anusaṃdhāna for the auto-researcher — beliefs are never stored, only re-derived
3
+ by replaying the ledger; any fresh agent rebuilds the same selector state (stateless
4
+ re-entry, same doctrine as the ralph loops).
5
+ Source: prabhasa application/efe/ledger.py (record format preserved; path adapted).
6
+ Primitive: EFELedger.log_* / records(); replay in runner.rebuild_selector.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from prabodha.efe.agent import Observation, Proposal
16
+
17
+
18
+ class EFELedger:
19
+ def __init__(self, path: str | Path = "research/efe_ledger.jsonl") -> None:
20
+ self.path = Path(path)
21
+ self.path.parent.mkdir(parents=True, exist_ok=True)
22
+
23
+ def _append(self, record: dict[str, Any]) -> None:
24
+ record["ts"] = time.time()
25
+ with self.path.open("a", encoding="utf-8") as f:
26
+ f.write(json.dumps(record) + "\n")
27
+
28
+ def log_proposal(self, proposal: Proposal, *, budget_remaining: float) -> None:
29
+ self._append({"event": "propose", "candidate": proposal.candidate.id,
30
+ "action": proposal.action.name, "efe": proposal.efe,
31
+ "epistemic": proposal.epistemic, "pragmatic": proposal.pragmatic,
32
+ "belief": list(proposal.belief),
33
+ "budget_remaining": budget_remaining})
34
+
35
+ def log_observation(self, candidate_id: str, obs: Observation,
36
+ posterior: list[float], *, source: str = "") -> None:
37
+ self._append({"event": "observe", "candidate": candidate_id,
38
+ "primary_tier": obs.primary_tier,
39
+ "secondary_tier": obs.secondary_tier,
40
+ "posterior": posterior, "source": source})
41
+
42
+ def log_spend(self, candidate_id: str, gpu_hours: float) -> None:
43
+ self._append({"event": "spend", "candidate": candidate_id,
44
+ "gpu_hours": gpu_hours})
45
+
46
+ def log_skip(self, candidate_id: str, reason: str) -> None:
47
+ self._append({"event": "skip", "candidate": candidate_id, "reason": reason})
48
+
49
+ def records(self) -> list[dict[str, Any]]:
50
+ if not self.path.exists():
51
+ return []
52
+ return [json.loads(line) for line in
53
+ self.path.read_text(encoding="utf-8").splitlines() if line.strip()]
prabodha/efe/lint.py ADDED
@@ -0,0 +1,44 @@
1
+ """lint — cycle-integrity checks over the EFE ledger (the staleness invariant as CODE).
2
+ Concept: review #10's rule, adopted as loop law — a disposition may only execute the
3
+ LATEST proposal; if new observations land after a proposal, it is STALE and must be
4
+ re-proposed; divergences are legal only as explicit ledgered events with reasons.
5
+ Source: contracts/L6_cycles.md loop mechanics; review #10 (d); journal 2026-07-10.
6
+ Primitive: lint_cycles(records) -> list of violations (empty = clean). Historical
7
+ divergences before the law's adoption are grandfathered via the cutoff timestamp.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ def lint_cycles(records: list[dict[str, Any]], *, adopted_ts: float = 0.0,
15
+ menu_sources: set[str] | frozenset[str] = frozenset()) -> list[str]:
16
+ """Check: every run-sourced observe (source set, and not a menu replay echo — pass the
17
+ menu's declared replay gates as menu_sources) must be preceded by either (a) a propose
18
+ for the SAME candidate, or (b) a ledgered divergence event naming it."""
19
+ violations: list[str] = []
20
+ last_propose: str | None = None
21
+ last_divergence: str | None = None
22
+ for rec in records:
23
+ ev = rec.get("event")
24
+ if ev == "propose":
25
+ last_propose = rec["candidate"]
26
+ elif ev == "divergence":
27
+ last_divergence = rec["candidate"]
28
+ elif (ev == "observe" and rec.get("source")
29
+ and rec.get("source") not in menu_sources):
30
+ cand = rec["candidate"]
31
+ if rec.get("ts", 0) < adopted_ts:
32
+ continue # grandfathered (pre-law history)
33
+ if cand != last_propose and cand != last_divergence:
34
+ violations.append(
35
+ f"run observation for '{cand}' (source={rec['source']}) executed "
36
+ f"without a matching proposal or ledgered divergence "
37
+ f"(last propose: {last_propose!r})")
38
+ last_divergence = None
39
+ return violations
40
+
41
+
42
+ def log_divergence(ledger, candidate_id: str, reason: str) -> None:
43
+ """Explicit, auditable divergence from the selector's latest proposal."""
44
+ ledger._append({"event": "divergence", "candidate": candidate_id, "reason": reason})
prabodha/efe/runner.py ADDED
@@ -0,0 +1,124 @@
1
+ """runner — rebuild the selector from menu + gates + ledger; propose the next experiment.
2
+ Concept: the proposing half of the auto-research loop. Beliefs are seeded by replaying the
3
+ menu's `replay` gates (what we HAVE seen) and any ledger observations, then EFE ranks the
4
+ menu within the remaining budget. Disposition is the agent's (standing authorization);
5
+ every proposal and disposition lands in the ledger.
6
+ Source: prabhasa application/efe/runner.py pattern; docs/efe_port_plan.md phase 2/3.
7
+ Primitive: build_from_configs -> (selector, candidates, budget); propose_next; CLI prints
8
+ the ranked menu (python -m prabodha.efe.runner --menu configs/efe_menu.yaml).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ from pathlib import Path
15
+
16
+ from prabodha.config import load
17
+ from prabodha.efe.agent import Action, Candidate, EFESelector, Proposal
18
+ from prabodha.efe.gate_to_obs import observation_from_gate
19
+ from prabodha.efe.ledger import EFELedger
20
+
21
+
22
+ def build_from_configs(menu_path: str | Path, *, ledger: EFELedger | None = None,
23
+ root: str | Path = ".") -> tuple[EFESelector, list[Candidate], float, dict[str, Action]]:
24
+ menu = load(menu_path, required=("candidates", "budget_gpu_hours"))
25
+ root = Path(root)
26
+ selector = EFESelector()
27
+ candidates: list[Candidate] = []
28
+ actions_by_id: dict[str, Action] = {}
29
+ for c in menu["candidates"]:
30
+ cand = Candidate(id=c["id"], description=c["description"],
31
+ knobs=dict(c.get("knobs", {})),
32
+ prior_value_hint=c.get("prior_value_hint"))
33
+ candidates.append(cand)
34
+ if "cost_gpu_hours" in c:
35
+ # review #7 P0: composite as-registered cost replaces the generic ladder
36
+ actions_by_id[cand.id] = Action("as-registered",
37
+ gpu_hours=float(c["cost_gpu_hours"]),
38
+ resolution=float(c.get("resolution", 0.6)))
39
+ # replay closed gates into this candidate's belief (prior -> posterior)
40
+ for gate in c.get("replay", []):
41
+ gp = root / gate
42
+ if not gp.exists():
43
+ continue
44
+ obs = observation_from_gate(gp)
45
+ post = selector.update(cand.id, obs, prior=selector.belief(cand))
46
+ if ledger is not None:
47
+ ledger.log_observation(cand.id, obs, post, source=str(gate))
48
+ # ledger replay: observations from RUNS (sources not among the menu's declared replay
49
+ # gates — those were already seeded above; without this, a completed run's observation
50
+ # is silently lost and the selector re-proposes it. Found live in cycle 2.)
51
+ menu_sources = {g for c in menu["candidates"] for g in c.get("replay", [])}
52
+ if ledger is not None:
53
+ from prabodha.efe.agent import Observation
54
+ for rec in ledger.records():
55
+ if rec.get("event") == "observe" and rec.get("source", "") not in menu_sources:
56
+ selector.update(rec["candidate"],
57
+ Observation(primary_tier=rec["primary_tier"],
58
+ secondary_tier=rec.get("secondary_tier")))
59
+ # budget scope: a menu's budget is debited ONLY by spends on ITS candidates
60
+ # (the ledger is program-global and spans menu generations — found live in cycle 7)
61
+ menu_ids = {c["id"] for c in menu["candidates"]}
62
+ spent = sum(r["gpu_hours"] for r in (ledger.records() if ledger else [])
63
+ if r.get("event") == "spend" and r.get("candidate") in menu_ids)
64
+ budget = float(menu["budget_gpu_hours"]) - spent
65
+ return selector, candidates, budget, actions_by_id
66
+
67
+
68
+ def propose_next(menu_path: str | Path, *, ledger: EFELedger,
69
+ root: str | Path = ".") -> Proposal:
70
+ selector, candidates, budget, actions = build_from_configs(menu_path, ledger=ledger,
71
+ root=root)
72
+ # consumption rule: a candidate whose registered question already has a RUN-sourced
73
+ # observation is consumed — re-running it verbatim has no marginal value; follow-ups
74
+ # are new menu entries. (Cycle-3 live finding: a tier-3 result raises pragmatic value
75
+ # and the raw EFE wants to re-run its winner.)
76
+ menu = load(menu_path, required=("candidates",))
77
+ menu_sources = {g for c in menu["candidates"] for g in c.get("replay", [])}
78
+ consumed = {rec["candidate"] for rec in ledger.records()
79
+ if rec.get("event") == "observe"
80
+ and rec.get("source", "") not in menu_sources and rec.get("source")}
81
+ blocked = {c["id"]: c["blocked"] for c in menu["candidates"] if c.get("blocked")}
82
+ best: Proposal | None = None
83
+ for cand in candidates:
84
+ act = actions.get(cand.id)
85
+ if cand.id in blocked:
86
+ if not any(r.get("event") == "skip" and r.get("candidate") == cand.id
87
+ for r in ledger.records()):
88
+ ledger.log_skip(cand.id, f"blocked: {blocked[cand.id]}")
89
+ continue
90
+ if act is None or act.gpu_hours > budget or cand.id in consumed:
91
+ continue
92
+ prop = selector.score(cand, act)
93
+ if best is None or prop.efe < best.efe:
94
+ best = prop
95
+ if best is None:
96
+ raise ValueError("no candidate fits the remaining budget")
97
+ ledger.log_proposal(best, budget_remaining=budget)
98
+ return best
99
+
100
+
101
+ def main(argv=None) -> None:
102
+ ap = argparse.ArgumentParser()
103
+ ap.add_argument("--menu", default="configs/efe_menu.yaml")
104
+ ap.add_argument("--ledger", default="research/efe_ledger.jsonl")
105
+ ap.add_argument("--propose", action="store_true",
106
+ help="log the top proposal to the ledger (else just print the ranking)")
107
+ a = ap.parse_args(argv)
108
+ ledger = EFELedger(a.ledger)
109
+ selector, candidates, budget, actions = build_from_configs(a.menu, ledger=None)
110
+ print(f"budget remaining: {budget:.2f} GPU-h; ranked menu (best first):")
111
+ ranked = sorted((selector.score(c, actions[c.id]) for c in candidates
112
+ if c.id in actions), key=lambda p: p.efe)
113
+ for p in ranked:
114
+ b = ", ".join(f"{x:.2f}" for x in p.belief)
115
+ print(f" {p.candidate.id:20s} action={p.action.name:7s} EFE={p.efe:+.3f} "
116
+ f"epi={p.epistemic:.3f} prag={p.pragmatic:+.3f} belief=[{b}]")
117
+ if a.propose:
118
+ prop = propose_next(a.menu, ledger=ledger)
119
+ print(json.dumps({"proposed": prop.candidate.id, "action": prop.action.name,
120
+ "efe": round(prop.efe, 4)}))
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
@@ -0,0 +1,10 @@
1
+ """prabodha.lens — public API for lens operations (fit, eval, vis).
2
+
3
+ Concept: ārambha (inception; the public gate to the instrument).
4
+ Source: docs/jspace_pratyabhijna_scoping.md; RULES R8 (public surface).
5
+ Primitive: public fit/eval/vis functions; internal modules for CLI dispatch.
6
+ """
7
+
8
+ from .public_api import fit, eval, vis
9
+
10
+ __all__ = ["fit", "eval", "vis"]