intent-drift 0.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.
@@ -0,0 +1,16 @@
1
+ from . import api as api
2
+ from . import evidence as evidence_providers
3
+ from .engine import IntentAlignmentEngine
4
+ from .models import AlignmentContext, AlignmentReport, Evidence, ScoreComponent
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "IntentAlignmentEngine",
10
+ "AlignmentContext",
11
+ "AlignmentReport",
12
+ "Evidence",
13
+ "ScoreComponent",
14
+ "evidence_providers",
15
+ "api",
16
+ ]
@@ -0,0 +1,4 @@
1
+ from .engine import IntentAlignmentEngine
2
+ from .models import AlignmentReport
3
+
4
+ __all__ = ["AlignmentReport", "IntentAlignmentEngine"]
@@ -0,0 +1,121 @@
1
+ from typing import Any
2
+
3
+ from .evidence import EvidenceProvider
4
+ from .models import AlignmentContext, AlignmentReport
5
+
6
+
7
+ class IntentAlignmentEngine:
8
+ """Main engine for intent alignment analysis."""
9
+
10
+ def __init__(self):
11
+ """Initialize the engine with default evidence providers."""
12
+ self.providers: list[EvidenceProvider] = []
13
+ self._register_default_providers()
14
+
15
+ def _register_default_providers(self) -> None:
16
+ """Register the default set of evidence providers."""
17
+ # Import providers here to avoid circular imports
18
+ from .evidence.providers import (
19
+ ArchitectureProvider,
20
+ ConstraintProvider,
21
+ DependencyProvider,
22
+ ExecutionProvider,
23
+ FileGraphProvider,
24
+ GoalProvider,
25
+ ProblematicFindingsProvider,
26
+ RequirementCoverageProvider,
27
+ ScopeProvider,
28
+ )
29
+
30
+ self.providers = [
31
+ GoalProvider(),
32
+ ConstraintProvider(),
33
+ ScopeProvider(),
34
+ ArchitectureProvider(),
35
+ ExecutionProvider(),
36
+ FileGraphProvider(),
37
+ DependencyProvider(),
38
+ RequirementCoverageProvider(),
39
+ ProblematicFindingsProvider(),
40
+ ]
41
+
42
+ def add_provider(self, provider: EvidenceProvider) -> None:
43
+ """Register an evidence provider to use during evaluation."""
44
+ self.providers.append(provider)
45
+
46
+ def evaluate(self, context: AlignmentContext) -> AlignmentReport:
47
+ """
48
+ Analyze the alignment between original goal and current implementation.
49
+
50
+ Args:
51
+ context: Alignment context containing goal, plan, and execution data.
52
+ Accepts either an :class:`AlignmentContext` or a plain dict with
53
+ the same three keys. Providers always receive a plain dict view.
54
+
55
+ Returns:
56
+ Alignment report with assessment results
57
+ """
58
+ # Accept a plain dict for convenience (e.g. example scripts and tests
59
+ # that construct context inline) and normalize to AlignmentContext.
60
+ context_dict: dict[str, Any]
61
+ if isinstance(context, AlignmentContext):
62
+ context_dict = {
63
+ "original_goal": context.original_goal,
64
+ "current_plan": context.current_plan,
65
+ "execution_context": context.execution_context,
66
+ }
67
+ elif isinstance(context, dict):
68
+ context_dict = {
69
+ "original_goal": context.get("original_goal", {}),
70
+ "current_plan": context.get("current_plan", {}),
71
+ "execution_context": context.get("execution_context", {}),
72
+ }
73
+ else:
74
+ raise TypeError(
75
+ "evaluate() expects an AlignmentContext or a dict with "
76
+ "'original_goal', 'current_plan', 'execution_context' keys."
77
+ )
78
+
79
+ # Collect evidence from all registered providers
80
+ all_evidence = []
81
+ for provider in self.providers:
82
+ all_evidence.extend(provider.collect(context_dict))
83
+
84
+ # Import scoring functions
85
+ from .scoring import compute_weighted_score
86
+ from .utils import (
87
+ compute_confidence,
88
+ determine_status,
89
+ generate_recommendation,
90
+ generate_risk_assessment,
91
+ generate_summary,
92
+ )
93
+
94
+ # Compute weighted score and alignment breakdown
95
+ weighted_score, component_scores = compute_weighted_score(all_evidence, self.providers)
96
+
97
+ # Calculate confidence based on evidence consistency and confidence scores
98
+ confidence = compute_confidence(all_evidence)
99
+
100
+ # Determine status based on alignment score
101
+ status = determine_status(weighted_score)
102
+
103
+ # Generate report components
104
+ summary = generate_summary(component_scores, weighted_score)
105
+ risk = generate_risk_assessment(all_evidence, component_scores)
106
+ recommendation = generate_recommendation(component_scores, weighted_score)
107
+
108
+ # Create the alignment report
109
+ report = AlignmentReport(
110
+ overall_alignment=weighted_score,
111
+ confidence=confidence,
112
+ status=status,
113
+ breakdown=component_scores,
114
+ summary=summary,
115
+ evidence=all_evidence,
116
+ risk=risk,
117
+ recommendation=recommendation,
118
+ timeline=[], # Timeline would be populated with historical data in a real implementation
119
+ )
120
+
121
+ return report
@@ -0,0 +1,27 @@
1
+ """Evidence providers package - exports all available providers."""
2
+
3
+ from ..models import Evidence
4
+ from .base import EvidenceProvider
5
+ from .providers.architecture_provider import ArchitectureProvider
6
+ from .providers.constraint_provider import ConstraintProvider
7
+ from .providers.dependency_provider import DependencyProvider
8
+ from .providers.execution_provider import ExecutionProvider
9
+ from .providers.file_graph_provider import FileGraphProvider
10
+ from .providers.goal_provider import GoalProvider
11
+ from .providers.problematic_findings_provider import ProblematicFindingsProvider
12
+ from .providers.requirement_coverage_provider import RequirementCoverageProvider
13
+ from .providers.scope_provider import ScopeProvider
14
+
15
+ __all__ = [
16
+ "EvidenceProvider",
17
+ "Evidence",
18
+ "GoalProvider",
19
+ "ConstraintProvider",
20
+ "ScopeProvider",
21
+ "ArchitectureProvider",
22
+ "ExecutionProvider",
23
+ "FileGraphProvider",
24
+ "DependencyProvider",
25
+ "RequirementCoverageProvider",
26
+ "ProblematicFindingsProvider",
27
+ ]
@@ -0,0 +1,353 @@
1
+ """Shared, dependency-free analysis helpers used by evidence providers.
2
+
3
+ These helpers implement the lightweight, explainable heuristics that let each
4
+ provider reason about a context without pulling in external NLP libraries. They
5
+ are deliberately simple and transparent: every score a provider produces can be
6
+ traced back to the tokens, keywords, or counts these functions return.
7
+
8
+ The matching is intentionally *fuzzy-but-transparent*: tokens are normalized
9
+ (stopword removal, lowercasing, light suffix stripping) and compared via both
10
+ exact and alias (synonym) matching, so "reduce memory usage" and "memory
11
+ optimization" are recognized as related even though they share no identical word
12
+ beyond "memory".
13
+ """
14
+
15
+ from typing import Any
16
+
17
+ # Words that carry no semantic weight for alignment comparison.
18
+ _STOPWORDS: set[str] = {
19
+ "the",
20
+ "a",
21
+ "an",
22
+ "and",
23
+ "or",
24
+ "but",
25
+ "of",
26
+ "to",
27
+ "in",
28
+ "on",
29
+ "for",
30
+ "with",
31
+ "as",
32
+ "is",
33
+ "are",
34
+ "was",
35
+ "were",
36
+ "be",
37
+ "been",
38
+ "being",
39
+ "this",
40
+ "that",
41
+ "these",
42
+ "those",
43
+ "it",
44
+ "its",
45
+ "we",
46
+ "our",
47
+ "you",
48
+ "your",
49
+ "i",
50
+ "my",
51
+ "me",
52
+ "by",
53
+ "from",
54
+ "at",
55
+ "into",
56
+ "than",
57
+ "then",
58
+ "so",
59
+ "if",
60
+ "else",
61
+ "when",
62
+ "while",
63
+ "which",
64
+ "who",
65
+ "whom",
66
+ "what",
67
+ "how",
68
+ "why",
69
+ "all",
70
+ "any",
71
+ "some",
72
+ "no",
73
+ "not",
74
+ "do",
75
+ "does",
76
+ "did",
77
+ "has",
78
+ "have",
79
+ "had",
80
+ "can",
81
+ "could",
82
+ "should",
83
+ "would",
84
+ "will",
85
+ "shall",
86
+ "may",
87
+ "might",
88
+ "must",
89
+ "using",
90
+ "use",
91
+ "make",
92
+ "making",
93
+ "add",
94
+ "added",
95
+ "new",
96
+ "current",
97
+ "currently",
98
+ "implement",
99
+ "implementation",
100
+ "support",
101
+ }
102
+
103
+ # Alias groups: any two tokens in the same group are treated as a match.
104
+ # This is the transparent "semantic" layer -- small, curated, and explainable.
105
+ _ALIASES: list[set[str]] = [
106
+ {"memory", "ram", "heap"},
107
+ {"optimize", "optimization", "optimise", "tune", "tuning"},
108
+ {"speed", "performance", "fast", "fastest", "latency"},
109
+ {"startup", "boot", "initialization", "init", "launch"},
110
+ {"refactor", "restructure", "rewrite", "reorganize"},
111
+ {"security", "secure", "auth", "authentication", "authorization"},
112
+ {"bug", "defect", "error", "issue", "fix"},
113
+ {"test", "tests", "testing", "coverage"},
114
+ {"database", "db", "storage", "persistence"},
115
+ {"api", "endpoint", "interface", "service"},
116
+ ]
117
+
118
+
119
+ def _alias_index() -> dict[str, int]:
120
+ """Map each alias token to its group id."""
121
+ index: dict[str, int] = {}
122
+ for gid, group in enumerate(_ALIASES):
123
+ for token in group:
124
+ index[token] = gid
125
+ return index
126
+
127
+
128
+ _ALIAS_INDEX = _alias_index()
129
+
130
+
131
+ def _normalize(text: Any) -> str:
132
+ """Coerce arbitrary context values into a single lowercase string."""
133
+ if text is None:
134
+ return ""
135
+ if isinstance(text, (list, tuple, set)):
136
+ return " ".join(_normalize(item) for item in text)
137
+ if isinstance(text, dict):
138
+ return " ".join(_normalize(v) for v in text.values())
139
+ return str(text).lower()
140
+
141
+
142
+ def _strip_suffix(token: str) -> str:
143
+ """Light suffix stripping so 'optimization' and 'optimize' can match."""
144
+ for suffix in ("ization", "isation", "ing", "ed", "er", "es"):
145
+ if token.endswith(suffix) and len(token) - len(suffix) >= 3:
146
+ return token[: -len(suffix)]
147
+ return token
148
+
149
+
150
+ def tokenize(text: Any) -> list[str]:
151
+ """Split a context value into meaningful word tokens (no stopwords)."""
152
+ raw = _normalize(text)
153
+ tokens: list[str] = []
154
+ current = ""
155
+ for ch in raw:
156
+ if ch.isalnum():
157
+ current += ch
158
+ else:
159
+ if current:
160
+ tokens.append(current)
161
+ current = ""
162
+ if current:
163
+ tokens.append(current)
164
+ return [t for t in tokens if t not in _STOPWORDS and len(t) > 1]
165
+
166
+
167
+ def _tokenset(text: Any) -> set[str]:
168
+ """Return the normalized token set for a value (for set operations)."""
169
+ return set(tokenize(text))
170
+
171
+
172
+ def _token_matches(a: str, b: str) -> bool:
173
+ """True if two tokens match via exact, suffix, substring, or alias rule."""
174
+ if a == b:
175
+ return True
176
+ # Suffix-stripped equality (optimize == optimization).
177
+ if _strip_suffix(a) == _strip_suffix(b):
178
+ return True
179
+ # Substring containment for compound words (mem matches memory).
180
+ if a in b or b in a:
181
+ return True
182
+ # Alias/synonym groups.
183
+ ga, gb = _ALIAS_INDEX.get(a), _ALIAS_INDEX.get(b)
184
+ if ga is not None and ga == gb:
185
+ return True
186
+ return False
187
+
188
+
189
+ def _matched_fraction(set_a: set[str], set_b: set[str]) -> float:
190
+ """Fraction of ``set_a`` tokens that have a match in ``set_b``, in [0, 1]."""
191
+ if not set_a:
192
+ return 0.0
193
+ matched = 0
194
+ for ta in set_a:
195
+ if any(_token_matches(ta, tb) for tb in set_b):
196
+ matched += 1
197
+ return matched / len(set_a)
198
+
199
+
200
+ def keyword_overlap(a: Any, b: Any) -> float:
201
+ """Return an overlap score in [0, 1] between two texts.
202
+
203
+ Uses the *overlap coefficient* (intersection / size of the smaller set)
204
+ rather than pure Jaccard, so a focused plan that is a subset of the goal's
205
+ concepts scores highly. Substring/alias matching makes the comparison robust
206
+ to wording changes.
207
+ """
208
+ set_a = _tokenset(a)
209
+ set_b = _tokenset(b)
210
+ if not set_a and not set_b:
211
+ return 1.0
212
+ if not set_a or not set_b:
213
+ return 0.0
214
+ intersection = sum(1 for ta in set_a if any(_token_matches(ta, tb) for tb in set_b))
215
+ smaller = min(len(set_a), len(set_b))
216
+ return intersection / smaller
217
+
218
+
219
+ def term_frequency(text: Any, terms: list[str]) -> float:
220
+ """Fraction of the given *phrases* present (via fuzzy match) in the text.
221
+
222
+ Each element of ``terms`` is treated as a phrase: it counts as present when
223
+ at least half of its tokens match the text (fuzzy/alias). Returns [0, 1].
224
+ """
225
+ if not terms:
226
+ return 1.0
227
+ target = _tokenset(text)
228
+ if not target:
229
+ return 0.0
230
+ hits = 0
231
+ for term in terms:
232
+ term_tokens = tokenize(term)
233
+ if not term_tokens:
234
+ continue
235
+ matched = sum(1 for tt in term_tokens if any(_token_matches(tt, t) for t in target))
236
+ if matched >= max(1, len(term_tokens) // 2):
237
+ hits += 1
238
+ return hits / len(terms)
239
+
240
+
241
+ # Short but meaningful domain tokens that should always count as topics even
242
+ # though they are brief (e.g. "ram", "api", "db").
243
+ _SALIENT_SHORT: set[str] = {
244
+ "ram",
245
+ "api",
246
+ "db",
247
+ "gpu",
248
+ "cpu",
249
+ "io",
250
+ "ui",
251
+ "os",
252
+ "log",
253
+ "bug",
254
+ "fix",
255
+ "web",
256
+ "cli",
257
+ "sdk",
258
+ "sql",
259
+ "xml",
260
+ "json",
261
+ "yaml",
262
+ "css",
263
+ "html",
264
+ "dom",
265
+ }
266
+
267
+
268
+ def salient_tokens(text: Any, min_len: int = 4) -> set[str]:
269
+ """Return the 'content' tokens of a text: longer non-stopword tokens.
270
+
271
+ Short words (verbs, articles, numbers) are excluded so the salient set
272
+ captures the *topic* nouns rather than generic wording. A small set of brief
273
+ but meaningful domain tokens (e.g. "ram", "api") is always retained.
274
+ """
275
+ tokens = tokenize(text)
276
+ return {t for t in tokens if t in _SALIENT_SHORT or len(t) >= min_len}
277
+
278
+
279
+ def topic_alignment(goal: Any, work: Any) -> float:
280
+ """Topical alignment between a goal and the work done toward it, in [0, 1].
281
+
282
+ This is the core "are we still talking about the same thing?" measure. It
283
+ computes recall of the goal's salient (topic) tokens within the work text
284
+ using fuzzy + alias matching, then applies a floor: if *any* goal topic token
285
+ is present in the work, alignment is at least 0.7, because sharing even one
286
+ dominant concept (e.g. "memory") means the work is on-topic.
287
+ """
288
+ goal_topics = salient_tokens(goal)
289
+ work_tokens = _tokenset(work)
290
+ if not goal_topics:
291
+ return 1.0
292
+ if not work_tokens:
293
+ return 0.0
294
+ matched = sum(1 for gt in goal_topics if any(_token_matches(gt, wt) for wt in work_tokens))
295
+ recall = matched / len(goal_topics)
296
+ # Floor: on-topic work (shares at least one dominant concept) is well aligned.
297
+ if matched >= 1:
298
+ return max(0.7, min(1.0, 0.7 + 0.3 * recall))
299
+ return recall
300
+
301
+
302
+ def get_text(context: dict[str, Any], key: str, default: str = "") -> str:
303
+ """Safely fetch a string field from original_goal / current_plan / execution."""
304
+ for section in ("original_goal", "current_plan", "execution_context"):
305
+ section_data = context.get(section, {}) or {}
306
+ if key in section_data and section_data[key] is not None:
307
+ return _normalize(section_data[key])
308
+ return default
309
+
310
+
311
+ def get_list(context: dict[str, Any], key: str) -> list[Any]:
312
+ """Safely fetch a list field from any context section."""
313
+ for section in ("original_goal", "current_plan", "execution_context"):
314
+ section_data = context.get(section, {}) or {}
315
+ value = section_data.get(key)
316
+ if isinstance(value, list):
317
+ return value
318
+ return []
319
+
320
+
321
+ def is_empty(context: dict[str, Any]) -> bool:
322
+ """True when the context carries no meaningful content in any section."""
323
+ for section in ("original_goal", "current_plan", "execution_context"):
324
+ if _normalize(context.get(section, {})).strip():
325
+ return False
326
+ return True
327
+
328
+
329
+ def parse_git_diff(diff: Any) -> dict[str, int]:
330
+ """Count added, removed, and total lines and files from a unified diff.
331
+
332
+ Returns a dict with ``added``, ``removed``, ``total`` line counts and a
333
+ ``files`` count parsed from ``diff --git`` headers.
334
+ """
335
+ if not isinstance(diff, str) or not diff.strip():
336
+ return {"added": 0, "removed": 0, "total": 0, "files": 0}
337
+
338
+ added = removed = files = 0
339
+ for line in diff.splitlines():
340
+ if line.startswith("diff --git"):
341
+ files += 1
342
+ elif line.startswith("+++") or line.startswith("---"):
343
+ continue
344
+ elif line.startswith("+"):
345
+ added += 1
346
+ elif line.startswith("-"):
347
+ removed += 1
348
+ return {
349
+ "added": added,
350
+ "removed": removed,
351
+ "total": added + removed,
352
+ "files": files,
353
+ }
@@ -0,0 +1,34 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+ from ..models import Evidence
5
+
6
+
7
+ class EvidenceProvider(ABC):
8
+ """Abstract base class for all evidence providers."""
9
+
10
+ @property
11
+ @abstractmethod
12
+ def name(self) -> str:
13
+ """Unique identifier for this provider."""
14
+ pass
15
+
16
+ @property
17
+ @abstractmethod
18
+ def weight(self) -> float:
19
+ """Relative weight of this provider's evidence (will be normalized)."""
20
+ pass
21
+
22
+ @abstractmethod
23
+ def collect(self, context: dict[str, Any]) -> list[Evidence]:
24
+ """
25
+ Collect evidence from the given context.
26
+
27
+ Args:
28
+ context: The alignment context containing original_goal, current_plan,
29
+ and execution_context
30
+
31
+ Returns:
32
+ List of Evidence objects representing findings
33
+ """
34
+ pass
@@ -0,0 +1,24 @@
1
+ from ..base import EvidenceProvider
2
+ from .architecture_provider import ArchitectureProvider
3
+ from .constraint_provider import ConstraintProvider
4
+ from .dependency_provider import DependencyProvider
5
+ from .execution_provider import ExecutionProvider
6
+ from .file_graph_provider import FileGraphProvider
7
+ from .goal_provider import GoalProvider
8
+ from .problematic_findings_provider import ProblematicFindingsProvider
9
+ from .requirement_coverage_provider import RequirementCoverageProvider
10
+ from .scope_provider import ScopeProvider
11
+
12
+ # Package-level re-exports
13
+ __all__ = [
14
+ "EvidenceProvider",
15
+ "GoalProvider",
16
+ "ConstraintProvider",
17
+ "ScopeProvider",
18
+ "ArchitectureProvider",
19
+ "ExecutionProvider",
20
+ "FileGraphProvider",
21
+ "DependencyProvider",
22
+ "RequirementCoverageProvider",
23
+ "ProblematicFindingsProvider",
24
+ ]
@@ -0,0 +1,82 @@
1
+ from ...models import Evidence
2
+ from ..analysis import (
3
+ get_list,
4
+ is_empty,
5
+ parse_git_diff,
6
+ )
7
+ from ..base import EvidenceProvider
8
+
9
+
10
+ class ArchitectureProvider(EvidenceProvider):
11
+ """Detects architectural drift, unnecessary complexity, and hidden rewrites.
12
+
13
+ Architectural integrity is assessed from the git diff shape: (a) a rewrite
14
+ signature — large deletions paired with large additions in the same files —
15
+ suggests a hidden rewrite rather than targeted change; (b) an explosion of
16
+ new files/symbols suggests unnecessary complexity or overengineering; (c) a
17
+ healthy, mostly-additive change tightly scoped to a few files indicates the
18
+ structure is being respected.
19
+ """
20
+
21
+ @property
22
+ def name(self) -> str:
23
+ return "architecture_provider"
24
+
25
+ @property
26
+ def weight(self) -> float:
27
+ # Architecture divergence is a major, often invisible, drift signal.
28
+ return 0.25
29
+
30
+ def collect(self, context: dict) -> list[Evidence]:
31
+ if is_empty(context):
32
+ return [
33
+ Evidence(
34
+ source=self.name,
35
+ value=0.0,
36
+ confidence=0.0,
37
+ details="No architectural information available.",
38
+ )
39
+ ]
40
+
41
+ exec_ctx = context.get("execution_context", {}) or {}
42
+ diff = parse_git_diff(exec_ctx.get("git_diff"))
43
+ edited_files = get_list(context, "edited_files")
44
+ file_count = max(len(edited_files), diff["files"])
45
+
46
+ added, removed = diff["added"], diff["removed"]
47
+ total = max(1, diff["total"])
48
+
49
+ # Rewrite signature: heavy simultaneous deletions + additions in few
50
+ # files. A pure addition (no deletions) is normal incremental work and
51
+ # is NOT treated as a rewrite.
52
+ rewrite_signal = 0.0
53
+ if removed > 0 and file_count <= 3:
54
+ deletion_fraction = removed / total
55
+ if deletion_fraction > 0.4:
56
+ rewrite_signal = max(0.6, min(1.0, deletion_fraction))
57
+ # Large net rewrite: many lines deleted relative to file count.
58
+ elif removed > 50:
59
+ rewrite_signal = 0.6
60
+
61
+ # Complexity explosion: many new files for a small objective.
62
+ new_file_signal = min(1.0, max(0, file_count - 6) / 10.0)
63
+
64
+ penalty = 0.5 * rewrite_signal + 0.5 * new_file_signal
65
+ value = max(0.0, 1.0 - penalty)
66
+
67
+ reasons = []
68
+ if rewrite_signal > 0.4:
69
+ reasons.append("possible hidden rewrite (high add/delete churn in few files)")
70
+ if new_file_signal > 0.4:
71
+ reasons.append("large number of new files suggests overengineering")
72
+ if not reasons:
73
+ reasons.append("change appears structurally targeted")
74
+
75
+ return [
76
+ Evidence(
77
+ source=self.name,
78
+ value=round(value, 3),
79
+ confidence=0.8 if diff["total"] > 0 else 0.5,
80
+ details=f"Architecture: {'; '.join(reasons)} (files={file_count}, +{added}/-{removed})",
81
+ )
82
+ ]