postrule 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. postrule/__init__.py +212 -0
  2. postrule/_examples/01_hello_world.py +59 -0
  3. postrule/_examples/17_exception_handling.py +180 -0
  4. postrule/_examples/19_autoresearch_loop.py +269 -0
  5. postrule/_examples/20_verifier_default.py +91 -0
  6. postrule/_examples/21_tournament.py +163 -0
  7. postrule/_examples/_stubs.py +194 -0
  8. postrule/_packing.py +280 -0
  9. postrule/analyzer.py +1603 -0
  10. postrule/auth.py +147 -0
  11. postrule/autoresearch.py +646 -0
  12. postrule/benchmarks/__init__.py +54 -0
  13. postrule/benchmarks/harness.py +745 -0
  14. postrule/benchmarks/loaders.py +375 -0
  15. postrule/benchmarks/rules.py +155 -0
  16. postrule/bundled.py +344 -0
  17. postrule/cli.py +2223 -0
  18. postrule/cloud/__init__.py +42 -0
  19. postrule/cloud/registry.py +97 -0
  20. postrule/cloud/report/__init__.py +69 -0
  21. postrule/cloud/report/aggregator.py +353 -0
  22. postrule/cloud/report/charts.py +348 -0
  23. postrule/cloud/report/discovery.py +414 -0
  24. postrule/cloud/report/hypotheses.py +310 -0
  25. postrule/cloud/report/render_markdown.py +394 -0
  26. postrule/cloud/report/summary.py +367 -0
  27. postrule/cloud/sync.py +81 -0
  28. postrule/cloud/team_corpus.py +94 -0
  29. postrule/cloud/verdict_telemetry.py +464 -0
  30. postrule/core.py +2681 -0
  31. postrule/decorator.py +273 -0
  32. postrule/gates.py +580 -0
  33. postrule/image_rules.py +65 -0
  34. postrule/insights/__init__.py +103 -0
  35. postrule/insights/_paths.py +44 -0
  36. postrule/insights/disclosure.py +67 -0
  37. postrule/insights/enrollment.py +132 -0
  38. postrule/insights/events.py +340 -0
  39. postrule/insights/fingerprint.py +136 -0
  40. postrule/insights/tuned_defaults.py +333 -0
  41. postrule/license.py +199 -0
  42. postrule/lifters/__init__.py +108 -0
  43. postrule/lifters/branch.py +1575 -0
  44. postrule/lifters/evidence.py +1338 -0
  45. postrule/mcp_server.py +509 -0
  46. postrule/ml.py +583 -0
  47. postrule/ml_strategy.py +137 -0
  48. postrule/models.py +574 -0
  49. postrule/py.typed +0 -0
  50. postrule/refresh.py +267 -0
  51. postrule/research.py +425 -0
  52. postrule/roi.py +285 -0
  53. postrule/storage.py +1516 -0
  54. postrule/switch_class.py +480 -0
  55. postrule/telemetry.py +167 -0
  56. postrule/verdicts.py +758 -0
  57. postrule/viz.py +259 -0
  58. postrule/wrap.py +233 -0
  59. postrule-1.1.0.dist-info/METADATA +675 -0
  60. postrule-1.1.0.dist-info/RECORD +67 -0
  61. postrule-1.1.0.dist-info/WHEEL +4 -0
  62. postrule-1.1.0.dist-info/entry_points.txt +2 -0
  63. postrule-1.1.0.dist-info/licenses/LICENSE-APACHE +190 -0
  64. postrule-1.1.0.dist-info/licenses/LICENSE-BSL +139 -0
  65. postrule-1.1.0.dist-info/licenses/LICENSE.md +52 -0
  66. postrule-1.1.0.dist-info/licenses/LICENSING.md +84 -0
  67. postrule-1.1.0.dist-info/licenses/NOTICE +27 -0
