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.
- dendra/__init__.py +192 -0
- dendra/_examples/01_hello_world.py +59 -0
- dendra/_examples/17_exception_handling.py +180 -0
- dendra/_examples/19_autoresearch_loop.py +269 -0
- dendra/_examples/20_verifier_default.py +91 -0
- dendra/_examples/21_tournament.py +163 -0
- dendra/_examples/_stubs.py +194 -0
- dendra/_packing.py +280 -0
- dendra/analyzer.py +1570 -0
- dendra/auth.py +104 -0
- dendra/autoresearch.py +646 -0
- dendra/benchmarks/__init__.py +54 -0
- dendra/benchmarks/harness.py +745 -0
- dendra/benchmarks/loaders.py +375 -0
- dendra/benchmarks/rules.py +155 -0
- dendra/bundled.py +344 -0
- dendra/cli.py +2040 -0
- dendra/cloud/__init__.py +42 -0
- dendra/cloud/registry.py +97 -0
- dendra/cloud/report/__init__.py +69 -0
- dendra/cloud/report/aggregator.py +353 -0
- dendra/cloud/report/charts.py +348 -0
- dendra/cloud/report/discovery.py +414 -0
- dendra/cloud/report/hypotheses.py +310 -0
- dendra/cloud/report/render_markdown.py +394 -0
- dendra/cloud/report/summary.py +367 -0
- dendra/cloud/sync.py +81 -0
- dendra/cloud/team_corpus.py +94 -0
- dendra/core.py +2626 -0
- dendra/decorator.py +273 -0
- dendra/gates.py +546 -0
- dendra/image_rules.py +65 -0
- dendra/insights/__init__.py +103 -0
- dendra/insights/_paths.py +44 -0
- dendra/insights/disclosure.py +67 -0
- dendra/insights/enrollment.py +132 -0
- dendra/insights/events.py +340 -0
- dendra/insights/fingerprint.py +136 -0
- dendra/insights/tuned_defaults.py +333 -0
- dendra/license.py +199 -0
- dendra/lifters/__init__.py +108 -0
- dendra/lifters/branch.py +1575 -0
- dendra/lifters/evidence.py +1338 -0
- dendra/mcp_server.py +509 -0
- dendra/ml.py +511 -0
- dendra/ml_strategy.py +137 -0
- dendra/models.py +574 -0
- dendra/py.typed +0 -0
- dendra/refresh.py +267 -0
- dendra/research.py +425 -0
- dendra/roi.py +285 -0
- dendra/storage.py +1516 -0
- dendra/switch_class.py +480 -0
- dendra/telemetry.py +64 -0
- dendra/verdicts.py +758 -0
- dendra/viz.py +247 -0
- dendra/wrap.py +233 -0
- dendra-1.0.0rc1.dist-info/METADATA +660 -0
- dendra-1.0.0rc1.dist-info/RECORD +66 -0
- dendra-1.0.0rc1.dist-info/WHEEL +4 -0
- dendra-1.0.0rc1.dist-info/entry_points.txt +2 -0
- dendra-1.0.0rc1.dist-info/licenses/LICENSE-APACHE +190 -0
- dendra-1.0.0rc1.dist-info/licenses/LICENSE-BSL +139 -0
- dendra-1.0.0rc1.dist-info/licenses/LICENSE.md +52 -0
- dendra-1.0.0rc1.dist-info/licenses/LICENSING.md +84 -0
- 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()
|