cseq 0.0.1__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.
cseq/static_store.py ADDED
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from pathlib import Path
6
+ from typing import Iterable, Iterator
7
+
8
+ from .model import CompactFunctionRecord, FunctionRecord, SourceRange
9
+
10
+
11
+ class StaticSymbolStore:
12
+ """Disk-backed function metadata store for very large projects.
13
+
14
+ The store preserves the FunctionRecord-facing contract while allowing parse
15
+ artifacts to release their per-TU function tables after ingestion.
16
+ """
17
+
18
+ SCHEMA_VERSION = 7
19
+
20
+ def __init__(self, path: str | Path) -> None:
21
+ self.path = Path(path)
22
+ self.path.parent.mkdir(parents=True, exist_ok=True)
23
+ self.connection = sqlite3.connect(self.path)
24
+ # The Static Symbol Store is a reproducible analysis cache, not the
25
+ # authoritative source. Keep writes fast on million-function projects.
26
+ # Reuse only requires that we avoid EXCLUSIVE locking; journaling can be
27
+ # disabled because an interrupted/corrupt store is safely rebuilt from
28
+ # source fingerprints on the next run.
29
+ self.connection.execute("PRAGMA busy_timeout=5000")
30
+ self.connection.execute("PRAGMA journal_mode=OFF")
31
+ self.connection.execute("PRAGMA synchronous=OFF")
32
+ self.connection.execute("PRAGMA temp_store=MEMORY")
33
+ self.connection.executescript(
34
+ """
35
+ CREATE TABLE IF NOT EXISTS meta(
36
+ key TEXT PRIMARY KEY,
37
+ value TEXT NOT NULL
38
+ );
39
+ CREATE TABLE IF NOT EXISTS functions(
40
+ qid TEXT NOT NULL,
41
+ name TEXT NOT NULL,
42
+ source_path TEXT NOT NULL,
43
+ start_line INTEGER,
44
+ start_column INTEGER,
45
+ end_line INTEGER,
46
+ end_column INTEGER,
47
+ storage_class TEXT,
48
+ type_qualifier TEXT,
49
+ parameters_json TEXT NOT NULL
50
+ );
51
+ CREATE TABLE IF NOT EXISTS translation_units(
52
+ source_path TEXT PRIMARY KEY,
53
+ fingerprint TEXT NOT NULL,
54
+ function_count INTEGER NOT NULL,
55
+ artifact_json TEXT NOT NULL DEFAULT '{}'
56
+ ) WITHOUT ROWID;
57
+ """
58
+ )
59
+ row = self.connection.execute(
60
+ "SELECT value FROM meta WHERE key='schema_version'"
61
+ ).fetchone()
62
+ old_schema = int(row[0]) if row and str(row[0]).isdigit() else None
63
+ columns = {r[1] for r in self.connection.execute("PRAGMA table_info(translation_units)")}
64
+ if "artifact_json" not in columns:
65
+ self.connection.execute(
66
+ "ALTER TABLE translation_units ADD COLUMN artifact_json TEXT NOT NULL DEFAULT '{}'"
67
+ )
68
+ self.connection.execute("DROP INDEX IF EXISTS idx_functions_source")
69
+ if old_schema != self.SCHEMA_VERSION:
70
+ # Schema v7 changes the function table from a random TEXT-primary-key
71
+ # B-tree into an append-oriented rowid table. The store is a rebuildable
72
+ # cache, so recreate it rather than paying a migration cost.
73
+ self.connection.execute("DROP TABLE IF EXISTS functions")
74
+ self.connection.execute(
75
+ """CREATE TABLE functions(
76
+ qid TEXT NOT NULL, name TEXT NOT NULL, source_path TEXT NOT NULL,
77
+ start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
78
+ storage_class TEXT, type_qualifier TEXT, parameters_json TEXT NOT NULL
79
+ )"""
80
+ )
81
+ self.connection.execute("DELETE FROM translation_units")
82
+ self.connection.execute("DROP INDEX IF EXISTS idx_functions_name")
83
+ # A permanent name index is deliberately avoided. Project-wide call
84
+ # resolution asks only for names that actually appear at CallSites.
85
+ self.connection.execute(
86
+ "INSERT OR REPLACE INTO meta(key,value) VALUES('schema_version',?)",
87
+ (str(self.SCHEMA_VERSION),),
88
+ )
89
+ self.connection.commit()
90
+
91
+ def reset(self) -> None:
92
+ self.connection.execute("DELETE FROM functions")
93
+ self.connection.execute("DELETE FROM translation_units")
94
+ self.connection.execute("DROP INDEX IF EXISTS idx_functions_name")
95
+ self.connection.commit()
96
+
97
+ def ensure_project(self, root: str | Path) -> bool:
98
+ """Bind the store to one project root.
99
+
100
+ Returns True when an existing compatible project binding is reused.
101
+ A different root invalidates relative-path keyed contents safely.
102
+ """
103
+ root_text = str(Path(root).resolve())
104
+ row = self.connection.execute(
105
+ "SELECT value FROM meta WHERE key='project_root'"
106
+ ).fetchone()
107
+ reused = bool(row and row[0] == root_text)
108
+ if row and row[0] != root_text:
109
+ self.reset()
110
+ self.connection.execute(
111
+ "INSERT OR REPLACE INTO meta(key,value) VALUES('project_root',?)",
112
+ (root_text,),
113
+ )
114
+ self.connection.commit()
115
+ return reused
116
+
117
+ def has_translation_unit(self, source_path: str, fingerprint: str) -> bool:
118
+ row = self.connection.execute(
119
+ "SELECT fingerprint FROM translation_units WHERE source_path=?",
120
+ (source_path,),
121
+ ).fetchone()
122
+ return bool(row and row[0] == fingerprint)
123
+
124
+ def translation_unit_state_raw(self) -> dict[str, tuple[str, str]]:
125
+ """Return the small TU reuse index without materializing functions."""
126
+ return {
127
+ source_path: (fingerprint, artifact_json)
128
+ for source_path, fingerprint, artifact_json in self.connection.execute(
129
+ "SELECT source_path,fingerprint,artifact_json FROM translation_units"
130
+ )
131
+ }
132
+
133
+ def replace_translation_unit(
134
+ self,
135
+ source_path: str,
136
+ fingerprint: str,
137
+ functions: Iterable[FunctionRecord | CompactFunctionRecord],
138
+ artifact_payload: dict | None = None,
139
+ ) -> int:
140
+ rows = [self._row(fn) for fn in functions]
141
+ old = self.connection.execute(
142
+ "SELECT 1 FROM translation_units WHERE source_path=?", (source_path,)
143
+ ).fetchone()
144
+ if old:
145
+ # Changed/removed TUs are rare compared with cold insertion. Avoid a
146
+ # permanent source_path secondary index and scan only on mutation.
147
+ self.connection.execute("DELETE FROM functions WHERE source_path=?", (source_path,))
148
+ if rows:
149
+ self.connection.executemany(
150
+ """
151
+ INSERT INTO functions(
152
+ qid,name,source_path,start_line,start_column,end_line,end_column,
153
+ storage_class,type_qualifier,parameters_json
154
+ ) VALUES(?,?,?,?,?,?,?,?,?,?)
155
+ """,
156
+ rows,
157
+ )
158
+ artifact_json = json.dumps(artifact_payload or {}, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
159
+ self.connection.execute(
160
+ """INSERT OR REPLACE INTO translation_units(source_path,fingerprint,function_count,artifact_json)
161
+ VALUES(?,?,?,?)""",
162
+ (source_path, fingerprint, len(rows), artifact_json),
163
+ )
164
+ return len(rows)
165
+
166
+ def remove_missing_translation_units(self, current_source_paths: set[str]) -> int:
167
+ rows = [r[0] for r in self.connection.execute("SELECT source_path FROM translation_units")]
168
+ stale = [path for path in rows if path not in current_source_paths]
169
+ for path in stale:
170
+ self.connection.execute("DELETE FROM functions WHERE source_path=?", (path,))
171
+ self.connection.execute("DELETE FROM translation_units WHERE source_path=?", (path,))
172
+ return len(stale)
173
+
174
+ def translation_unit_count(self) -> int:
175
+ return int(self.connection.execute("SELECT COUNT(*) FROM translation_units").fetchone()[0])
176
+
177
+ @staticmethod
178
+ def _row(fn: FunctionRecord | CompactFunctionRecord):
179
+ r = fn.source_range
180
+ return (
181
+ fn.qualified_id,
182
+ fn.name,
183
+ fn.source_path,
184
+ r.start_line,
185
+ r.start_column,
186
+ r.end_line,
187
+ r.end_column,
188
+ fn.storage_class,
189
+ fn.type_qualifier,
190
+ json.dumps(list(fn.parameters), ensure_ascii=False, separators=(",", ":")),
191
+ )
192
+
193
+ def add_functions(self, functions: Iterable[FunctionRecord | CompactFunctionRecord]) -> int:
194
+ rows = [self._row(fn) for fn in functions]
195
+ if not rows:
196
+ return 0
197
+ self.connection.executemany(
198
+ """
199
+ INSERT INTO functions(
200
+ qid,name,source_path,start_line,start_column,end_line,end_column,
201
+ storage_class,type_qualifier,parameters_json
202
+ ) VALUES(?,?,?,?,?,?,?,?,?,?)
203
+ """,
204
+ rows,
205
+ )
206
+ return len(rows)
207
+
208
+ def flush(self) -> None:
209
+ """Commit current row batches without forcing secondary-index creation."""
210
+ self.connection.commit()
211
+
212
+ def commit(self) -> None:
213
+ self.connection.commit()
214
+
215
+ def count(self) -> int:
216
+ return int(self.connection.execute("SELECT COUNT(*) FROM functions").fetchone()[0])
217
+
218
+ @staticmethod
219
+ def _record(row) -> FunctionRecord:
220
+ return FunctionRecord(
221
+ name=row[1],
222
+ qualified_id=row[0],
223
+ source_path=row[2],
224
+ source_range=SourceRange(row[3], row[4], row[5], row[6]),
225
+ storage_class=row[7],
226
+ type_qualifier=row[8],
227
+ parameters=tuple(json.loads(row[9]) if row[9] else ()),
228
+ )
229
+
230
+ def iter_functions(self, fetch_size: int = 4096) -> Iterator[FunctionRecord]:
231
+ cur = self.connection.execute(
232
+ """SELECT qid,name,source_path,start_line,start_column,end_line,end_column,
233
+ storage_class,type_qualifier,parameters_json
234
+ FROM functions ORDER BY source_path,start_line,start_column"""
235
+ )
236
+ while True:
237
+ rows = cur.fetchmany(fetch_size)
238
+ if not rows:
239
+ break
240
+ for row in rows:
241
+ yield self._record(row)
242
+
243
+ def iter_named(self, names: set[str]) -> Iterator[FunctionRecord]:
244
+ if not names:
245
+ return
246
+ ordered = sorted(names)
247
+ # No persistent name index and no TEMP-table writes: each chunk performs
248
+ # one sequential scan of the disk-backed store. Most real resolution
249
+ # queries fit in a single chunk because they contain only names observed
250
+ # at reachable CallSites.
251
+ for start in range(0, len(ordered), 800):
252
+ chunk = ordered[start:start + 800]
253
+ placeholders = ",".join("?" for _ in chunk)
254
+ cur = self.connection.execute(
255
+ f"""SELECT qid,name,source_path,start_line,start_column,end_line,end_column,
256
+ storage_class,type_qualifier,parameters_json
257
+ FROM functions WHERE name IN ({placeholders})""",
258
+ chunk,
259
+ )
260
+ for row in cur:
261
+ yield self._record(row)
262
+
263
+ def iter_ids(self, qids: set[str]) -> Iterator[FunctionRecord]:
264
+ if not qids:
265
+ return
266
+ ordered = sorted(qids)
267
+ for start in range(0, len(ordered), 400):
268
+ chunk = ordered[start:start + 400]
269
+ placeholders = ",".join("?" for _ in chunk)
270
+ cur = self.connection.execute(
271
+ f"""SELECT qid,name,source_path,start_line,start_column,end_line,end_column,
272
+ storage_class,type_qualifier,parameters_json
273
+ FROM functions WHERE qid IN ({placeholders})""",
274
+ chunk,
275
+ )
276
+ for row in cur:
277
+ yield self._record(row)
278
+
279
+ def close(self) -> None:
280
+ self.connection.close()
cseq/trace_analysis.py ADDED
@@ -0,0 +1,336 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from collections import defaultdict, deque
6
+
7
+ from .model import CallTargetKind, ProjectIndex
8
+ from .runtime import RuntimeEvent
9
+
10
+
11
+ class RuntimeRelationType(str, Enum):
12
+ CORRELATED_EXACT = "CORRELATED_EXACT"
13
+ CORRELATED_POSSIBLE = "CORRELATED_POSSIBLE"
14
+ TEMPORAL_ONLY = "TEMPORAL_ONLY"
15
+ CONCURRENT_UNKNOWN = "CONCURRENT_UNKNOWN"
16
+ UNKNOWN_GAP = "UNKNOWN_GAP"
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class CorrelationRelation:
21
+ left_event_index: int
22
+ right_event_index: int
23
+ relation_type: RuntimeRelationType
24
+ keys: tuple[str, ...]
25
+ values: tuple[str, ...]
26
+ score: float
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ClockAlignment:
31
+ source_domain: str
32
+ target_domain: str
33
+ scale: float
34
+ offset_seconds: float
35
+ anchor_count: int
36
+ max_abs_residual_seconds: float
37
+ confidence: str
38
+
39
+ def transform_seconds(self, source_seconds: float) -> float:
40
+ return self.scale * source_seconds + self.offset_seconds
41
+
42
+
43
+ class ReconstructionState(str, Enum):
44
+ EXACT_PATH = "EXACT_PATH"
45
+ AMBIGUOUS_PATH = "AMBIGUOUS_PATH"
46
+ UNKNOWN_GAP = "UNKNOWN_GAP"
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class ReconstructedPath:
51
+ start_function: str
52
+ end_function: str
53
+ state: ReconstructionState
54
+ paths: tuple[tuple[str, ...], ...]
55
+
56
+
57
+ def discover_correlations(
58
+ events: list[RuntimeEvent],
59
+ *,
60
+ exact_min_repetitions: int = 3,
61
+ ) -> list[CorrelationRelation]:
62
+ """Find conservative payload correlations and optionally promote stable ones.
63
+
64
+ A relation is promoted to CORRELATED_EXACT only when the *same field* links
65
+ the same marker pair across at least ``exact_min_repetitions`` distinct values,
66
+ and each of those values occurs in exactly two events. A single matching value
67
+ or time proximity is never enough. Set ``exact_min_repetitions <= 0`` to
68
+ disable automatic exact promotion.
69
+ """
70
+ occurrence: dict[tuple[str, str], list[int]] = defaultdict(list)
71
+ for e in events:
72
+ for key, value in e.payload:
73
+ occurrence[(key, value)].append(e.event_index)
74
+
75
+ by_index = {e.event_index: e for e in events}
76
+ pair_fields: dict[tuple[int, int], list[tuple[str, str, float]]] = defaultdict(list)
77
+ for (key, value), indexes in occurrence.items():
78
+ # Very common values are weak and create too many false edges.
79
+ if len(indexes) < 2 or len(indexes) > 8:
80
+ continue
81
+ uniqueness = 1.0 / len(indexes)
82
+ for i, left in enumerate(indexes):
83
+ for right in indexes[i + 1:]:
84
+ a, b = by_index[left], by_index[right]
85
+ # Same marker repeated is useful for repetition, not transaction linking.
86
+ if a.marker_id == b.marker_id:
87
+ continue
88
+ pair_fields[(left, right)].append((key, value, uniqueness))
89
+
90
+ stable_exact: set[tuple[str, tuple[str, str]]] = set()
91
+ if exact_min_repetitions > 0:
92
+ # key + marker-pair -> distinct values that uniquely link exactly two events
93
+ stable_values: dict[tuple[str, tuple[str, str]], set[str]] = defaultdict(set)
94
+ for (key, value), indexes in occurrence.items():
95
+ if len(indexes) != 2:
96
+ continue
97
+ a, b = by_index[indexes[0]], by_index[indexes[1]]
98
+ if a.marker_id == b.marker_id:
99
+ continue
100
+ marker_pair = tuple(sorted((a.marker_id, b.marker_id)))
101
+ stable_values[(key, marker_pair)].add(value)
102
+ stable_exact = {
103
+ group for group, values in stable_values.items()
104
+ if len(values) >= exact_min_repetitions
105
+ }
106
+
107
+ out: list[CorrelationRelation] = []
108
+ for (left, right), fields in sorted(pair_fields.items()):
109
+ a, b = by_index[left], by_index[right]
110
+ keys = tuple(x[0] for x in fields)
111
+ values = tuple(x[1] for x in fields)
112
+ score = sum(x[2] for x in fields)
113
+ if a.cpu_hint and b.cpu_hint and a.cpu_hint != b.cpu_hint:
114
+ score += 0.25
115
+ if a.task_hint and b.task_hint and a.task_hint != b.task_hint:
116
+ score += 0.10
117
+ marker_pair = tuple(sorted((a.marker_id, b.marker_id)))
118
+ exact_keys = [key for key, _value, _u in fields if (key, marker_pair) in stable_exact]
119
+ relation_type = (
120
+ RuntimeRelationType.CORRELATED_EXACT
121
+ if exact_keys
122
+ else RuntimeRelationType.CORRELATED_POSSIBLE
123
+ )
124
+ out.append(CorrelationRelation(
125
+ left_event_index=left,
126
+ right_event_index=right,
127
+ relation_type=relation_type,
128
+ keys=keys,
129
+ values=values,
130
+ score=score,
131
+ ))
132
+ return sorted(out, key=lambda r: (-r.score, r.left_event_index, r.right_event_index))
133
+
134
+
135
+ def fit_clock_alignments(
136
+ events: list[RuntimeEvent],
137
+ relations: list[CorrelationRelation],
138
+ *,
139
+ min_anchors: int = 3,
140
+ ) -> list[ClockAlignment]:
141
+ """Fit affine clock mappings using CORRELATED_EXACT anchors only.
142
+
143
+ Possible correlations are deliberately excluded to avoid circularly turning a
144
+ weak payload/time coincidence into stronger temporal evidence.
145
+ """
146
+ by_index = {e.event_index: e for e in events}
147
+ pairs: dict[tuple[str, str], list[tuple[float, float]]] = defaultdict(list)
148
+ for rel in relations:
149
+ if rel.relation_type != RuntimeRelationType.CORRELATED_EXACT:
150
+ continue
151
+ left, right = by_index.get(rel.left_event_index), by_index.get(rel.right_event_index)
152
+ if left is None or right is None or left.timestamp is None or right.timestamp is None:
153
+ continue
154
+ ld = left.clock_domain or left.cpu_hint or "default"
155
+ rd = right.clock_domain or right.cpu_hint or "default"
156
+ if ld == rd:
157
+ continue
158
+ pairs[(ld, rd)].append((_datetime_seconds(left.timestamp), _datetime_seconds(right.timestamp)))
159
+
160
+ out: list[ClockAlignment] = []
161
+ for (source, target), anchors in sorted(pairs.items()):
162
+ if len(anchors) < min_anchors:
163
+ continue
164
+ xs = [x for x, _ in anchors]
165
+ ys = [y for _, y in anchors]
166
+ xbar, ybar = sum(xs) / len(xs), sum(ys) / len(ys)
167
+ denom = sum((x - xbar) ** 2 for x in xs)
168
+ scale = 1.0 if denom == 0 else sum((x - xbar) * (y - ybar) for x, y in anchors) / denom
169
+ offset = ybar - scale * xbar
170
+ residuals = [abs((scale * x + offset) - y) for x, y in anchors]
171
+ max_residual = max(residuals, default=0.0)
172
+ if max_residual <= 0.001:
173
+ confidence = "HIGH"
174
+ elif max_residual <= 0.100:
175
+ confidence = "MEDIUM"
176
+ else:
177
+ confidence = "LOW"
178
+ out.append(ClockAlignment(
179
+ source_domain=source, target_domain=target, scale=scale,
180
+ offset_seconds=offset, anchor_count=len(anchors),
181
+ max_abs_residual_seconds=max_residual, confidence=confidence,
182
+ ))
183
+ return out
184
+
185
+
186
+ def _datetime_seconds(value) -> float:
187
+ # Stable naive-clock numeric representation without local-timezone conversion.
188
+ return (
189
+ value.toordinal() * 86400.0
190
+ + value.hour * 3600.0
191
+ + value.minute * 60.0
192
+ + value.second
193
+ + value.microsecond / 1_000_000.0
194
+ )
195
+
196
+
197
+ def reconstruct_static_paths(
198
+ index: ProjectIndex,
199
+ start_function: str,
200
+ end_function: str,
201
+ *,
202
+ max_paths: int = 8,
203
+ max_depth: int = 64,
204
+ ) -> ReconstructedPath:
205
+ graph: dict[str, set[str]] = defaultdict(set)
206
+ functions_by_id = {f.qualified_id: f for f in index.functions()}
207
+
208
+ for call in index.callsites:
209
+ targets: list[str] = []
210
+ if call.target_kind == CallTargetKind.INTERNAL_EXACT and call.target_function_id:
211
+ target = functions_by_id.get(call.target_function_id)
212
+ if target:
213
+ targets.append(target.name)
214
+ elif call.target_kind in {CallTargetKind.INTERNAL_POSSIBLE, CallTargetKind.INDIRECT_EXACT, CallTargetKind.INDIRECT_POSSIBLE}:
215
+ for tid in call.target_function_ids:
216
+ target = functions_by_id.get(tid)
217
+ if target:
218
+ targets.append(target.name)
219
+ for target_name in targets:
220
+ graph[call.caller_name].add(target_name)
221
+
222
+ paths: list[tuple[str, ...]] = []
223
+ q: deque[tuple[str, tuple[str, ...]]] = deque([(start_function, (start_function,))])
224
+ shortest_len: int | None = None
225
+ while q and len(paths) < max_paths:
226
+ node, path = q.popleft()
227
+ if shortest_len is not None and len(path) > shortest_len:
228
+ continue
229
+ if len(path) > max_depth:
230
+ continue
231
+ if node == end_function:
232
+ paths.append(path)
233
+ shortest_len = len(path) if shortest_len is None else shortest_len
234
+ continue
235
+ for nxt in sorted(graph.get(node, ())):
236
+ if nxt in path: # avoid recursion/SCC infinite expansion in the PoC path search
237
+ continue
238
+ q.append((nxt, path + (nxt,)))
239
+
240
+ if not paths:
241
+ state = ReconstructionState.UNKNOWN_GAP
242
+ elif len(paths) == 1:
243
+ state = ReconstructionState.EXACT_PATH
244
+ else:
245
+ state = ReconstructionState.AMBIGUOUS_PATH
246
+ return ReconstructedPath(start_function, end_function, state, tuple(paths))
247
+
248
+ @dataclass(frozen=True, slots=True)
249
+ class MarkerSuggestion:
250
+ function_name: str
251
+ distinguished_path_pairs: int
252
+ total_path_pairs: int
253
+ information_gain_score: float
254
+ reason: str
255
+
256
+
257
+ def suggest_markers_for_paths(paths: tuple[tuple[str, ...], ...], *, count: int = 1) -> list[MarkerSuggestion]:
258
+ """Rank function nodes by how many ambiguous path pairs they distinguish.
259
+
260
+ This is the PoC core of marker suggestion. Entry/end nodes and nodes shared
261
+ by every path have zero discrimination and are excluded. Runtime cost and
262
+ existing-log reuse are intentionally separate scoring factors to add later.
263
+ """
264
+ if len(paths) < 2 or count <= 0:
265
+ return []
266
+ path_sets = [set(p) for p in paths]
267
+ endpoints = {paths[0][0], paths[0][-1]}
268
+ candidates = sorted(set().union(*path_sets) - endpoints)
269
+ total_pairs = len(paths) * (len(paths) - 1) // 2
270
+ ranked: list[MarkerSuggestion] = []
271
+ for fn in candidates:
272
+ distinguished = 0
273
+ for i in range(len(paths)):
274
+ for j in range(i + 1, len(paths)):
275
+ if (fn in path_sets[i]) != (fn in path_sets[j]):
276
+ distinguished += 1
277
+ if distinguished == 0:
278
+ continue
279
+ score = distinguished / total_pairs if total_pairs else 0.0
280
+ ranked.append(MarkerSuggestion(
281
+ function_name=fn,
282
+ distinguished_path_pairs=distinguished,
283
+ total_path_pairs=total_pairs,
284
+ information_gain_score=score,
285
+ reason=f"present on a subset of ambiguous paths; distinguishes {distinguished}/{total_pairs} path pairs",
286
+ ))
287
+ ranked.sort(key=lambda x: (-x.information_gain_score, x.function_name))
288
+ return ranked[:count]
289
+
290
+ @dataclass(frozen=True, slots=True)
291
+ class MarkerCandidateContext:
292
+ has_existing_log: bool = False
293
+ hotness: float = 0.0
294
+ source_stability: float = 1.0
295
+
296
+
297
+ def score_marker_suggestions(
298
+ paths: tuple[tuple[str, ...], ...],
299
+ *,
300
+ count: int = 1,
301
+ contexts: dict[str, MarkerCandidateContext] | None = None,
302
+ ) -> list[MarkerSuggestion]:
303
+ """Rank ambiguous-path marker points with transparent multi-factor scoring.
304
+
305
+ Information gain remains the dominant factor. Existing-log reuse is a bonus;
306
+ hot-path and unstable-source locations are penalties. All factors are bounded
307
+ so a low-information point cannot outrank a clearly discriminating point only
308
+ because it already has a log call.
309
+ """
310
+ contexts = contexts or {}
311
+ base = suggest_markers_for_paths(paths, count=10_000)
312
+ rescored: list[MarkerSuggestion] = []
313
+ for item in base:
314
+ ctx = contexts.get(item.function_name, MarkerCandidateContext())
315
+ hotness = min(max(ctx.hotness, 0.0), 1.0)
316
+ stability = min(max(ctx.source_stability, 0.0), 1.0)
317
+ reuse_bonus = 0.12 if ctx.has_existing_log else 0.0
318
+ hot_penalty = 0.20 * hotness
319
+ instability_penalty = 0.10 * (1.0 - stability)
320
+ total = max(0.0, item.information_gain_score + reuse_bonus - hot_penalty - instability_penalty)
321
+ reason = (
322
+ f"information_gain={item.information_gain_score:.3f}; "
323
+ f"existing_log_bonus={reuse_bonus:.3f}; "
324
+ f"hotness_penalty={hot_penalty:.3f}; "
325
+ f"source_instability_penalty={instability_penalty:.3f}; "
326
+ f"total={total:.3f}"
327
+ )
328
+ rescored.append(MarkerSuggestion(
329
+ function_name=item.function_name,
330
+ distinguished_path_pairs=item.distinguished_path_pairs,
331
+ total_path_pairs=item.total_path_pairs,
332
+ information_gain_score=total,
333
+ reason=reason,
334
+ ))
335
+ rescored.sort(key=lambda x: (-x.information_gain_score, x.function_name))
336
+ return rescored[:count]
cseq/trace_diff.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from collections import Counter
5
+ from .runtime import TraceSession
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class TraceDiffResult:
10
+ left_session: str
11
+ right_session: str
12
+ added_markers: tuple[tuple[str, int], ...]
13
+ missing_markers: tuple[tuple[str, int], ...]
14
+ changed_cpu: tuple[tuple[str, tuple[str, ...], tuple[str, ...]], ...]
15
+ changed_task: tuple[tuple[str, tuple[str, ...], tuple[str, ...]], ...]
16
+ order_changes: tuple[tuple[str, int, int], ...] = ()
17
+ timing_changes: tuple[tuple[str, str | None, str | None], ...] = ()
18
+
19
+
20
+ def diff_sessions(left: TraceSession, right: TraceSession) -> TraceDiffResult:
21
+ lc = Counter(e.marker_id for e in left.events)
22
+ rc = Counter(e.marker_id for e in right.events)
23
+ added = tuple(sorted((m, rc[m] - lc[m]) for m in rc if rc[m] > lc[m]))
24
+ missing = tuple(sorted((m, lc[m] - rc[m]) for m in lc if lc[m] > rc[m]))
25
+
26
+ def domains(session: TraceSession, attr: str) -> dict[str, tuple[str, ...]]:
27
+ out: dict[str, set[str]] = {}
28
+ for e in session.events:
29
+ value = getattr(e, attr)
30
+ if value:
31
+ out.setdefault(e.marker_id, set()).add(value)
32
+ return {k: tuple(sorted(v)) for k, v in out.items()}
33
+
34
+ lcpu, rcpu = domains(left, "cpu_hint"), domains(right, "cpu_hint")
35
+ ltask, rtask = domains(left, "task_hint"), domains(right, "task_hint")
36
+ shared = sorted(set(lc) & set(rc))
37
+ changed_cpu = tuple((m, lcpu.get(m, ()), rcpu.get(m, ())) for m in shared if lcpu.get(m, ()) != rcpu.get(m, ()))
38
+ changed_task = tuple((m, ltask.get(m, ()), rtask.get(m, ())) for m in shared if ltask.get(m, ()) != rtask.get(m, ()))
39
+
40
+ def first_positions(session: TraceSession) -> dict[str, int]:
41
+ out: dict[str, int] = {}
42
+ for e in session.events:
43
+ out.setdefault(e.marker_id, e.event_index)
44
+ return out
45
+
46
+ def first_times(session: TraceSession) -> dict[str, str | None]:
47
+ out: dict[str, str | None] = {}
48
+ for e in session.events:
49
+ out.setdefault(e.marker_id, e.timestamp_raw)
50
+ return out
51
+
52
+ lp, rp = first_positions(left), first_positions(right)
53
+ lt, rt = first_times(left), first_times(right)
54
+ order_changes = tuple((m, lp[m], rp[m]) for m in shared if lp[m] != rp[m])
55
+ timing_changes = tuple((m, lt.get(m), rt.get(m)) for m in shared if lt.get(m) != rt.get(m))
56
+ return TraceDiffResult(left.name, right.name, added, missing, changed_cpu, changed_task, order_changes, timing_changes)