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 +0 -0
- prabodha/cli.py +49 -0
- prabodha/config.py +15 -0
- prabodha/contracts/__init__.py +0 -0
- prabodha/contracts/closure.py +33 -0
- prabodha/contracts/trace.py +44 -0
- prabodha/efe/__init__.py +7 -0
- prabodha/efe/agent.py +245 -0
- prabodha/efe/gate_to_obs.py +34 -0
- prabodha/efe/ledger.py +53 -0
- prabodha/efe/lint.py +44 -0
- prabodha/efe/runner.py +124 -0
- prabodha/lens/__init__.py +10 -0
- prabodha/lens/adapter.py +169 -0
- prabodha/lens/compare_cli.py +71 -0
- prabodha/lens/e1_cli.py +79 -0
- prabodha/lens/e1_metrics.py +501 -0
- prabodha/lens/fit_cli.py +24 -0
- prabodha/lens/public_api.py +111 -0
- prabodha/lens/vis_cli.py +44 -0
- prabodha/stats/__init__.py +0 -0
- prabodha/stats/core.py +71 -0
- prabodha/steer/__init__.py +10 -0
- prabodha/steering/__init__.py +8 -0
- prabodha/steering/bridge_trained.py +163 -0
- prabodha/steering/e3_cli.py +169 -0
- prabodha/steering/e4_cli.py +460 -0
- prabodha/steering/injector.py +83 -0
- prabodha/steering/public_api.py +125 -0
- prabodha/steering/scoring.py +30 -0
- prabodha/steering/timing.py +170 -0
- prabodha/steering/verifier.py +69 -0
- prabodha/steering/writer.py +64 -0
- prabodha-1.0.0.dist-info/METADATA +156 -0
- prabodha-1.0.0.dist-info/RECORD +40 -0
- prabodha-1.0.0.dist-info/WHEEL +5 -0
- prabodha-1.0.0.dist-info/entry_points.txt +2 -0
- prabodha-1.0.0.dist-info/licenses/LICENSE +202 -0
- prabodha-1.0.0.dist-info/licenses/NOTICE +8 -0
- prabodha-1.0.0.dist-info/top_level.txt +1 -0
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
|
prabodha/efe/__init__.py
ADDED
|
@@ -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"]
|