codecaliper 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.
Files changed (46) hide show
  1. codecaliper/__init__.py +64 -0
  2. codecaliper/_version.py +1 -0
  3. codecaliper/api.py +446 -0
  4. codecaliper/canonical.py +130 -0
  5. codecaliper/cli.py +231 -0
  6. codecaliper/errors.py +27 -0
  7. codecaliper/languages/__init__.py +44 -0
  8. codecaliper/languages/base.py +162 -0
  9. codecaliper/languages/java.py +192 -0
  10. codecaliper/languages/python.py +179 -0
  11. codecaliper/metrics/__init__.py +0 -0
  12. codecaliper/metrics/base.py +55 -0
  13. codecaliper/metrics/cognitive.py +111 -0
  14. codecaliper/metrics/cyclomatic.py +62 -0
  15. codecaliper/metrics/halstead.py +55 -0
  16. codecaliper/metrics/loc.py +62 -0
  17. codecaliper/metrics/mi.py +31 -0
  18. codecaliper/model.py +132 -0
  19. codecaliper/py.typed +0 -0
  20. codecaliper/readability/__init__.py +13 -0
  21. codecaliper/readability/base.py +11 -0
  22. codecaliper/readability/bw2010.py +156 -0
  23. codecaliper/readability/granularity.py +210 -0
  24. codecaliper/readability/retrain.py +67 -0
  25. codecaliper/spec/__init__.py +3 -0
  26. codecaliper/spec/registry.py +112 -0
  27. codecaliper/spec/rulings/bw.toml +156 -0
  28. codecaliper/spec/rulings/cognitive.toml +124 -0
  29. codecaliper/spec/rulings/core.toml +87 -0
  30. codecaliper/spec/rulings/cyclomatic.toml +172 -0
  31. codecaliper/spec/rulings/halstead.toml +20 -0
  32. codecaliper/spec/rulings/index.toml +54 -0
  33. codecaliper/spec/rulings/loc.toml +50 -0
  34. codecaliper/spec/rulings/mi.toml +15 -0
  35. codecaliper/spec/rulings/tokenization.toml +191 -0
  36. codecaliper/spec/validated_grammars.toml +6 -0
  37. codecaliper/syntax/__init__.py +0 -0
  38. codecaliper/syntax/_treesitter.py +179 -0
  39. codecaliper/syntax/grammars.py +45 -0
  40. codecaliper/syntax/tokens.py +165 -0
  41. codecaliper-0.1.0.dist-info/METADATA +180 -0
  42. codecaliper-0.1.0.dist-info/RECORD +46 -0
  43. codecaliper-0.1.0.dist-info/WHEEL +4 -0
  44. codecaliper-0.1.0.dist-info/entry_points.txt +2 -0
  45. codecaliper-0.1.0.dist-info/licenses/LICENSE +21 -0
  46. codecaliper-0.1.0.dist-info/licenses/NOTICE +29 -0
