normform 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.
normform/__init__.py ADDED
@@ -0,0 +1,126 @@
1
+ """NormForm: Context-aware Norwegian morphological standardization, alternative generation, and style auditing."""
2
+ from __future__ import annotations
3
+
4
+ from .audit import (
5
+ extract_approximate_cache_usage,
6
+ extract_changed_alternatives,
7
+ find_inconsistencies,
8
+ )
9
+ from .batch import (
10
+ export_sealed_cache,
11
+ greedy_set_cover,
12
+ render_cached_template,
13
+ run_batch_standardization,
14
+ )
15
+ from .contract import (
16
+ DIRECT_TASK,
17
+ PROHIBITED,
18
+ STYLE_RULE,
19
+ STYLE_TASK,
20
+ Template,
21
+ clean_direct,
22
+ parse,
23
+ prohibited,
24
+ style_example,
25
+ )
26
+ from .evidence import SelectionResult, find_representative_evidence
27
+ from .harness import (
28
+ CACHE_SCHEMA,
29
+ DEFAULT_MODEL_BY_LANG,
30
+ DEFAULT_MODEL_ID,
31
+ DEFAULT_NNO_MODEL_ID,
32
+ DEFAULT_NNO_MODEL_REVISION,
33
+ DEFAULT_NOB_MODEL_ID,
34
+ DEFAULT_NOB_MODEL_REVISION,
35
+ DocumentHarness,
36
+ HarnessSummary,
37
+ SentenceSpan,
38
+ Standardizer,
39
+ forced_choice,
40
+ model_revision,
41
+ parse_prompt_words,
42
+ pinned_model_identifier,
43
+ split_sentences,
44
+ )
45
+ from .model import MultitaskModel, Resolution, load_adapter, resolve
46
+ from .styles import (
47
+ DEFAULT_STYLE_REPOS,
48
+ DEFAULT_STYLE_REVISIONS,
49
+ STYLE_ALIASES,
50
+ ChoiceCache,
51
+ NormFormYamlLoader,
52
+ StyleProfile,
53
+ canonical_choice_key,
54
+ choice_key,
55
+ invalid_pair_signature,
56
+ is_invalid_pair,
57
+ load_invalid_pairs,
58
+ load_seed_cache,
59
+ load_style_profile,
60
+ load_yaml,
61
+ pull_style,
62
+ push_style,
63
+ resolve_candidate_choice,
64
+ resolve_style_path,
65
+ )
66
+
67
+ __version__ = "0.1.0"
68
+
69
+ __all__ = [
70
+ "CACHE_SCHEMA",
71
+ "ChoiceCache",
72
+ "DEFAULT_MODEL_BY_LANG",
73
+ "DEFAULT_MODEL_ID",
74
+ "DEFAULT_NNO_MODEL_ID",
75
+ "DEFAULT_NNO_MODEL_REVISION",
76
+ "DEFAULT_NOB_MODEL_ID",
77
+ "DEFAULT_NOB_MODEL_REVISION",
78
+ "DEFAULT_STYLE_REPOS",
79
+ "DEFAULT_STYLE_REVISIONS",
80
+ "DIRECT_TASK",
81
+ "DocumentHarness",
82
+ "HarnessSummary",
83
+ "MultitaskModel",
84
+ "NormFormYamlLoader",
85
+ "PROHIBITED",
86
+ "Resolution",
87
+ "STYLE_ALIASES",
88
+ "STYLE_RULE",
89
+ "STYLE_TASK",
90
+ "SelectionResult",
91
+ "SentenceSpan",
92
+ "Standardizer",
93
+ "StyleProfile",
94
+ "Template",
95
+ "canonical_choice_key",
96
+ "choice_key",
97
+ "clean_direct",
98
+ "export_sealed_cache",
99
+ "extract_approximate_cache_usage",
100
+ "extract_changed_alternatives",
101
+ "find_inconsistencies",
102
+ "find_representative_evidence",
103
+ "forced_choice",
104
+ "greedy_set_cover",
105
+ "invalid_pair_signature",
106
+ "is_invalid_pair",
107
+ "load_adapter",
108
+ "load_invalid_pairs",
109
+ "load_seed_cache",
110
+ "load_style_profile",
111
+ "load_yaml",
112
+ "model_revision",
113
+ "parse",
114
+ "parse_prompt_words",
115
+ "pinned_model_identifier",
116
+ "prohibited",
117
+ "pull_style",
118
+ "push_style",
119
+ "render_cached_template",
120
+ "resolve",
121
+ "resolve_candidate_choice",
122
+ "resolve_style_path",
123
+ "run_batch_standardization",
124
+ "split_sentences",
125
+ "style_example",
126
+ ]
normform/audit.py ADDED
@@ -0,0 +1,327 @@
1
+ """Morphological inconsistency auditing and corpus change-impact reporting."""
2
+ from __future__ import annotations
3
+
4
+ from collections import Counter, defaultdict
5
+ import json
6
+ from pathlib import Path
7
+ import re
8
+ from typing import Any, Sequence
9
+
10
+ from .contract import parse
11
+ from .harness import DocumentHarness, choice_key, split_sentences
12
+ from .styles import load_yaml
13
+
14
+
15
+ def find_inconsistencies(
16
+ records: list[dict[str, Any]],
17
+ harness: DocumentHarness,
18
+ prompt_words: Sequence[str],
19
+ *,
20
+ use_cache: bool = True,
21
+ raw_mode: bool = False,
22
+ ) -> list[dict[str, Any]]:
23
+ """Scan records and identify inconsistent choices for identical candidate groups."""
24
+ history: dict[str, dict[str, Any]] = {}
25
+ inconsistencies: list[dict[str, Any]] = []
26
+
27
+ for rec_idx, record in enumerate(records):
28
+ source_text = record.get("text", "")
29
+ if not isinstance(source_text, str) or not source_text.strip():
30
+ continue
31
+
32
+ spans = split_sentences(source_text)
33
+
34
+ if not use_cache:
35
+ harness.cache.clear()
36
+
37
+ for sent_idx, span in enumerate(spans):
38
+ if not span.text.strip():
39
+ continue
40
+
41
+ if raw_mode:
42
+ res = harness.adapter.alternatives(span.text)
43
+ alt_text = res.get("text", span.text)
44
+ if "[" not in alt_text or "]" not in alt_text:
45
+ continue
46
+ try:
47
+ template = parse(alt_text)
48
+ if not template.groups:
49
+ continue
50
+ choices = template.align(span.text)
51
+ final_text = span.text
52
+ except Exception:
53
+ continue
54
+ else:
55
+ processed = harness.process_record(
56
+ {"text": span.text},
57
+ record_index=rec_idx,
58
+ default_prompt_words=prompt_words,
59
+ )
60
+ final_text = processed.get("standardised_text", span.text)
61
+ try:
62
+ template = parse(span.text)
63
+ if not template.groups:
64
+ continue
65
+ choices = template.align(final_text)
66
+ except Exception:
67
+ continue
68
+
69
+ for grp_idx, candidates in enumerate(template.groups):
70
+ key = choice_key(candidates)
71
+ choice = choices[grp_idx]
72
+
73
+ if key not in history:
74
+ history[key] = {
75
+ "choice": choice,
76
+ "candidates": list(candidates),
77
+ "record_index": rec_idx,
78
+ "sentence_index": sent_idx,
79
+ "original_text": span.text,
80
+ "standardised_text": final_text,
81
+ }
82
+ else:
83
+ prev = history[key]
84
+ if prev["choice"] != choice:
85
+ inconsistencies.append(
86
+ {
87
+ "candidate_group": list(candidates),
88
+ "group_key": key,
89
+ "first_occurrence": {
90
+ "choice": prev["choice"],
91
+ "record_index": prev["record_index"],
92
+ "sentence_index": prev["sentence_index"],
93
+ "original_text": prev["original_text"],
94
+ "standardised_text": prev["standardised_text"],
95
+ },
96
+ "inconsistent_occurrence": {
97
+ "choice": choice,
98
+ "record_index": rec_idx,
99
+ "sentence_index": sent_idx,
100
+ "original_text": span.text,
101
+ "standardised_text": final_text,
102
+ },
103
+ }
104
+ )
105
+
106
+ return inconsistencies
107
+
108
+
109
+ def load_candidate_groups(
110
+ cache_path: Path | None = None,
111
+ ) -> tuple[
112
+ dict[tuple[str, ...], str | None],
113
+ dict[str, list[tuple[tuple[str, ...], str | None]]],
114
+ ]:
115
+ """Load known morphological candidate groups from a YAML/JSON cache."""
116
+ all_groups: dict[tuple[str, ...], str | None] = {}
117
+ word_to_groups: dict[str, list[tuple[tuple[str, ...], str | None]]] = defaultdict(
118
+ list
119
+ )
120
+
121
+ if cache_path and cache_path.exists():
122
+ content = cache_path.read_text(encoding="utf-8")
123
+ data = (
124
+ load_yaml(content)
125
+ if cache_path.suffix in (".yaml", ".yml")
126
+ else json.loads(content)
127
+ )
128
+ raw_choices = data.get("choices", data) if isinstance(data, dict) else {}
129
+ for k, chosen_val in raw_choices.items():
130
+ if str(k).startswith("_") or str(k) == "invalid_pairs":
131
+ continue
132
+ chosen = (
133
+ str(
134
+ chosen_val.get("choice", "")
135
+ if isinstance(chosen_val, dict)
136
+ else chosen_val
137
+ )
138
+ .strip()
139
+ .lower()
140
+ )
141
+ cands = tuple(sorted({c.strip().lower() for c in str(k).split("|")}))
142
+ all_groups[cands] = chosen
143
+ for c in cands:
144
+ word_to_groups[c].append((cands, chosen))
145
+
146
+ return all_groups, word_to_groups
147
+
148
+
149
+ def extract_changed_alternatives(
150
+ corpus_path: Path,
151
+ text_field: str = "text",
152
+ normalized_field: str = "normalized",
153
+ cache_path: Path | None = None,
154
+ lang: str = "nob",
155
+ limit: int | None = None,
156
+ ) -> tuple[list[tuple[tuple[str, ...], str, int, list[tuple[str, int]]]], int, int]:
157
+ """Scan corpus and aggregate change statistics per alternative group."""
158
+ _, word_to_groups = load_candidate_groups(cache_path)
159
+ token_re = re.compile(r"[\w]+", re.UNICODE)
160
+
161
+ diff_counts: Counter[tuple[str, str]] = Counter()
162
+ total_records = 0
163
+ total_changed_records = 0
164
+
165
+ if corpus_path.suffix == ".parquet":
166
+ import pyarrow.parquet as pq
167
+
168
+ table = pq.read_table(corpus_path, columns=[text_field, normalized_field])
169
+ orig_col = table[text_field].to_pylist()
170
+ norm_col = table[normalized_field].to_pylist()
171
+ for orig, std in zip(orig_col, norm_col):
172
+ total_records += 1
173
+ if orig != std:
174
+ total_changed_records += 1
175
+ ot = token_re.findall(str(orig).lower())
176
+ st = token_re.findall(str(std).lower())
177
+ if len(ot) == len(st):
178
+ for o, s in zip(ot, st):
179
+ if o != s:
180
+ diff_counts[(o, s)] += 1
181
+ if limit and total_records >= limit:
182
+ break
183
+ else:
184
+ with corpus_path.open("r", encoding="utf-8") as f:
185
+ for line in f:
186
+ line = line.strip()
187
+ if not line:
188
+ continue
189
+ total_records += 1
190
+ obj = json.loads(line)
191
+ orig = str(obj.get(text_field, ""))
192
+ std = str(obj.get(normalized_field, ""))
193
+ if orig != std:
194
+ total_changed_records += 1
195
+ ot = token_re.findall(orig.lower())
196
+ st = token_re.findall(std.lower())
197
+ if len(ot) == len(st):
198
+ for o, s in zip(ot, st):
199
+ if o != s:
200
+ diff_counts[(o, s)] += 1
201
+ if limit and total_records >= limit:
202
+ break
203
+
204
+ group_input_changes: dict[tuple[str, ...], Counter[tuple[str, str]]] = defaultdict(
205
+ Counter
206
+ )
207
+ group_targets: dict[tuple[str, ...], Counter[str]] = defaultdict(Counter)
208
+
209
+ for (o, s), cnt in diff_counts.items():
210
+ matched_grp: tuple[str, ...] | None = None
211
+ if o in word_to_groups:
212
+ for grp, _ in word_to_groups[o]:
213
+ if s in grp:
214
+ matched_grp = grp
215
+ break
216
+ if not matched_grp:
217
+ matched_grp = tuple(sorted([o, s]))
218
+
219
+ group_input_changes[matched_grp][(o, s)] += cnt
220
+ group_targets[matched_grp][s] += cnt
221
+
222
+ ranked_groups = []
223
+ for grp, pair_dict in group_input_changes.items():
224
+ chosen = group_targets[grp].most_common(1)[0][0]
225
+ clean_inputs: Counter[str] = Counter()
226
+ for (o, s), cnt in pair_dict.items():
227
+ if s.lower() == chosen.lower() and o.lower() != chosen.lower():
228
+ clean_inputs[o] += cnt
229
+
230
+ total_chg = sum(clean_inputs.values())
231
+ if total_chg > 0:
232
+ ranked_groups.append((grp, chosen, total_chg, clean_inputs.most_common()))
233
+
234
+ ranked_groups.sort(key=lambda item: -item[2])
235
+ return ranked_groups, total_records, total_changed_records
236
+
237
+
238
+ def extract_approximate_cache_usage(
239
+ corpus_path: Path,
240
+ cache_path: Path,
241
+ text_field: str = "text",
242
+ normalized_field: str = "normalized",
243
+ limit: int | None = None,
244
+ ) -> tuple[list[dict[str, Any]], dict[str, int]]:
245
+ """Conservatively infer cache usage from aligned source/output word tokens."""
246
+ all_groups, word_to_groups = load_candidate_groups(cache_path)
247
+ token_re = re.compile(r"[\w]+", re.UNICODE)
248
+ supported_groups = {
249
+ group: chosen
250
+ for group, chosen in all_groups.items()
251
+ if chosen and all(token_re.fullmatch(candidate) for candidate in group)
252
+ }
253
+ usage: dict[tuple[str, ...], dict[str, Any]] = {
254
+ group: {
255
+ "group": group,
256
+ "chosen": chosen,
257
+ "seen": 0,
258
+ "changed": 0,
259
+ "already_correct": 0,
260
+ "changed_inputs": Counter(),
261
+ }
262
+ for group, chosen in supported_groups.items()
263
+ }
264
+ metadata = {
265
+ "records_scanned": 0,
266
+ "unaligned_records_skipped": 0,
267
+ "ambiguous_occurrences_skipped": 0,
268
+ "non_single_token_groups_not_counted": len(all_groups) - len(supported_groups),
269
+ }
270
+
271
+ def process_record(orig: Any, std: Any) -> None:
272
+ metadata["records_scanned"] += 1
273
+ original_tokens = token_re.findall(str(orig).lower())
274
+ normalized_tokens = token_re.findall(str(std).lower())
275
+ if len(original_tokens) != len(normalized_tokens):
276
+ metadata["unaligned_records_skipped"] += 1
277
+ return
278
+ for original, normalized in zip(original_tokens, normalized_tokens):
279
+ matches = []
280
+ for group, chosen in word_to_groups.get(original, []):
281
+ if chosen and group in usage and normalized == chosen:
282
+ matches.append(group)
283
+ matches = list(dict.fromkeys(matches))
284
+ if len(matches) > 1:
285
+ metadata["ambiguous_occurrences_skipped"] += 1
286
+ continue
287
+ if not matches:
288
+ continue
289
+ stats = usage[matches[0]]
290
+ stats["seen"] += 1
291
+ if original == normalized:
292
+ stats["already_correct"] += 1
293
+ else:
294
+ stats["changed"] += 1
295
+ stats["changed_inputs"][original] += 1
296
+
297
+ if corpus_path.suffix == ".parquet":
298
+ import pyarrow.parquet as pq
299
+
300
+ table = pq.read_table(corpus_path, columns=[text_field, normalized_field])
301
+ for orig, std in zip(
302
+ table[text_field].to_pylist(), table[normalized_field].to_pylist()
303
+ ):
304
+ process_record(orig, std)
305
+ if limit and metadata["records_scanned"] >= limit:
306
+ break
307
+ else:
308
+ with corpus_path.open("r", encoding="utf-8") as stream:
309
+ for line in stream:
310
+ if not line.strip():
311
+ continue
312
+ record = json.loads(line)
313
+ process_record(
314
+ record.get(text_field, ""),
315
+ record.get(normalized_field, ""),
316
+ )
317
+ if limit and metadata["records_scanned"] >= limit:
318
+ break
319
+
320
+ rows = list(usage.values())
321
+ rows.sort(key=lambda item: (-int(item["seen"]), item["group"]))
322
+ metadata["decisions_seen"] = sum(int(item["seen"]) for item in rows)
323
+ metadata["decisions_changed"] = sum(int(item["changed"]) for item in rows)
324
+ metadata["decisions_already_correct"] = sum(
325
+ int(item["already_correct"]) for item in rows
326
+ )
327
+ return rows, metadata