postrule/__init__.py ADDED
@@ -0,0 +1,212 @@
1
+ # Copyright (c) 2026 B-Tree Labs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Postrule — 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 postrule.analyzer import analyze``, ``from postrule.roi import
20
+ compute_switch_roi``, etc.
21
+
22
+ See README.md and https://postrule.ai.
23
+ """
24
+
25
+ __version__ = "1.1.0"
26
+
27
+ from postrule.autoresearch import (
28
+ CandidateHarness,
29
+ CandidateReport,
30
+ Tournament,
31
+ TournamentReport,
32
+ )
33
+ from postrule.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 postrule.decorator import ml_switch
46
+ from postrule.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 postrule.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 postrule.ml_strategy import (
72
+ CardinalityMLHeadStrategy,
73
+ FixedMLHeadStrategy,
74
+ MLHeadStrategy,
75
+ )
76
+ from postrule.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 postrule.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 postrule.switch_class import Switch
101
+ from postrule.telemetry import (
102
+ ListEmitter,
103
+ NullEmitter,
104
+ StdoutEmitter,
105
+ TelemetryEmitter,
106
+ )
107
+ from postrule.verdicts import (
108
+ CallableVerdictSource,
109
+ HumanReviewerSource,
110
+ JudgeCommittee,
111
+ JudgeSource,
112
+ NoVerifierAvailableError,
113
+ VerdictSource,
114
+ WebhookVerdictSource,
115
+ default_verifier,
116
+ )
117
+
118
+ # Default-on hosted-API verdict telemetry. Best-effort, fails silent.
119
+ # Auto-installs the cloud emitter as the process-wide default iff the
120
+ # user is signed in (``~/.postrule/credentials`` exists) AND has not
121
+ # opted out via ``$POSTRULE_NO_TELEMETRY``. The decision happens in
122
+ # ``maybe_install`` itself; this block just triggers the check.
123
+ #
124
+ # The import + call is wrapped so a missing optional dependency, a
125
+ # broken credentials file, or a malformed env override never aborts
126
+ # ``import postrule``. The decision path is observability-unaware: if
127
+ # anything here misfires, the user gets a NullEmitter and life goes
128
+ # on. Out of scope for this block (handled by other agents): the
129
+ # sign-up flow's consent banner that announces the default-on
130
+ # decision to the user.
131
+ try: # pragma: no cover — observability hook; intentionally fails silent
132
+ from postrule.cloud import verdict_telemetry as _verdict_telemetry # pragma: no cover
133
+
134
+ _verdict_telemetry.maybe_install() # pragma: no cover
135
+ except BaseException: # noqa: BLE001 — observability hook, fails silent # pragma: no cover
136
+ pass # pragma: no cover
137
+
138
+ __all__ = [
139
+ "AccuracyMarginGate",
140
+ "AnthropicAdapter",
141
+ "AnthropicAsyncAdapter",
142
+ "BoundedInMemoryStorage",
143
+ "BulkVerdict",
144
+ "BulkVerdictSummary",
145
+ "CandidateHarness",
146
+ "CandidateReport",
147
+ "CardinalityMLHeadStrategy",
148
+ "CompositeGate",
149
+ "ClassificationRecord",
150
+ "ClassificationResult",
151
+ "FileStorage",
152
+ "FixedMLHeadStrategy",
153
+ "Gate",
154
+ "GateDecision",
155
+ "ImagePixelLogRegHead",
156
+ "InMemoryStorage",
157
+ "Label",
158
+ "LearnedSwitch",
159
+ "ListEmitter",
160
+ "LlamafileAdapter",
161
+ "LlamafileAsyncAdapter",
162
+ "MLHead",
163
+ "MLHeadFactory",
164
+ "MLHeadStrategy",
165
+ "MLPrediction",
166
+ "ManualGate",
167
+ "McNemarGate",
168
+ "MinVolumeGate",
169
+ "ModelClassifier",
170
+ "ModelPrediction",
171
+ "NullEmitter",
172
+ "OllamaAdapter",
173
+ "OllamaAsyncAdapter",
174
+ "OpenAIAdapter",
175
+ "OpenAIAsyncAdapter",
176
+ "Phase",
177
+ "ResilientStorage",
178
+ "SklearnTextHead",
179
+ "SqliteStorage",
180
+ "TfidfGradientBoostingHead",
181
+ "TfidfHeadBase",
182
+ "TfidfLinearSVCHead",
183
+ "TfidfMultinomialNBHead",
184
+ "StdoutEmitter",
185
+ "Storage",
186
+ "StorageBase",
187
+ "Switch",
188
+ "SwitchConfig",
189
+ "SwitchStatus",
190
+ "CallableVerdictSource",
191
+ "HumanReviewerSource",
192
+ "JudgeCommittee",
193
+ "JudgeSource",
194
+ "TelemetryEmitter",
195
+ "Tournament",
196
+ "TournamentReport",
197
+ "Verdict",
198
+ "VerdictSource",
199
+ "NoVerifierAvailableError",
200
+ "WebhookVerdictSource",
201
+ "__version__",
202
+ "available_ml_heads",
203
+ "default_verifier",
204
+ "deserialize_record",
205
+ "flock_supported",
206
+ "make_ml_head",
207
+ "ml_switch",
208
+ "next_phase",
209
+ "prev_phase",
210
+ "register_ml_head",
211
+ "serialize_record",
212
+ ]
@@ -0,0 +1,59 @@
1
+ # Copyright (c) 2026 B-Tree Labs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Postrule 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 postrule 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 Postrule 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 postrule 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()