codecaliper/model.py ADDED
@@ -0,0 +1,132 @@
1
+ """Result data model.
2
+
3
+ All result types are frozen, slotted dataclasses: immutable, clock-free (no
4
+ timestamps anywhere — outputs are byte-reproducible per platform, CORE-ALL-0004),
5
+ with a stable field order that canonical.py serializes deterministically.
6
+
7
+ Honesty is encoded in types, not docs: MI carries a typed ``derived_from`` and a
8
+ standing ``mi-contains-cc`` diagnostic; every Halstead value carries
9
+ ``halstead-approximation``; readability results carry ``extrapolated`` as a field;
10
+ there is deliberately no score field anywhere (ARCHITECTURE.md §13).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Literal
17
+
18
+ Granularity = Literal["snippet", "function", "file"]
19
+ Severity = Literal["info", "warning", "error"]
20
+
21
+ #: Closed set of diagnostic codes (ARCHITECTURE.md §2).
22
+ DIAGNOSTIC_CODES = frozenset(
23
+ {
24
+ "parse-error-recovered",
25
+ "unvalidated-grammar",
26
+ "granularity-extrapolated",
27
+ "snippet-out-of-calibrated-range",
28
+ "snippet-scaffolded",
29
+ "bw-lexical-fallback",
30
+ "mi-contains-cc",
31
+ "halstead-approximation",
32
+ "encoding-replaced",
33
+ "bom-stripped",
34
+ }
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class Span:
40
+ """1-based line numbers, 0-based columns (editor convention)."""
41
+
42
+ start_line: int
43
+ start_col: int
44
+ end_line: int
45
+ end_col: int
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class GrammarInfo:
50
+ language: str # "python"
51
+ package: str # "tree-sitter-python"
52
+ version: str # actually-installed version (importlib.metadata)
53
+ abi_version: int # tree-sitter Language ABI
54
+ validated: bool # matches spec/validated_grammars.toml? Run and label, never refuse.
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class Provenance:
59
+ tool_version: str
60
+ spec_version: str # on EVERY report — the instrument's calibration stamp
61
+ language: str
62
+ grammar: GrammarInfo
63
+ modes: tuple[tuple[str, str], ...] # (("cognitive", "whitepaper"),) — sorted, hashable
64
+ rulings_applied: tuple[str, ...] # sorted, deduped IDs governing the emitted values
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class Diagnostic:
69
+ severity: Severity
70
+ code: str # member of DIAGNOSTIC_CODES
71
+ message: str
72
+ span: Span | None = None
73
+ ruling: str | None = None # the ruling governing this behaviour, if any
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class RulingTrace:
78
+ """Per-increment attribution, populated only with explain=True (zero default cost)."""
79
+
80
+ ruling_id: str
81
+ span: Span
82
+ delta: float
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class MetricValue:
87
+ metric: str # "cyclomatic", "halstead.volume", ...
88
+ value: int | float
89
+ rulings: tuple[str, ...] = () # ruling IDs governing this metric for this language
90
+ derived_from: tuple[str, ...] = () # MI: ("halstead.volume", "cyclomatic", "sloc")
91
+ trace: tuple[RulingTrace, ...] = ()
92
+ diagnostics: tuple[Diagnostic, ...] = ()
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class FeatureVectorResult:
97
+ """A raw feature vector — never a score (ARCHITECTURE.md §7/§13)."""
98
+
99
+ feature_set: str # "bw2010"
100
+ granularity: Granularity
101
+ native_granularity: Granularity # "snippet" for bw2010
102
+ extrapolated: bool # granularity != native_granularity
103
+ names: tuple[str, ...] # canonical order (bw2010: Fig. 6 order from the reference)
104
+ values: tuple[float, ...]
105
+ rulings: tuple[str, ...] = ()
106
+ diagnostics: tuple[Diagnostic, ...] = ()
107
+ unit_name: str | None = None # function name when granularity == "function"
108
+ span: Span | None = None
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class FunctionReport:
113
+ name: str
114
+ qualified_name: str
115
+ span: Span
116
+ metrics: tuple[MetricValue, ...] = ()
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class FileReport:
121
+ path: str | None # None for in-memory measure()
122
+ parse_ok: bool # False if the tree contains ERROR/MISSING nodes (CORE-ALL-0002)
123
+ file_metrics: tuple[MetricValue, ...] = ()
124
+ functions: tuple[FunctionReport, ...] = ()
125
+ readability: tuple[FeatureVectorResult, ...] = () # plural: feature SETS, not scores
126
+ diagnostics: tuple[Diagnostic, ...] = ()
127
+ provenance: Provenance | None = None
128
+
129
+
130
+ def metric_map(values: tuple[MetricValue, ...]) -> dict[str, MetricValue]:
131
+ """Convenience view keyed by metric name (stable insertion order preserved)."""
132
+ return {v.metric: v for v in values}
codecaliper/py.typed ADDED
File without changes
@@ -0,0 +1,13 @@
1
+ from codecaliper.readability.base import available_feature_sets
2
+ from codecaliper.readability.bw2010 import (
3
+ BW_FEATURE_NAMES,
4
+ BW_FEATURE_ORDER_SHA,
5
+ bw_features,
6
+ )
7
+
8
+ __all__ = [
9
+ "BW_FEATURE_NAMES",
10
+ "BW_FEATURE_ORDER_SHA",
11
+ "available_feature_sets",
12
+ "bw_features",
13
+ ]
@@ -0,0 +1,11 @@
1
+ """FeatureSet registry — bw2010 now; scalabrino2018/dorn2012 slot in at 1.x
2
+ without model changes (FileReport.readability is already plural)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ #: name -> native granularity; extraction dispatch lives in api.py for the MVP.
7
+ FEATURE_SETS: dict[str, str] = {"bw2010": "snippet"}
8
+
9
+
10
+ def available_feature_sets() -> tuple[str, ...]:
11
+ return tuple(FEATURE_SETS)
@@ -0,0 +1,156 @@
1
+ """The 25 Buse-Weimer (2010, Fig. 6) readability features on tree-sitter.
2
+
3
+ Canonical feature names and order are inherited verbatim from the reference
4
+ implementation (Spaghetti Architect bench/anchor.py::_BW_FEATURE_NAMES,
5
+ NOTICE-credited); tests/test_bw_port_fidelity.py holds this port to the stdlib
6
+ reference per feature. Output is always the raw vector — there is no score
7
+ (BW-ALL-0001).
8
+
9
+ Two feature families (BW-ALL-0004):
10
+ - raw-line facts (length, indentation, spaces, blanks, char frequency) from the
11
+ normalized text of every line, strings included;
12
+ - token facts (identifiers, keywords, numbers, comments, operator classes,
13
+ punctuation) from the unified LexicalToken stream, so string/comment contents
14
+ are invisible to them.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+
21
+ from codecaliper.languages.base import LanguageAdapter
22
+ from codecaliper.spec import require
23
+ from codecaliper.syntax.tokens import LexicalToken, TokenKind
24
+
25
+ R_FEATURES = require("BW-ALL-0001")
26
+ R_GRANULARITY = require("BW-ALL-0002")
27
+ R_COMMENT_ONCE = require("BW-ALL-0003")
28
+ R_TOKEN_LEVEL = require("BW-ALL-0004")
29
+ R_ID_LENGTH = require("BW-ALL-0005")
30
+ R_OP_CLASSES = require("BW-ALL-0006")
31
+
32
+ #: Fig. 6 order — verbatim from the reference implementation.
33
+ BW_FEATURE_NAMES: tuple[str, ...] = (
34
+ "avg_line_length", "max_line_length",
35
+ "avg_identifiers", "max_identifiers",
36
+ "avg_identifier_length", "max_identifier_length",
37
+ "avg_indentation", "max_indentation",
38
+ "avg_keywords", "max_keywords",
39
+ "avg_numbers", "max_numbers",
40
+ "avg_comments", "avg_periods", "avg_commas", "avg_spaces",
41
+ "avg_parentheses", "avg_arithmetic_ops", "avg_comparison_ops",
42
+ "avg_assignments", "avg_branches", "avg_loops", "avg_blank_lines",
43
+ "max_char_occurrences", "max_identifier_occurrences",
44
+ )
45
+
46
+ #: Feature-order integrity stamp for trained-model artifacts (ARCHITECTURE.md §7.2).
47
+ BW_FEATURE_ORDER_SHA: str = hashlib.sha256(",".join(BW_FEATURE_NAMES).encode()).hexdigest()
48
+
49
+ #: The rulings in force for every bw2010 vector.
50
+ BW_RULINGS: tuple[str, ...] = (
51
+ R_FEATURES, R_GRANULARITY, R_COMMENT_ONCE, R_TOKEN_LEVEL, R_ID_LENGTH, R_OP_CLASSES,
52
+ )
53
+
54
+ #: The paper's calibrated snippet regime (BW-ALL-0002).
55
+ CALIBRATED_LINE_RANGE = (4, 11)
56
+
57
+
58
+ def bw_features(
59
+ lines: list[str], tokens: list[LexicalToken], adapter: LanguageAdapter
60
+ ) -> dict[str, float]:
61
+ """Compute the 25 features. ``tokens`` line numbers must be 1-based relative
62
+ to ``lines`` (granularity slicing rebases them)."""
63
+ n_lines = max(1, len(lines))
64
+
65
+ line_len: list[int] = []
66
+ indent: list[int] = []
67
+ spaces = 0
68
+ blank = 0
69
+ char_freq: dict[str, int] = {}
70
+ for line in lines:
71
+ line_len.append(len(line))
72
+ stripped = line.lstrip()
73
+ # TOK-ALL-0006 (supersedes TOK-ALL-0004): a tab counts as 8 indentation
74
+ # characters, arbitrated by the BW faithfulness experiment. Line length
75
+ # above stays a raw character count.
76
+ leading = line[: len(line) - len(stripped)]
77
+ indent.append(sum(8 if ch == "\t" else 1 for ch in leading))
78
+ spaces += line.count(" ")
79
+ if stripped == "":
80
+ blank += 1
81
+ for ch in line:
82
+ char_freq[ch] = char_freq.get(ch, 0) + 1
83
+
84
+ ident_per_line = [0] * len(lines)
85
+ ident_len_per_line = [0] * len(lines) # per-line max identifier length (BW-ALL-0005)
86
+ kw_per_line = [0] * len(lines)
87
+ num_per_line = [0] * len(lines)
88
+ comments = periods = commas = parens = 0
89
+ arith = compare = assign = branch = loop = 0
90
+ ident_freq: dict[str, int] = {}
91
+
92
+ for tok in tokens:
93
+ i = tok.line - 1
94
+ if not (0 <= i < len(lines)):
95
+ continue
96
+ if tok.kind is TokenKind.IDENTIFIER:
97
+ ident_per_line[i] += 1
98
+ ident_len_per_line[i] = max(ident_len_per_line[i], len(tok.text))
99
+ ident_freq[tok.text] = ident_freq.get(tok.text, 0) + 1
100
+ elif tok.kind is TokenKind.KEYWORD:
101
+ kw_per_line[i] += 1
102
+ if tok.text in adapter.branch_keywords:
103
+ branch += 1
104
+ elif tok.text in adapter.loop_keywords:
105
+ loop += 1
106
+ elif tok.kind is TokenKind.NUMBER:
107
+ num_per_line[i] += 1
108
+ elif tok.kind is TokenKind.COMMENT:
109
+ comments += 1 # once per token (BW-ALL-0003, provisional)
110
+ elif tok.kind in (TokenKind.OPERATOR, TokenKind.PUNCT):
111
+ if tok.text == ",":
112
+ commas += 1
113
+ elif tok.text == ".":
114
+ periods += 1
115
+ elif tok.text in ("(", ")"):
116
+ parens += 1
117
+ if tok.text in adapter.assignment_ops:
118
+ assign += 1
119
+ elif tok.text in adapter.comparison_ops:
120
+ compare += 1
121
+ elif tok.text in adapter.arithmetic_ops:
122
+ arith += 1
123
+
124
+ def _avg(xs: list[int]) -> float:
125
+ return sum(xs) / len(xs) if xs else 0.0
126
+
127
+ def _max(xs: list[int]) -> float:
128
+ return float(max(xs)) if xs else 0.0
129
+
130
+ return {
131
+ "avg_line_length": _avg(line_len),
132
+ "max_line_length": _max(line_len),
133
+ "avg_identifiers": _avg(ident_per_line),
134
+ "max_identifiers": _max(ident_per_line),
135
+ "avg_identifier_length": _avg(ident_len_per_line),
136
+ "max_identifier_length": _max(ident_len_per_line),
137
+ "avg_indentation": _avg(indent),
138
+ "max_indentation": _max(indent),
139
+ "avg_keywords": _avg(kw_per_line),
140
+ "max_keywords": _max(kw_per_line),
141
+ "avg_numbers": _avg(num_per_line),
142
+ "max_numbers": _max(num_per_line),
143
+ "avg_comments": comments / n_lines,
144
+ "avg_periods": periods / n_lines,
145
+ "avg_commas": commas / n_lines,
146
+ "avg_spaces": spaces / n_lines,
147
+ "avg_parentheses": parens / n_lines,
148
+ "avg_arithmetic_ops": arith / n_lines,
149
+ "avg_comparison_ops": compare / n_lines,
150
+ "avg_assignments": assign / n_lines,
151
+ "avg_branches": branch / n_lines,
152
+ "avg_loops": loop / n_lines,
153
+ "avg_blank_lines": blank / n_lines,
154
+ "max_char_occurrences": float(max(char_freq.values(), default=0)),
155
+ "max_identifier_occurrences": float(max(ident_freq.values(), default=0)),
156
+ }
@@ -0,0 +1,210 @@
1
+ """Granularity segmentation and extrapolation labelling (BW-ALL-0002), plus the
2
+ Java bare-snippet scaffold (CORE-JAVA-0001)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from codecaliper.languages.base import FunctionUnit, LanguageAdapter
7
+ from codecaliper.model import Diagnostic, FeatureVectorResult, Granularity, Span
8
+ from codecaliper.readability.bw2010 import (
9
+ BW_FEATURE_NAMES,
10
+ BW_RULINGS,
11
+ CALIBRATED_LINE_RANGE,
12
+ bw_features,
13
+ )
14
+ from codecaliper.spec import require
15
+ from codecaliper.syntax.tokens import LexicalToken, lang_tokenization_rulings
16
+
17
+ R_TOK_LINE = require("TOK-ALL-0005") # token line attribution
18
+ R_TOK_TAB = require("TOK-ALL-0006") # tab = 8 indentation characters
19
+
20
+ JAVA_SNIPPET_PREFIX = "class __CC__ {\nvoid __cc__() {\n"
21
+ JAVA_SNIPPET_SUFFIX = "\n}\n}\n"
22
+ JAVA_SNIPPET_LINE_OFFSET = 2 # lines prepended by the class+method scaffold
23
+
24
+ # Class-body-only scaffold: rescues member-level snippets (constructors,
25
+ # modifier-bearing methods) that a method body cannot contain (CORE-JAVA-0001).
26
+ JAVA_CLASS_PREFIX = "class __CC__ {\n"
27
+ JAVA_CLASS_SUFFIX = "\n}\n"
28
+ JAVA_CLASS_LINE_OFFSET = 1
29
+
30
+
31
+ def rebase_unit(unit: FunctionUnit, line_offset: int, n_lines: int) -> FunctionUnit:
32
+ """Shift a FunctionUnit's span from scaffold to original snippet coordinates,
33
+ and strip the synthetic scaffold qualifiers from qualified_name — scaffold
34
+ artifacts must never leak into emitted output (CORE-JAVA-0001). Longest
35
+ prefix first: the class+method scaffold nests units under __CC__.__cc__."""
36
+ qualified = unit.qualified_name
37
+ for scaffold_prefix in ("__CC__.__cc__.", "__CC__."):
38
+ if qualified.startswith(scaffold_prefix):
39
+ qualified = qualified[len(scaffold_prefix):]
40
+ break
41
+ s = unit.span
42
+ return FunctionUnit(
43
+ unit.name,
44
+ qualified,
45
+ unit.node,
46
+ Span(
47
+ start_line=s.start_line - line_offset,
48
+ start_col=s.start_col,
49
+ end_line=min(s.end_line - line_offset, n_lines),
50
+ end_col=s.end_col,
51
+ ),
52
+ )
53
+
54
+
55
+ def rebase_tokens(
56
+ tokens: list[LexicalToken], start_line: int, end_line: int
57
+ ) -> list[LexicalToken]:
58
+ """Tokens whose start lies in [start_line, end_line], rebased to 1-based."""
59
+ offset = start_line - 1
60
+ return [
61
+ LexicalToken(t.kind, t.text, t.line - offset, min(t.end_line, end_line) - offset)
62
+ for t in tokens
63
+ if start_line <= t.line <= end_line
64
+ ]
65
+
66
+
67
+ def _bw_vector_rulings(
68
+ adapter: LanguageAdapter, lexical_fallback: bool, cond_rulings: tuple[str, ...]
69
+ ) -> tuple[str, ...]:
70
+ """The governing set for an emitted BW vector: the six BW-ALL requires,
71
+ the language-specific bw2010 rulings (keyword/branch-keyword delimitation),
72
+ and the tokenization rulings that shape the features (TOK-ALL-0005 line
73
+ attribution, TOK-ALL-0006 tab=8 indentation, the language's static
74
+ atomic-string rulings). ``cond_rulings`` are the construct-specific
75
+ tokenization rulings (TOK-ALL-0007 anonymous words, TOK-JAVA-0002 `_`) that
76
+ actually engaged inside THIS vector's line span; BW-ALL-0007 stays strictly
77
+ conditional on the lexical fallback actually engaging."""
78
+ from codecaliper.spec import iter_rulings
79
+
80
+ lang_specific = tuple(sorted(
81
+ r.id
82
+ for r in iter_rulings(metric="bw2010", language=adapter.name)
83
+ if r.id not in BW_RULINGS and r.id != "BW-ALL-0007"
84
+ ))
85
+ return (
86
+ BW_RULINGS
87
+ + lang_specific
88
+ + (R_TOK_LINE, R_TOK_TAB)
89
+ + lang_tokenization_rulings(adapter)
90
+ + tuple(sorted(cond_rulings))
91
+ + (("BW-ALL-0007",) if lexical_fallback else ())
92
+ )
93
+
94
+
95
+ def vector(
96
+ lines: list[str],
97
+ tokens: list[LexicalToken],
98
+ adapter: LanguageAdapter,
99
+ granularity: Granularity,
100
+ *,
101
+ unit_name: str | None = None,
102
+ span: Span | None = None,
103
+ scaffolded: bool = False,
104
+ lexical_fallback: bool = False,
105
+ cond_rulings: tuple[str, ...] = (),
106
+ ) -> FeatureVectorResult:
107
+ features = bw_features(lines, tokens, adapter)
108
+ diags: list[Diagnostic] = []
109
+ if lexical_fallback:
110
+ diags.append(
111
+ Diagnostic(
112
+ "info", "bw-lexical-fallback",
113
+ "parse errors present; token-family features computed over the "
114
+ "full lexical stream, ERROR regions included — the BW construct "
115
+ "is lexical (BW-ALL-0007). CORE-ALL-0002 still governs every "
116
+ "metric.",
117
+ ruling="BW-ALL-0007",
118
+ )
119
+ )
120
+ extrapolated = granularity != "snippet"
121
+ if extrapolated:
122
+ diags.append(
123
+ Diagnostic(
124
+ "warning", "granularity-extrapolated",
125
+ f"bw2010 is calibrated on snippets; {granularity}-level vectors are "
126
+ "extrapolation beyond the model's native unit (BW-ALL-0002)",
127
+ ruling="BW-ALL-0002",
128
+ )
129
+ )
130
+ lo, hi = CALIBRATED_LINE_RANGE
131
+ if granularity == "snippet" and not (lo <= len(lines) <= hi):
132
+ diags.append(
133
+ Diagnostic(
134
+ "info", "snippet-out-of-calibrated-range",
135
+ f"snippet is {len(lines)} lines; the bw2010 calibrated regime is "
136
+ f"{lo}-{hi} lines (BW-ALL-0002)",
137
+ ruling="BW-ALL-0002",
138
+ )
139
+ )
140
+ if scaffolded:
141
+ diags.append(
142
+ Diagnostic(
143
+ "info", "snippet-scaffolded",
144
+ "bare Java snippet was parsed inside a synthetic class/method "
145
+ "scaffold; features computed over the original lines only "
146
+ "(CORE-JAVA-0001)",
147
+ ruling="CORE-JAVA-0001",
148
+ )
149
+ )
150
+ return FeatureVectorResult(
151
+ feature_set="bw2010",
152
+ granularity=granularity,
153
+ native_granularity="snippet",
154
+ extrapolated=extrapolated,
155
+ names=BW_FEATURE_NAMES,
156
+ values=tuple(features[n] for n in BW_FEATURE_NAMES),
157
+ rulings=_bw_vector_rulings(adapter, lexical_fallback, cond_rulings),
158
+ diagnostics=tuple(diags),
159
+ unit_name=unit_name,
160
+ span=span,
161
+ )
162
+
163
+
164
+ def cond_in_range(cond_lines: dict[str, set[int]], lo: int, hi: int) -> tuple[str, ...]:
165
+ """Conditional tokenization rulings that engaged on a line within [lo, hi]."""
166
+ return tuple(sorted(
167
+ rid for rid, lns in cond_lines.items() if any(lo <= ln <= hi for ln in lns)
168
+ ))
169
+
170
+
171
+ def function_vectors(
172
+ lines: list[str],
173
+ tokens: list[LexicalToken],
174
+ adapter: LanguageAdapter,
175
+ units: list[FunctionUnit],
176
+ opaque_tokens: list[LexicalToken] | None = None,
177
+ cond_opaque: dict[str, set[int]] | None = None,
178
+ cond_full: dict[str, set[int]] | None = None,
179
+ ) -> list[FeatureVectorResult]:
180
+ """Per-unit vectors. ``opaque_tokens`` is the error-opaque stream, passed
181
+ only when the file-level BW lexical fallback engaged (BW-ALL-0007): a
182
+ unit's vector is flagged only when its OWN span gained ERROR-region tokens
183
+ — the opaque stream is a subsequence of the full one, so equal slice
184
+ lengths imply identical slices. ``cond_opaque``/``cond_full`` map each
185
+ conditional tokenization ruling to the lines it engaged on in the
186
+ respective stream; a unit cites one only when it engaged inside the unit's
187
+ OWN span (in the stream that unit actually used)."""
188
+ out = []
189
+ for unit in units:
190
+ s, e = unit.span.start_line, unit.span.end_line
191
+ unit_tokens = rebase_tokens(tokens, s, e)
192
+ unit_fallback = (
193
+ opaque_tokens is not None
194
+ and len(unit_tokens) != len(rebase_tokens(opaque_tokens, s, e))
195
+ )
196
+ cond_src = cond_full if unit_fallback else cond_opaque
197
+ cond_rulings = cond_in_range(cond_src, s, e) if cond_src is not None else ()
198
+ out.append(
199
+ vector(
200
+ lines[s - 1 : e],
201
+ unit_tokens,
202
+ adapter,
203
+ "function",
204
+ unit_name=unit.qualified_name,
205
+ span=unit.span,
206
+ lexical_fallback=unit_fallback,
207
+ cond_rulings=cond_rulings,
208
+ )
209
+ )
210
+ return out
@@ -0,0 +1,67 @@
1
+ """Retraining scaffold (ARCHITECTURE.md §7.4): feature extraction is decoupled from any
2
+ trained model; codecaliper ships NO weights. Model artifacts are JSON (never
3
+ pickle) and carry honesty metadata — applying a Java-snippet model elsewhere is
4
+ a construct-validity decision the user makes with eyes open.
5
+
6
+ The heavy lifting (logistic regression) needs the [retrain] extra
7
+ (scikit-learn); this module only defines the artifact contract so the §6.3
8
+ faithfulness pipeline and user retraining share one format.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from dataclasses import asdict, dataclass
15
+
16
+ from codecaliper.errors import SpecError
17
+ from codecaliper.readability.bw2010 import BW_FEATURE_ORDER_SHA
18
+ from codecaliper.spec import spec_version
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class ModelArtifact:
23
+ feature_set: str # "bw2010"
24
+ feature_order_sha: str # must equal BW_FEATURE_ORDER_SHA at predict time
25
+ spec_version: str
26
+ trained_granularity: str
27
+ trained_language: str
28
+ dataset_id: str # e.g. "bw2010-original-100java" — an ID, never bundled data
29
+ protocol: str # e.g. "bw2010-logistic-10fold"
30
+ seed: int
31
+ coefficients: tuple[float, ...]
32
+ intercept: float
33
+
34
+ def save(self, path: str) -> None:
35
+ with open(path, "w", encoding="utf-8") as f:
36
+ json.dump(asdict(self), f, indent=2, sort_keys=True)
37
+ f.write("\n")
38
+
39
+ @classmethod
40
+ def load(cls, path: str) -> ModelArtifact:
41
+ with open(path, encoding="utf-8") as f:
42
+ data = json.load(f)
43
+ data["coefficients"] = tuple(data["coefficients"])
44
+ art = cls(**data)
45
+ if art.feature_set == "bw2010" and art.feature_order_sha != BW_FEATURE_ORDER_SHA:
46
+ raise SpecError(
47
+ "model artifact feature order does not match this codecaliper's "
48
+ "canonical bw2010 order — refusing to predict with misaligned features"
49
+ )
50
+ return art
51
+
52
+
53
+ def new_artifact_metadata(
54
+ *, trained_granularity: str, trained_language: str, dataset_id: str,
55
+ protocol: str, seed: int,
56
+ ) -> dict[str, object]:
57
+ """Metadata stamp for a fresh training run (used by validation/bw_faithfulness)."""
58
+ return {
59
+ "feature_set": "bw2010",
60
+ "feature_order_sha": BW_FEATURE_ORDER_SHA,
61
+ "spec_version": spec_version(),
62
+ "trained_granularity": trained_granularity,
63
+ "trained_language": trained_language,
64
+ "dataset_id": dataset_id,
65
+ "protocol": protocol,
66
+ "seed": seed,
67
+ }
@@ -0,0 +1,3 @@
1
+ from codecaliper.spec.registry import Ruling, iter_rulings, require, ruling, spec_version
2
+
3
+ __all__ = ["Ruling", "iter_rulings", "require", "ruling", "spec_version"]