dendra 1.0.0rc1__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.

Potentially problematic release.


This version of dendra might be problematic. Click here for more details.

Files changed (66) hide show
  1. dendra/__init__.py +192 -0
  2. dendra/_examples/01_hello_world.py +59 -0
  3. dendra/_examples/17_exception_handling.py +180 -0
  4. dendra/_examples/19_autoresearch_loop.py +269 -0
  5. dendra/_examples/20_verifier_default.py +91 -0
  6. dendra/_examples/21_tournament.py +163 -0
  7. dendra/_examples/_stubs.py +194 -0
  8. dendra/_packing.py +280 -0
  9. dendra/analyzer.py +1570 -0
  10. dendra/auth.py +104 -0
  11. dendra/autoresearch.py +646 -0
  12. dendra/benchmarks/__init__.py +54 -0
  13. dendra/benchmarks/harness.py +745 -0
  14. dendra/benchmarks/loaders.py +375 -0
  15. dendra/benchmarks/rules.py +155 -0
  16. dendra/bundled.py +344 -0
  17. dendra/cli.py +2040 -0
  18. dendra/cloud/__init__.py +42 -0
  19. dendra/cloud/registry.py +97 -0
  20. dendra/cloud/report/__init__.py +69 -0
  21. dendra/cloud/report/aggregator.py +353 -0
  22. dendra/cloud/report/charts.py +348 -0
  23. dendra/cloud/report/discovery.py +414 -0
  24. dendra/cloud/report/hypotheses.py +310 -0
  25. dendra/cloud/report/render_markdown.py +394 -0
  26. dendra/cloud/report/summary.py +367 -0
  27. dendra/cloud/sync.py +81 -0
  28. dendra/cloud/team_corpus.py +94 -0
  29. dendra/core.py +2626 -0
  30. dendra/decorator.py +273 -0
  31. dendra/gates.py +546 -0
  32. dendra/image_rules.py +65 -0
  33. dendra/insights/__init__.py +103 -0
  34. dendra/insights/_paths.py +44 -0
  35. dendra/insights/disclosure.py +67 -0
  36. dendra/insights/enrollment.py +132 -0
  37. dendra/insights/events.py +340 -0
  38. dendra/insights/fingerprint.py +136 -0
  39. dendra/insights/tuned_defaults.py +333 -0
  40. dendra/license.py +199 -0
  41. dendra/lifters/__init__.py +108 -0
  42. dendra/lifters/branch.py +1575 -0
  43. dendra/lifters/evidence.py +1338 -0
  44. dendra/mcp_server.py +509 -0
  45. dendra/ml.py +511 -0
  46. dendra/ml_strategy.py +137 -0
  47. dendra/models.py +574 -0
  48. dendra/py.typed +0 -0
  49. dendra/refresh.py +267 -0
  50. dendra/research.py +425 -0
  51. dendra/roi.py +285 -0
  52. dendra/storage.py +1516 -0
  53. dendra/switch_class.py +480 -0
  54. dendra/telemetry.py +64 -0
  55. dendra/verdicts.py +758 -0
  56. dendra/viz.py +247 -0
  57. dendra/wrap.py +233 -0
  58. dendra-1.0.0rc1.dist-info/METADATA +660 -0
  59. dendra-1.0.0rc1.dist-info/RECORD +66 -0
  60. dendra-1.0.0rc1.dist-info/WHEEL +4 -0
  61. dendra-1.0.0rc1.dist-info/entry_points.txt +2 -0
  62. dendra-1.0.0rc1.dist-info/licenses/LICENSE-APACHE +190 -0
  63. dendra-1.0.0rc1.dist-info/licenses/LICENSE-BSL +139 -0
  64. dendra-1.0.0rc1.dist-info/licenses/LICENSE.md +52 -0
  65. dendra-1.0.0rc1.dist-info/licenses/LICENSING.md +84 -0
  66. dendra-1.0.0rc1.dist-info/licenses/NOTICE +27 -0
