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 +126 -0
- normform/audit.py +327 -0
- normform/batch.py +900 -0
- normform/cli.py +592 -0
- normform/contract.py +159 -0
- normform/evidence.py +251 -0
- normform/harness.py +965 -0
- normform/model.py +601 -0
- normform/styles.py +515 -0
- normform-0.1.0.dist-info/METADATA +339 -0
- normform-0.1.0.dist-info/RECORD +15 -0
- normform-0.1.0.dist-info/WHEEL +5 -0
- normform-0.1.0.dist-info/entry_points.txt +2 -0
- normform-0.1.0.dist-info/licenses/LICENSE +201 -0
- normform-0.1.0.dist-info/top_level.txt +1 -0
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
|