guard-cli 0.1.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.
guard/decision.py ADDED
@@ -0,0 +1,316 @@
1
+ """Guard decision engine (Task T5): trust score, hard overrides, 3-level policy.
2
+
3
+ This module is the *pure* decision core. It takes a change set plus the graph
4
+ evidence gathered for it and returns which tests to run and why. It performs no
5
+ database or subprocess work; wiring it to the live graph / a git diff is the job
6
+ of the thin slice (T5a).
7
+
8
+ Frozen starting parameters (PLAN.md, T5). These are tuned on the calibration
9
+ split only, then frozen. Changing any value requires a DEVIATIONS.md entry.
10
+
11
+ trust = W_EVIDENCE * evidence_ratio
12
+ + W_FRESHNESS * freshness
13
+ + W_CERTAINTY * (1 - uncertain_ratio)
14
+
15
+ where, over the changed functions:
16
+ evidence_ratio = mean of the best covering-edge confidence per function
17
+ (0.0 for a function with no covering edge)
18
+ freshness = fraction of covering edges whose last_verified_sha equals
19
+ the current SHA (unknown SHA on either side counts as fresh)
20
+ uncertain_ratio = fraction of changed functions flagged dynamic dispatch
21
+
22
+ Hard overrides (applied before the trust policy):
23
+ 1. a changed test file -> run all + warning
24
+ 2. a changed function with no covering evidence -> run all
25
+ 3. a changed function flagged dynamic dispatch -> add its module tests
26
+
27
+ Policy:
28
+ trust >= TRUST_SUBSET_MIN subset
29
+ trust >= TRUST_MEDIUM_MIN subset + module tests
30
+ otherwise run all
31
+ """
32
+ from __future__ import annotations
33
+
34
+ from dataclasses import dataclass, field
35
+ from typing import Any
36
+
37
+ # --- frozen parameters -------------------------------------------------
38
+ W_EVIDENCE = 0.5
39
+ W_FRESHNESS = 0.3
40
+ W_CERTAINTY = 0.2
41
+ TRUST_SUBSET_MIN = 0.8
42
+ TRUST_MEDIUM_MIN = 0.5
43
+
44
+ LEVEL_SUBSET = "subset"
45
+ LEVEL_MEDIUM = "medium"
46
+ LEVEL_RUN_ALL = "run_all"
47
+
48
+ STATUS_SELECTED = "selected"
49
+ STATUS_SKIPPED = "skipped"
50
+
51
+
52
+ def is_test_file(path: str) -> bool:
53
+ """True if a repo-relative path is a test file or lives under tests/."""
54
+ parts = path.replace("\\", "/").split("/")
55
+ name = parts[-1]
56
+ return (
57
+ name.startswith("test_")
58
+ or name.endswith("_test.py")
59
+ or "tests" in parts[:-1]
60
+ )
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class CoveringTest:
65
+ """A COVERED_BY edge: Test -> covers -> Function (edge confidence)."""
66
+
67
+ test_id: str
68
+ confidence: float
69
+ source: str = "unknown"
70
+ last_verified_sha: str | None = None
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class FunctionEvidence:
75
+ """Graph evidence for one changed function."""
76
+
77
+ function_id: str
78
+ file: str
79
+ covering_tests: tuple[CoveringTest, ...] = ()
80
+ has_dynamic_dispatch: bool = False
81
+
82
+ @property
83
+ def has_evidence(self) -> bool:
84
+ return len(self.covering_tests) > 0
85
+
86
+ @property
87
+ def best_confidence(self) -> float:
88
+ if not self.covering_tests:
89
+ return 0.0
90
+ return max(c.confidence for c in self.covering_tests)
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class Change:
95
+ """Inputs to a decision: changed functions, changed files, and the known tests."""
96
+
97
+ functions: tuple[FunctionEvidence, ...] = ()
98
+ changed_files: tuple[str, ...] = ()
99
+ known_tests: tuple[str, ...] = ()
100
+ module_tests: dict[str, tuple[str, ...]] = field(default_factory=dict)
101
+ current_sha: str | None = None
102
+
103
+ def module_tests_for(self, file: str) -> tuple[str, ...]:
104
+ return tuple(self.module_tests.get(file, ()))
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class EvidenceEdge:
109
+ function: str
110
+ source: str
111
+ confidence: float
112
+ fresh: bool
113
+
114
+ def as_dict(self) -> dict[str, Any]:
115
+ return {
116
+ "function": self.function,
117
+ "source": self.source,
118
+ "confidence": self.confidence,
119
+ "fresh": self.fresh,
120
+ }
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class Reason:
125
+ test_id: str
126
+ status: str
127
+ text: str
128
+ evidence: tuple[EvidenceEdge, ...] = ()
129
+ queried_functions: tuple[str, ...] = ()
130
+
131
+ def as_dict(self) -> dict[str, Any]:
132
+ return {
133
+ "test_id": self.test_id,
134
+ "status": self.status,
135
+ "text": self.text,
136
+ "evidence": [e.as_dict() for e in self.evidence],
137
+ "queried_functions": list(self.queried_functions),
138
+ }
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class Decision:
143
+ run_all: bool
144
+ policy_level: str
145
+ trust: float
146
+ evidence_ratio: float
147
+ freshness: float
148
+ uncertain_ratio: float
149
+ selected_tests: tuple[str, ...]
150
+ skipped_tests: tuple[str, ...]
151
+ reasons: dict[str, Reason]
152
+ overrides: tuple[str, ...] = ()
153
+ warnings: tuple[str, ...] = ()
154
+
155
+ def explain(self, test_id: str) -> Reason:
156
+ """Queryable reason for any test the engine considered."""
157
+ if test_id not in self.reasons:
158
+ raise KeyError(f"no decision recorded for test {test_id!r}")
159
+ return self.reasons[test_id]
160
+
161
+ def as_dict(self) -> dict[str, Any]:
162
+ return {
163
+ "run_all": self.run_all,
164
+ "policy_level": self.policy_level,
165
+ "trust": round(self.trust, 6),
166
+ "components": {
167
+ "evidence_ratio": round(self.evidence_ratio, 6),
168
+ "freshness": round(self.freshness, 6),
169
+ "uncertain_ratio": round(self.uncertain_ratio, 6),
170
+ },
171
+ "selected_tests": list(self.selected_tests),
172
+ "skipped_tests": list(self.skipped_tests),
173
+ "overrides": list(self.overrides),
174
+ "warnings": list(self.warnings),
175
+ "reasons": {t: r.as_dict() for t, r in self.reasons.items()},
176
+ }
177
+
178
+
179
+ def _fresh(edge_sha: str | None, current_sha: str | None) -> bool:
180
+ if edge_sha is None or current_sha is None:
181
+ return True
182
+ return edge_sha == current_sha
183
+
184
+
185
+ def compute_trust(change: Change) -> tuple[float, float, float, float]:
186
+ """Return (trust, evidence_ratio, freshness, uncertain_ratio)."""
187
+ funcs = change.functions
188
+ n = len(funcs)
189
+ if n == 0:
190
+ return 0.0, 0.0, 0.0, 0.0
191
+
192
+ evidence_ratio = sum(f.best_confidence for f in funcs) / n
193
+ uncertain_ratio = sum(1 for f in funcs if f.has_dynamic_dispatch) / n
194
+
195
+ edges = [c for f in funcs for c in f.covering_tests]
196
+ if edges:
197
+ freshness = sum(1 for c in edges if _fresh(c.last_verified_sha, change.current_sha)) / len(edges)
198
+ else:
199
+ freshness = 0.0
200
+
201
+ trust = (
202
+ W_EVIDENCE * evidence_ratio
203
+ + W_FRESHNESS * freshness
204
+ + W_CERTAINTY * (1.0 - uncertain_ratio)
205
+ )
206
+ return trust, evidence_ratio, freshness, uncertain_ratio
207
+
208
+
209
+ def policy_for_trust(trust: float) -> str:
210
+ """Map a trust score to one of the three policy levels."""
211
+ if trust >= TRUST_SUBSET_MIN:
212
+ return LEVEL_SUBSET
213
+ if trust >= TRUST_MEDIUM_MIN:
214
+ return LEVEL_MEDIUM
215
+ return LEVEL_RUN_ALL
216
+
217
+
218
+ def decide(change: Change) -> Decision:
219
+ """Apply the hard overrides, then the trust policy, to select tests."""
220
+ funcs = list(change.functions)
221
+ all_functions = tuple(f.function_id for f in funcs)
222
+
223
+ overrides: list[str] = []
224
+ warnings: list[str] = []
225
+
226
+ # Base selection: covering tests of each changed function, with the edge that
227
+ # justifies the selection so every decision stays queryable.
228
+ base: dict[str, list[EvidenceEdge]] = {}
229
+ for f in funcs:
230
+ for c in f.covering_tests:
231
+ base.setdefault(c.test_id, []).append(
232
+ EvidenceEdge(f.function_id, c.source, c.confidence,
233
+ _fresh(c.last_verified_sha, change.current_sha))
234
+ )
235
+
236
+ # --- hard override 1: test files touched
237
+ touched_test_files = sorted(p for p in change.changed_files if is_test_file(p))
238
+ force_run_all = False
239
+ if touched_test_files:
240
+ force_run_all = True
241
+ overrides.append("test_file_touched")
242
+ warnings.append(
243
+ f"test files changed ({', '.join(touched_test_files)}); running the full suite"
244
+ )
245
+
246
+ # --- hard override 2: any changed function with no covering evidence
247
+ no_evidence = sorted(f.function_id for f in funcs if not f.has_evidence)
248
+ if not funcs or no_evidence:
249
+ force_run_all = True
250
+ overrides.append("no_evidence")
251
+ if no_evidence:
252
+ warnings.append(f"no covering evidence for: {', '.join(no_evidence)}")
253
+ else:
254
+ warnings.append("no changed functions carried evidence; running the full suite")
255
+
256
+ # --- hard override 3: dynamic dispatch widens to module tests
257
+ dynamic_funcs = sorted(f.function_id for f in funcs if f.has_dynamic_dispatch)
258
+ if dynamic_funcs:
259
+ overrides.append("dynamic_dispatch")
260
+
261
+ trust, ev_ratio, fresh, unc = compute_trust(change)
262
+
263
+ if force_run_all:
264
+ level = LEVEL_RUN_ALL
265
+ run_all = True
266
+ selected_set = set(change.known_tests)
267
+ else:
268
+ level = policy_for_trust(trust)
269
+ run_all = level == LEVEL_RUN_ALL
270
+ if run_all:
271
+ selected_set = set(change.known_tests)
272
+ else:
273
+ selected_set = set(base)
274
+ if level == LEVEL_MEDIUM or dynamic_funcs:
275
+ for f in funcs:
276
+ selected_set.update(change.module_tests_for(f.file))
277
+
278
+ # --- a queryable reason for every known test
279
+ reasons: dict[str, Reason] = {}
280
+ for test_id in change.known_tests:
281
+ if test_id in selected_set:
282
+ if run_all:
283
+ text = "selected: full suite required"
284
+ if force_run_all:
285
+ text = "selected: full suite (hard override)"
286
+ elif base.get(test_id):
287
+ text = "selected: covers a changed function"
288
+ else:
289
+ text = "selected: module test for a changed function's module"
290
+ reasons[test_id] = Reason(
291
+ test_id, STATUS_SELECTED, text,
292
+ tuple(base.get(test_id, ())), all_functions,
293
+ )
294
+ else:
295
+ reasons[test_id] = Reason(
296
+ test_id, STATUS_SKIPPED,
297
+ "skipped: no covering edge from any changed function",
298
+ (), all_functions,
299
+ )
300
+
301
+ selected_tests = tuple(sorted(selected_set))
302
+ skipped_tests = tuple(t for t in change.known_tests if t not in selected_set)
303
+
304
+ return Decision(
305
+ run_all=run_all,
306
+ policy_level=level,
307
+ trust=trust,
308
+ evidence_ratio=ev_ratio,
309
+ freshness=fresh,
310
+ uncertain_ratio=unc,
311
+ selected_tests=selected_tests,
312
+ skipped_tests=skipped_tests,
313
+ reasons=reasons,
314
+ overrides=tuple(overrides),
315
+ warnings=tuple(warnings),
316
+ )
guard/feedback.py ADDED
@@ -0,0 +1,86 @@
1
+ """Feedback loop and repair edge mechanism for Guard (Task T6).
2
+
3
+ When a test failure is missed by Guard (an escape), this module repairs
4
+ the graph by inserting a high-confidence COVERED_BY edge with source='miss_repair',
5
+ logging the old and new confidence values, and unsetting no_evidence flags so that
6
+ future decisions widen to include the missed test.
7
+ """
8
+ from __future__ import annotations
9
+ import time
10
+ from typing import Any
11
+ from guard import db
12
+
13
+
14
+ def record_miss(
15
+ g,
16
+ function_id: str,
17
+ test_id: str,
18
+ new_confidence: float = 1.0,
19
+ ) -> dict[str, Any]:
20
+ """Records a missed failure by repairing the graph with a miss_repair edge.
21
+
22
+ Args:
23
+ g: FalkorDB graph instance
24
+ function_id: ID of the function where the fault/change occurred
25
+ test_id: ID of the test that failed and was missed
26
+ new_confidence: Confidence score for the repair edge (default: 1.0)
27
+
28
+ Returns:
29
+ Dict reporting the repair event including old and new confidence.
30
+ """
31
+ # 1. Query existing edge if present
32
+ q_existing = (
33
+ "MATCH (f:Function {id: $fid})-[e:COVERED_BY]->(t:Test {id: $tid}) "
34
+ "RETURN e.confidence, e.source"
35
+ )
36
+ existing_res = g.query(q_existing, {"fid": function_id, "tid": test_id}).result_set
37
+ if existing_res:
38
+ old_confidence = float(existing_res[0][0])
39
+ prev_source = existing_res[0][1]
40
+ else:
41
+ old_confidence = 0.0
42
+ prev_source = None
43
+
44
+ ts = time.time()
45
+
46
+ # 2. Ensure Test node exists and has basic defined properties if new
47
+ file_part = test_id.split("::")[0] if "::" in test_id else "tests/unknown.py"
48
+ g.query(
49
+ "MERGE (t:Test {id: $tid}) "
50
+ "ON CREATE SET t.file = $file, t.flake_rate = 0.0",
51
+ {"tid": test_id, "file": file_part},
52
+ )
53
+
54
+ # 3. Create or update COVERED_BY edge with source='miss_repair'
55
+ q_repair = (
56
+ """MATCH (f:Function {id: $fid}), (t:Test {id: $tid})
57
+ MERGE (f)-[e:COVERED_BY]->(t)
58
+ SET e.source = 'miss_repair',
59
+ e.old_confidence = $old_conf,
60
+ e.confidence = $new_conf,
61
+ e.evidence_count = coalesce(e.evidence_count, 0) + 1,
62
+ e.last_repaired_at = $ts
63
+ WITH f
64
+ SET f.no_evidence = false"""
65
+ )
66
+ g.query(
67
+ q_repair,
68
+ {
69
+ "fid": function_id,
70
+ "tid": test_id,
71
+ "old_conf": old_confidence,
72
+ "new_conf": new_confidence,
73
+ "ts": ts,
74
+ },
75
+ )
76
+
77
+ return {
78
+ "status": "repaired",
79
+ "function": function_id,
80
+ "test": test_id,
81
+ "old_confidence": old_confidence,
82
+ "new_confidence": new_confidence,
83
+ "source": "miss_repair",
84
+ "previous_source": prev_source,
85
+ "timestamp": ts,
86
+ }
@@ -0,0 +1 @@
1
+ """Ingestion modules for Guard."""