dendra/__init__.py ADDED
@@ -0,0 +1,192 @@
1
+ # Copyright (c) 2026 B-Tree Labs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Dendra — graduated-autonomy classification primitive.
5
+
6
+ Six-phase lifecycle from hand-written rule to learned classifier,
7
+ with a statistical transition gate at every phase, a safety-critical
8
+ cap that refuses construction in the highest-autonomy phase for
9
+ authorization-class decisions, a circuit breaker that reverts to the
10
+ rule on ML failure, and shadow-path isolation that keeps observational
11
+ classifiers from affecting user-visible output.
12
+
13
+ Core public API: :class:`LearnedSwitch`, :func:`ml_switch` decorator,
14
+ :class:`Phase`, :class:`SwitchConfig`, :class:`ClassificationRecord`,
15
+ :class:`FileStorage`, and the language model/ML protocol interfaces.
16
+
17
+ Tooling (analyzer, ROI reporter, AST-based `wrap_function`, viz,
18
+ research runners) ships in submodules — import directly:
19
+ ``from dendra.analyzer import analyze``, ``from dendra.roi import
20
+ compute_switch_roi``, etc.
21
+
22
+ See README.md and https://dendra.run.
23
+ """
24
+
25
+ __version__ = "1.0.0rc1"
26
+
27
+ from dendra.autoresearch import (
28
+ CandidateHarness,
29
+ CandidateReport,
30
+ Tournament,
31
+ TournamentReport,
32
+ )
33
+ from dendra.core import (
34
+ BulkVerdict,
35
+ BulkVerdictSummary,
36
+ ClassificationRecord,
37
+ ClassificationResult,
38
+ Label,
39
+ LearnedSwitch,
40
+ Phase,
41
+ SwitchConfig,
42
+ SwitchStatus,
43
+ Verdict,
44
+ )
45
+ from dendra.decorator import ml_switch
46
+ from dendra.gates import (
47
+ AccuracyMarginGate,
48
+ CompositeGate,
49
+ Gate,
50
+ GateDecision,
51
+ ManualGate,
52
+ McNemarGate,
53
+ MinVolumeGate,
54
+ next_phase,
55
+ prev_phase,
56
+ )
57
+ from dendra.ml import (
58
+ ImagePixelLogRegHead,
59
+ MLHead,
60
+ MLHeadFactory,
61
+ MLPrediction,
62
+ SklearnTextHead,
63
+ TfidfGradientBoostingHead,
64
+ TfidfHeadBase,
65
+ TfidfLinearSVCHead,
66
+ TfidfMultinomialNBHead,
67
+ available_ml_heads,
68
+ make_ml_head,
69
+ register_ml_head,
70
+ )
71
+ from dendra.ml_strategy import (
72
+ CardinalityMLHeadStrategy,
73
+ FixedMLHeadStrategy,
74
+ MLHeadStrategy,
75
+ )
76
+ from dendra.models import (
77
+ AnthropicAdapter,
78
+ AnthropicAsyncAdapter,
79
+ LlamafileAdapter,
80
+ LlamafileAsyncAdapter,
81
+ ModelClassifier,
82
+ ModelPrediction,
83
+ OllamaAdapter,
84
+ OllamaAsyncAdapter,
85
+ OpenAIAdapter,
86
+ OpenAIAsyncAdapter,
87
+ )
88
+ from dendra.storage import (
89
+ BoundedInMemoryStorage,
90
+ FileStorage,
91
+ InMemoryStorage,
92
+ ResilientStorage,
93
+ SqliteStorage,
94
+ Storage,
95
+ StorageBase,
96
+ deserialize_record,
97
+ flock_supported,
98
+ serialize_record,
99
+ )
100
+ from dendra.switch_class import Switch
101
+ from dendra.telemetry import (
102
+ ListEmitter,
103
+ NullEmitter,
104
+ StdoutEmitter,
105
+ TelemetryEmitter,
106
+ )
107
+ from dendra.verdicts import (
108
+ CallableVerdictSource,
109
+ HumanReviewerSource,
110
+ JudgeCommittee,
111
+ JudgeSource,
112
+ NoVerifierAvailableError,
113
+ VerdictSource,
114
+ WebhookVerdictSource,
115
+ default_verifier,
116
+ )
117
+
118
+ __all__ = [
119
+ "AccuracyMarginGate",
120
+ "AnthropicAdapter",
121
+ "AnthropicAsyncAdapter",
122
+ "BoundedInMemoryStorage",
123
+ "BulkVerdict",
124
+ "BulkVerdictSummary",
125
+ "CandidateHarness",
126
+ "CandidateReport",
127
+ "CardinalityMLHeadStrategy",
128
+ "CompositeGate",
129
+ "ClassificationRecord",
130
+ "ClassificationResult",
131
+ "FileStorage",
132
+ "FixedMLHeadStrategy",
133
+ "Gate",
134
+ "GateDecision",
135
+ "ImagePixelLogRegHead",
136
+ "InMemoryStorage",
137
+ "Label",
138
+ "LearnedSwitch",
139
+ "ListEmitter",
140
+ "LlamafileAdapter",
141
+ "LlamafileAsyncAdapter",
142
+ "MLHead",
143
+ "MLHeadFactory",
144
+ "MLHeadStrategy",
145
+ "MLPrediction",
146
+ "ManualGate",
147
+ "McNemarGate",
148
+ "MinVolumeGate",
149
+ "ModelClassifier",
150
+ "ModelPrediction",
151
+ "NullEmitter",
152
+ "OllamaAdapter",
153
+ "OllamaAsyncAdapter",
154
+ "OpenAIAdapter",
155
+ "OpenAIAsyncAdapter",
156
+ "Phase",
157
+ "ResilientStorage",
158
+ "SklearnTextHead",
159
+ "SqliteStorage",
160
+ "TfidfGradientBoostingHead",
161
+ "TfidfHeadBase",
162
+ "TfidfLinearSVCHead",
163
+ "TfidfMultinomialNBHead",
164
+ "StdoutEmitter",
165
+ "Storage",
166
+ "StorageBase",
167
+ "Switch",
168
+ "SwitchConfig",
169
+ "SwitchStatus",
170
+ "CallableVerdictSource",
171
+ "HumanReviewerSource",
172
+ "JudgeCommittee",
173
+ "JudgeSource",
174
+ "TelemetryEmitter",
175
+ "Tournament",
176
+ "TournamentReport",
177
+ "Verdict",
178
+ "VerdictSource",
179
+ "NoVerifierAvailableError",
180
+ "WebhookVerdictSource",
181
+ "__version__",
182
+ "available_ml_heads",
183
+ "default_verifier",
184
+ "deserialize_record",
185
+ "flock_supported",
186
+ "make_ml_head",
187
+ "ml_switch",
188
+ "next_phase",
189
+ "prev_phase",
190
+ "register_ml_head",
191
+ "serialize_record",
192
+ ]
@@ -0,0 +1,59 @@
1
+ # Copyright (c) 2026 B-Tree Labs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Dendra hello-world — rule + dispatch in the smallest form.
4
+
5
+ Run: `python examples/01_hello_world.py`
6
+
7
+ Pass ``labels=`` as a dict mapping label → action, call
8
+ ``rule.dispatch(input)``, done. Each dict entry is a
9
+ **label-based conditional expression** — "when the classifier's
10
+ output equals this label, evaluate this action."
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dendra import ml_switch
16
+
17
+
18
+ def send_to_engineering(ticket: dict) -> str:
19
+ """Action fired when the classifier returns ``label="bug"``."""
20
+ return f"engineering ← {ticket['title']}"
21
+
22
+
23
+ def send_to_product(ticket: dict) -> str:
24
+ """Action fired when the classifier returns ``label="feature_request"``."""
25
+ return f"product ← {ticket['title']}"
26
+
27
+
28
+ def send_to_support(ticket: dict) -> str:
29
+ """Action fired when the classifier returns ``label="question"``."""
30
+ return f"support ← {ticket['title']}"
31
+
32
+
33
+ @ml_switch(
34
+ labels={
35
+ "bug": send_to_engineering,
36
+ "feature_request": send_to_product,
37
+ "question": send_to_support,
38
+ },
39
+ # `author` omitted — auto-derives to "@__main__:triage_rule".
40
+ )
41
+ def triage_rule(ticket: dict) -> str:
42
+ """Classify one ticket into exactly one of the declared labels."""
43
+ title = (ticket.get("title") or "").lower()
44
+ if "crash" in title or "error" in title:
45
+ return "bug"
46
+ if title.endswith("?"):
47
+ return "question"
48
+ return "feature_request"
49
+
50
+
51
+ if __name__ == "__main__":
52
+ samples = [
53
+ {"title": "app crashes on login"},
54
+ {"title": "how do I reset my password?"},
55
+ {"title": "add dark mode"},
56
+ ]
57
+ for sample in samples:
58
+ c = triage_rule.dispatch(sample)
59
+ print(f"{sample['title']:40s} → label={c.label:18s} → action={c.action_result}")
@@ -0,0 +1,180 @@
1
+ # Copyright (c) 2026 B-Tree Labs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Using a Dendra switch as an exception-handling dispatcher.
4
+
5
+ Run: `python examples/17_exception_handling.py`
6
+
7
+ A try/except tree that picks among retry, fallback, escalate,
8
+ and drop is a classifier — input is ``(exception, context)``,
9
+ output is one of a fixed strategy set. Wrapping the dispatch in
10
+ a :class:`LearnedSwitch` records every decision on the outcome
11
+ log and lets a learned policy graduate against the hand-written
12
+ rule once enough outcomes accumulate.
13
+
14
+ Day 0 (RULE)
15
+ Hand-written dispatch on exception type + HTTP status.
16
+ Conservative defaults — always retry on 5xx, never retry on
17
+ 4xx, escalate ``RuntimeError`` to the queue.
18
+
19
+ Day N (MODEL_SHADOW → ML_PRIMARY)
20
+ As outcomes accumulate (did the retry succeed? did the
21
+ fallback produce an acceptable answer?), the ML head learns
22
+ endpoint-specific patterns the rule can't see:
23
+ "endpoint X's 503s clear in 2 s; endpoint Y's are permanent"
24
+ or "this auth error on tenant Z is actually a billing
25
+ suspension, route to CS." The rule remains the floor (paper
26
+ §7.1) — a buggy learned policy cannot remove the
27
+ "503 → retry" baseline.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import random
33
+ from dataclasses import dataclass
34
+
35
+ from dendra import LearnedSwitch, Verdict
36
+
37
+
38
+ @dataclass
39
+ class FailureContext:
40
+ """Everything a classifier sees about a failure."""
41
+
42
+ exception_type: str
43
+ http_status: int | None = None
44
+ endpoint: str = ""
45
+ attempt: int = 1
46
+ elapsed_ms: float = 0.0
47
+
48
+
49
+ # The four strategies the classifier chooses between. Each is a
50
+ # distinct post-failure action. "retry" means loop + backoff;
51
+ # "fallback" means route to cache/default; "escalate" means push
52
+ # onto the ops queue; "drop" means log and continue (non-critical).
53
+ STRATEGIES = ["retry", "fallback", "escalate", "drop"]
54
+
55
+
56
+ def handling_rule(ctx: FailureContext) -> str:
57
+ """Day-0 exception-handling dispatch.
58
+
59
+ Keyword-matched on exception type + HTTP status. Conservative
60
+ by design — anything ambiguous escalates.
61
+ """
62
+ # Transient server-side — retry bounded.
63
+ if ctx.http_status in (502, 503, 504) and ctx.attempt < 3:
64
+ return "retry"
65
+ # Client-side auth / permission — not retryable; needs a human.
66
+ if ctx.http_status in (401, 403):
67
+ return "escalate"
68
+ # Validation errors on submitted data — the caller's problem.
69
+ if ctx.exception_type == "ValueError":
70
+ return "drop"
71
+ # Anything timing-out — fall back to a cached answer rather than
72
+ # burning more budget.
73
+ if ctx.exception_type == "TimeoutError":
74
+ return "fallback"
75
+ # Runtime / programmer errors — don't retry; a human should look.
76
+ if ctx.exception_type in ("RuntimeError", "KeyError", "AttributeError"):
77
+ return "escalate"
78
+ # Default: one-shot retry then give up.
79
+ return "retry" if ctx.attempt < 2 else "escalate"
80
+
81
+
82
+ def main() -> None:
83
+ # Dispatch table: each strategy has a real action. In production
84
+ # these would call your retry loop, cache lookup, pager queue, etc.
85
+ def do_retry(ctx: FailureContext) -> str:
86
+ return f"requeued {ctx.endpoint} (attempt {ctx.attempt + 1})"
87
+
88
+ def do_fallback(ctx: FailureContext) -> str:
89
+ return f"served cached response for {ctx.endpoint}"
90
+
91
+ def do_escalate(ctx: FailureContext) -> str:
92
+ return f"pushed {ctx.endpoint} + {ctx.exception_type} to ops queue"
93
+
94
+ def do_drop(ctx: FailureContext) -> str:
95
+ return f"logged {ctx.exception_type} and continued"
96
+
97
+ sw = LearnedSwitch(
98
+ rule=handling_rule,
99
+ labels={
100
+ "retry": do_retry,
101
+ "fallback": do_fallback,
102
+ "escalate": do_escalate,
103
+ "drop": do_drop,
104
+ },
105
+ auto_advance=False, # deterministic example output
106
+ )
107
+
108
+ # Simulated failure stream. In production this feeds off your
109
+ # real exception traffic — a decorator on your HTTP client, a
110
+ # middleware on your message handler, an ``except`` block that
111
+ # calls ``sw.dispatch(ctx)`` instead of inlining its own ladder.
112
+ failures = [
113
+ FailureContext("HTTPError", http_status=503, endpoint="/api/v1/users", attempt=1),
114
+ FailureContext("HTTPError", http_status=401, endpoint="/api/v1/billing", attempt=1),
115
+ FailureContext("TimeoutError", endpoint="/api/v1/search", attempt=1, elapsed_ms=5000),
116
+ FailureContext("ValueError", endpoint="/api/v1/parse", attempt=1),
117
+ FailureContext("RuntimeError", endpoint="/api/v1/internal", attempt=1),
118
+ FailureContext("HTTPError", http_status=504, endpoint="/api/v1/export", attempt=3),
119
+ ]
120
+
121
+ print("Day 0: rule-based exception handling")
122
+ print("-" * 78)
123
+ for ctx in failures:
124
+ result = sw.dispatch(ctx)
125
+ print(
126
+ f" {ctx.exception_type:14s} "
127
+ f"status={str(ctx.http_status or '-'):>4s} "
128
+ f"attempt={ctx.attempt} "
129
+ f"-> {result.label:9s} {result.action_result}"
130
+ )
131
+
132
+ # As outcomes accumulate, reviewers label whether each strategy
133
+ # actually worked. Simulated here; in production, a downstream
134
+ # signal (did the retry succeed? did the escalated ticket get
135
+ # resolved as a real bug?) feeds record_verdict.
136
+ print("\nLabeling outcomes (simulated downstream signal)...")
137
+ records = sw.storage.load_records(sw.name)
138
+ for r in records:
139
+ # Toy oracle: retries on 5xx worked; escalations on 4xx worked;
140
+ # fallbacks on timeouts worked; drops on ValueError worked.
141
+ # Everything else is "incorrect" — the rule overreached.
142
+ ctx = r.input
143
+ rule_pick = r.label
144
+ ok = (
145
+ (rule_pick == "retry" and ctx.http_status in (502, 503, 504))
146
+ or (rule_pick == "escalate" and ctx.http_status in (401, 403))
147
+ or (rule_pick == "fallback" and ctx.exception_type == "TimeoutError")
148
+ or (rule_pick == "drop" and ctx.exception_type == "ValueError")
149
+ or (
150
+ rule_pick == "escalate"
151
+ and ctx.exception_type in ("RuntimeError", "KeyError", "AttributeError")
152
+ )
153
+ )
154
+ sw.record_verdict(
155
+ input=ctx,
156
+ label=rule_pick,
157
+ outcome=Verdict.CORRECT.value if ok else Verdict.INCORRECT.value,
158
+ source="downstream-signal",
159
+ )
160
+
161
+ status = sw.status()
162
+ print(
163
+ f"outcome log: total={status.outcomes_total}, "
164
+ f"correct={status.outcomes_correct}, "
165
+ f"incorrect={status.outcomes_incorrect}"
166
+ )
167
+ print(
168
+ "\nNext step (not shown here): as the log grows, an ML head "
169
+ "trained on (ctx → correct_strategy) pairs graduates into\n"
170
+ "MODEL_SHADOW / MODEL_PRIMARY. The rule stays as the safety "
171
+ "floor; the learned policy fires only when the evidence gate\n"
172
+ "confirms it beats the rule head-to-head with p < 0.05. See "
173
+ "examples/06_ml_primary.py for the full lifecycle."
174
+ )
175
+
176
+
177
+ if __name__ == "__main__":
178
+ # Seed for reproducible output.
179
+ random.seed(0)
180
+ main()
@@ -0,0 +1,269 @@
1
+ # Copyright (c) 2026 B-Tree Labs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Karpathy-style autoresearch loop, gated by ``CandidateHarness``.
4
+
5
+ Run: `python examples/19_autoresearch_loop.py`
6
+
7
+ The autoresearch loop pattern (popularised by Andrej Karpathy):
8
+
9
+ +------------+ +------------+ +------------+
10
+ | PROPOSE | ---> | EVALUATE | ---> | REFLECT | ---+
11
+ +------------+ +------------+ +------------+ |
12
+ ^ |
13
+ +---------------------- next iter ------------------+
14
+
15
+ A language-model agent reads recent results (REFLECT), proposes a fresh
16
+ candidate classifier (PROPOSE), the candidate runs against a
17
+ truth oracle (EVALUATE), and the loop repeats. The agent decides
18
+ when to stop.
19
+
20
+ This example wires that loop. ``CandidateHarness`` is the
21
+ EVALUATE rung — it shadows each candidate against the production
22
+ switch on the same inputs, runs a head-to-head significance
23
+ test, and returns a ``CandidateReport``. The loop's ``REFLECT``
24
+ step reads that report; the loop's ``PROPOSE`` step is the place
25
+ a language-model agent plugs in.
26
+
27
+ The propose-step here is a deterministic stand-in (a hardcoded
28
+ ratchet through known keyword expansions) so the example runs
29
+ offline with no API keys. Replace ``propose_next_candidate`` with
30
+ a language-model-backed function and the rest of the loop is unchanged.
31
+
32
+ What the harness gives your loop, in one line each:
33
+
34
+ - *Bounded false-promotion rate.* False promotions are
35
+ capped at the ``alpha`` you pass (default ``0.05``), not
36
+ by the candidate generator's appetite.
37
+ - *Faster convergence per paired sample.* Head-to-head
38
+ testing on the same inputs is statistically tighter than
39
+ independent-samples testing — 1.7–6× faster on the four
40
+ NLU benchmarks shipped here.
41
+ - *Reproducible promotion decisions.* Every recommendation
42
+ carries a p-value and the discordant-pair counts
43
+ (``b``, ``c``) that drove it.
44
+
45
+ For the situations where each of these matters most — and
46
+ the situations where they don't — see ``docs/scenarios.md``.
47
+ That doc opens with an at-a-glance table of high-volume,
48
+ high-stakes industries (card fraud, AML, SOC, prior-auth,
49
+ brand safety, returns fraud, customs) where the autoresearch
50
+ loop's promotion gate carries direct financial weight.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import random
56
+ from collections.abc import Callable
57
+
58
+ from dendra import (
59
+ CandidateHarness,
60
+ CandidateReport,
61
+ LearnedSwitch,
62
+ )
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # The loop
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ def main() -> None:
70
+ sw = LearnedSwitch(rule=production_rule)
71
+ harness = CandidateHarness(switch=sw, truth_oracle=truth_oracle, alpha=0.05)
72
+
73
+ print("Production rule misses bugs phrased as 'error'/'down'/'stuck'/'broken'.")
74
+ print("Loop will propose keyword expansions; harness gates each.\n")
75
+
76
+ history: list[CandidateReport] = []
77
+ for iter_num in range(1, MAX_ITERS + 1):
78
+ # 1. PROPOSE — the agent reads history and produces a new candidate.
79
+ # Swap this for a model call: read history, decide what to try next.
80
+ proposal = propose_next_candidate(history)
81
+ if proposal is None:
82
+ print("Agent says: nothing left to try. Stopping.")
83
+ break
84
+ name, candidate = proposal
85
+
86
+ # 2. EVALUATE — harness shadows the candidate, head-to-head vs production.
87
+ harness.register(name, candidate)
88
+ harness.observe_batch(_TICKETS)
89
+ report = harness.evaluate(name)
90
+
91
+ # 3. REFLECT — the agent reads the report. Stop on convergence or
92
+ # diminishing returns; otherwise feed it into the next propose.
93
+ history.append(report)
94
+ _print_report(iter_num, report)
95
+ if should_stop(report):
96
+ print("Agent says: candidate accuracy at ceiling. Stopping.")
97
+ break
98
+
99
+ promoted = [r.candidate_name for r in history if r.recommend_promote]
100
+ print(f"\nLoop complete after {len(history)} iteration(s).")
101
+ print(f"Recommended for promotion: {promoted}")
102
+ print(
103
+ "\nIn production, the loop reads ``report.recommend_promote`` "
104
+ "and acts on it: swap the candidate into the switch's rule, "
105
+ "ml_head, or model and ship through your normal deployment "
106
+ "process. The rule floor stays in place throughout — even a "
107
+ "bad promotion can't remove the day-zero safety net."
108
+ )
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # The three loop steps (PROPOSE / EVALUATE / REFLECT)
113
+ # ---------------------------------------------------------------------------
114
+ #
115
+ # EVALUATE is implemented by ``CandidateHarness.evaluate`` directly;
116
+ # the other two are below. The PROPOSE step is the language-model agent hook.
117
+
118
+
119
+ # Deterministic stand-in for the language model proposer. Real loops use an
120
+ # agent that reads ``history`` (the prior CandidateReports + the
121
+ # ground-truth misses each surface) and proposes the next refinement.
122
+ _PROGRESSION = [
123
+ ("v1_kw2", ("crash", "error")),
124
+ ("v2_kw3", ("crash", "error", "down")),
125
+ ("v3_kw4", ("crash", "error", "down", "stuck")),
126
+ ("v4_kw5", ("crash", "error", "down", "stuck", "broken")),
127
+ ]
128
+
129
+
130
+ def propose_next_candidate(
131
+ history: list[CandidateReport],
132
+ ) -> tuple[str, Callable[[dict], str]] | None:
133
+ """PROPOSE step. Returns ``(name, candidate_classifier)`` or None to stop.
134
+
135
+ Production swap-in: a language-model agent that reads ``history`` (the
136
+ prior reports), inspects which inputs the production rule got
137
+ wrong, and emits the next refinement to try.
138
+ """
139
+ idx = len(history)
140
+ if idx >= len(_PROGRESSION):
141
+ return None
142
+ name, keywords = _PROGRESSION[idx]
143
+ return name, _make_keyword_rule(keywords)
144
+
145
+
146
+ def should_stop(report: CandidateReport) -> bool:
147
+ """REFLECT step's stop-decision. Real loops layer in plateau detection,
148
+ cost ceilings, or agent-controlled halt. Here: stop on perfect
149
+ accuracy."""
150
+ return report.candidate_accuracy >= 1.0
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # World setup — production rule, truth oracle, evaluation traffic.
155
+ # Below the loop because the loop is the point of the file.
156
+ # ---------------------------------------------------------------------------
157
+ #
158
+ # Ground-truth labeling rule (what we WISH the classifier knew):
159
+ # - "crash"/"error"/"down"/"stuck"/"broken" → bug
160
+ # - "?" at end → question
161
+ # - everything else → feature_request
162
+ #
163
+ # Production rule (what was hand-written six months ago):
164
+ # - "crash" only → bug
165
+ # - "?" at end → question
166
+ # - everything else → feature_request
167
+ #
168
+ # The production rule misses bugs phrased as "error"/"down"/
169
+ # "stuck"/"broken". Day zero, the team didn't think of those
170
+ # keywords. The autoresearch loop's job is to discover them — and
171
+ # the harness's job is to gate the discovery.
172
+
173
+ _BUG_KEYWORDS_TRUTH = ("crash", "error", "down", "stuck", "broken")
174
+
175
+ # Stop after this many propose-evaluate cycles even if the agent
176
+ # keeps proposing. Cost ceiling on a real loop.
177
+ MAX_ITERS = 10
178
+
179
+
180
+ def production_rule(ticket: dict) -> str:
181
+ """Six-month-old hand-written rule. Only catches 'crash' for bugs."""
182
+ title = (ticket.get("title") or "").lower()
183
+ if "crash" in title:
184
+ return "bug"
185
+ if title.endswith("?"):
186
+ return "question"
187
+ return "feature_request"
188
+
189
+
190
+ def truth_oracle(ticket: dict) -> str:
191
+ """Ground truth — what a perfect classifier would return.
192
+
193
+ In production: a labeled validation set, a downstream signal
194
+ (with a wrapper that waits for it), a reviewer-pool aggregator,
195
+ or a language-model judge committee with bias guardrails.
196
+ """
197
+ title = (ticket.get("title") or "").lower()
198
+ if any(kw in title for kw in _BUG_KEYWORDS_TRUTH):
199
+ return "bug"
200
+ if title.endswith("?"):
201
+ return "question"
202
+ return "feature_request"
203
+
204
+
205
+ def _make_keyword_rule(keywords: tuple[str, ...]) -> Callable[[dict], str]:
206
+ """Factory: build a classifier that flags 'bug' on any of these keywords."""
207
+
208
+ def rule(ticket: dict) -> str:
209
+ title = (ticket.get("title") or "").lower()
210
+ if any(kw in title for kw in keywords):
211
+ return "bug"
212
+ if title.endswith("?"):
213
+ return "question"
214
+ return "feature_request"
215
+
216
+ return rule
217
+
218
+
219
+ # Sample tickets the loop evaluates against. In production this is
220
+ # live traffic; for the example we hard-code a representative mix.
221
+ _TICKETS = [
222
+ # Bugs the production rule catches:
223
+ {"title": "app crashes on startup"},
224
+ {"title": "page crashes when I scroll"},
225
+ # Bugs the production rule MISSES (no "crash" keyword):
226
+ {"title": "got an error trying to log in"},
227
+ {"title": "the search index is down"},
228
+ {"title": "checkout is stuck on step 3"},
229
+ {"title": "broken: cannot upload files"},
230
+ {"title": "error 500 on dashboard refresh"},
231
+ {"title": "service is down for our team"},
232
+ # Real questions:
233
+ {"title": "how do I export data?"},
234
+ {"title": "is dark mode coming?"},
235
+ # Real feature requests:
236
+ {"title": "please add SAML support"},
237
+ {"title": "add a CSV export option"},
238
+ {"title": "i would love a dark theme"},
239
+ {"title": "support for keyboard shortcuts"},
240
+ # More bugs (mixed keywords):
241
+ {"title": "checkout error during payment"},
242
+ {"title": "page is broken after the update"},
243
+ {"title": "crash report attached"},
244
+ {"title": "everything is down right now"},
245
+ # More features:
246
+ {"title": "feature: bulk-upload UI"},
247
+ {"title": "add a setting for default view"},
248
+ ] * 5 # repeat to give the harness statistical power
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Reporting
253
+ # ---------------------------------------------------------------------------
254
+
255
+
256
+ def _print_report(iter_num: int, report: CandidateReport) -> None:
257
+ verdict = "PROMOTE" if report.recommend_promote else "HOLD "
258
+ print(
259
+ f"iter {iter_num}: {report.candidate_name:14s} "
260
+ f"prod_acc={report.prod_accuracy:.1%} "
261
+ f"cand_acc={report.candidate_accuracy:.1%} "
262
+ f"b={report.b} c={report.c} p={report.p_value:.2e} "
263
+ f"-> {verdict}"
264
+ )
265
+
266
+
267
+ if __name__ == "__main__":
268
+ random.seed(0)
269
+ main()