Merge nucleic/sleek-ember-seal-uady into dev
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
# Purpose-classifier energy and accelerator-residency check
|
||||||
|
|
||||||
|
This is the required per-model-version hardware spot check from
|
||||||
|
`docs/PURPOSE_CLASSIFIER.md` §8.5. It is deliberately not CI: the numbers are meaningful
|
||||||
|
only on named physical hardware with a stable power source, thermal state, and runtime
|
||||||
|
placement.
|
||||||
|
|
||||||
|
## Common protocol
|
||||||
|
|
||||||
|
1. Record the artifact SHA-256, app/build commit, machine model, OS build, battery/AC
|
||||||
|
state, ambient power mode, and runtime compute policy.
|
||||||
|
2. Warm the model with 100 batch-one classifications, then classify the same fixed
|
||||||
|
1,000-prompt sequence. Tokenization is included. Disable network activity and other
|
||||||
|
foreground workloads.
|
||||||
|
3. Run at least five trials per compute policy, alternating policy order. Report median
|
||||||
|
wall time, p95 inference latency, average package power, and joules per inference.
|
||||||
|
4. Capture the runtime placement evidence alongside the power trace. A fast CPU fallback
|
||||||
|
is still a residency failure for `purpose-deep`.
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
- Run the release Core ML artifact once with `.cpuAndNeuralEngine`, recording the
|
||||||
|
`MLComputePlan` ANE operation share and the artifact's required floor.
|
||||||
|
- Repeat with a diagnostic CPU-only configuration on the same Mac and power source.
|
||||||
|
- Sample the 1,000-inference region with `sudo powermetrics --samplers cpu_power,gpu_power`
|
||||||
|
at one-second intervals. Store the raw trace outside git and put its summarized table in
|
||||||
|
the model metrics report.
|
||||||
|
- The default ANE policy is accepted only when it uses less average power and fewer joules
|
||||||
|
per inference than CPU-only. On AC, a `.all` GPU retry must separately meet its declared
|
||||||
|
GPU operation-share floor before it can activate a deep model.
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
- Record the Windows ML execution provider and assigned device after AOT compilation.
|
||||||
|
Deep requires QNN NPU, OpenVINO NPU/GPU, NvTensorRT-RTX, or MIGraphX; bare CPU is only
|
||||||
|
valid for lite.
|
||||||
|
- On battery compare `MAX_EFFICIENCY` with an explicit CPU session. On AC compare the
|
||||||
|
selected NPU, otherwise the validated vendor GPU EP, with CPU.
|
||||||
|
- Capture the 1,000-inference window with HWiNFO sensor logging or the vendor's documented
|
||||||
|
NPU/GPU telemetry. Include package/device average power and energy per inference in the
|
||||||
|
model metrics report.
|
||||||
|
- Verify that unplugging skips the discrete-GPU rung and that an idle session unloads
|
||||||
|
without keeping the dGPU awake.
|
||||||
|
|
||||||
|
The report is incomplete if it gives latency without placement evidence and energy, or if
|
||||||
|
it compares different prompt sequences between policies.
|
||||||
@@ -54,7 +54,9 @@ python3 ml/purpose-classifier/prepare_data.py --refresh-frozen-test
|
|||||||
Use a dedicated virtual environment. The base model is pinned to a specific
|
Use a dedicated virtual environment. The base model is pinned to a specific
|
||||||
`sentence-transformers/all-MiniLM-L6-v2` commit: a 6-layer, 384-dimensional encoder. The
|
`sentence-transformers/all-MiniLM-L6-v2` commit: a 6-layer, 384-dimensional encoder. The
|
||||||
training collator always pads/truncates to 128 tokens so the later ONNX/Core ML export
|
training collator always pads/truncates to 128 tokens so the later ONNX/Core ML export
|
||||||
can expose a fixed `1 x 128` runtime shape.
|
can expose a fixed `1 x 128` runtime shape. Long prompts preserve both ends as
|
||||||
|
`[CLS]` + 63 head tokens + `[SEP]` + 62 tail tokens + `[SEP]`; this keeps the ask when it
|
||||||
|
follows a pasted log or stack trace while retaining enough leading context to interpret it.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv ml/purpose-classifier/.venv
|
python3 -m venv ml/purpose-classifier/.venv
|
||||||
@@ -68,8 +70,9 @@ accelerator training, install the platform's `torch==2.13.0` build using PyTorch
|
|||||||
platform selector, then install `requirements-base.txt`.
|
platform selector, then install `requirements-base.txt`.
|
||||||
|
|
||||||
Training writes a local checkpoint, `calibration.json`, and `metrics.json` under
|
Training writes a local checkpoint, `calibration.json`, and `metrics.json` under
|
||||||
`outputs/purpose-lite-v1/`. It fits one validation-only temperature and derives nested
|
`outputs/purpose-lite-v1/`. It selects checkpoints and fits temperature on label-scorable
|
||||||
HIGH/MEDIUM/LOW cutoffs from calibrated top-one probability plus top-two margin.
|
validation records. When deriving nested HIGH/MEDIUM/LOW cutoffs, every `vague-eval`
|
||||||
|
record counts as an abstention miss even if its synthetic label happens to match.
|
||||||
|
|
||||||
For a wiring smoke test, use a small deterministic prefix:
|
For a wiring smoke test, use a small deterministic prefix:
|
||||||
|
|
||||||
@@ -85,10 +88,39 @@ ml/purpose-classifier/.venv/bin/python ml/purpose-classifier/train.py \
|
|||||||
ml/purpose-classifier/.venv/bin/python ml/purpose-classifier/eval.py
|
ml/purpose-classifier/.venv/bin/python ml/purpose-classifier/eval.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The command returns failure unless frozen accuracy is at least 95%, every purpose recall
|
The command returns failure unless label-scorable frozen accuracy is at least 95%, every
|
||||||
is at least 85%, and measured batch-one p95 is at most 20 ms. Use `--no-gate` only for
|
purpose recall is at least 85%, at least 90% of the deliberately context-free
|
||||||
diagnostic runs. Accelerator residency, ONNX export/quantization, tokenizer golden tests,
|
`vague-eval` slice resolves LOW, every misroute stays within one routing cost tier, and
|
||||||
tier-drift evaluation, and Core ML parity remain follow-on work. Before calling dataset
|
measured batch-one p95 is at most 20 ms. Use `--no-gate` only for diagnostic runs.
|
||||||
work item 2 complete, also run a semantic embedding duplicate audit and record the planned
|
|
||||||
10% human label spot-check; the current dependency-free word-trigram pass is deliberately
|
## Export and score ONNX
|
||||||
conservative.
|
|
||||||
|
```bash
|
||||||
|
ml/purpose-classifier/.venv/bin/python ml/purpose-classifier/export.py
|
||||||
|
ml/purpose-classifier/.venv/bin/python ml/purpose-classifier/eval.py \
|
||||||
|
--onnx-model ml/purpose-classifier/outputs/purpose-lite-v1/export/purpose-lite-v1-int8-qdq.onnx \
|
||||||
|
--report ml/purpose-classifier/outputs/purpose-lite-v1/export/int8-frozen-eval.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`export.py` emits fixed-shape opset-17 fp16 and int8-QDQ graphs, a tokenizer/
|
||||||
|
normalization contract, golden tokenizations, shared calibration config, graph checks,
|
||||||
|
artifact hashes, and a size report. The int8 graph is the ≤25 MiB shipping candidate;
|
||||||
|
the fp16 graph remains the accelerator-oriented conversion input.
|
||||||
|
|
||||||
|
## Audit curation
|
||||||
|
|
||||||
|
Run the semantic embedding duplicate audit. It also emits the deterministic,
|
||||||
|
purpose/slice/language-stratified 10% human label-and-difficulty review CSV:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ml/purpose-classifier/.venv/bin/python ml/purpose-classifier/audit_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The semantic pass uses the same commit-pinned MiniLM encoder and fixed 128-token input as
|
||||||
|
`purpose-lite`. Similarity only proposes review candidates; it never edits source data or
|
||||||
|
the frozen split automatically. The 18 word-trigram exclusions and the human-review
|
||||||
|
completion rule are recorded in `data/curation-review-v1.json`; the semantic report is
|
||||||
|
versioned as `data/semantic-audit-v1.json`.
|
||||||
|
|
||||||
|
The one-time, hardware-bound energy and accelerator-residency procedure is in
|
||||||
|
`ENERGY_AND_RESIDENCY.md`.
|
||||||
|
|||||||
+531
@@ -0,0 +1,531 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Audit semantic duplicates and emit the frozen human-review sample."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from prepare_data import (
|
||||||
|
DEFAULT_CURATION_REVIEW,
|
||||||
|
DEFAULT_FIXTURES,
|
||||||
|
DEFAULT_FROZEN_TEST,
|
||||||
|
default_source_paths,
|
||||||
|
load_reviewed_semantic_exclusions,
|
||||||
|
)
|
||||||
|
from purpose_data import (
|
||||||
|
LABELS,
|
||||||
|
DataError,
|
||||||
|
SourceRecord,
|
||||||
|
curate_records,
|
||||||
|
exclude_reviewed_duplicates,
|
||||||
|
load_classifiable_fixtures,
|
||||||
|
load_sources,
|
||||||
|
normalize_prompt,
|
||||||
|
prompt_hash,
|
||||||
|
write_json,
|
||||||
|
)
|
||||||
|
from train import (
|
||||||
|
DEFAULT_MODEL,
|
||||||
|
DEFAULT_MODEL_REVISION,
|
||||||
|
HEAD_TOKENS,
|
||||||
|
MAX_LENGTH,
|
||||||
|
TAIL_TOKENS,
|
||||||
|
encode_fixed_shape,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
REPOSITORY_ROOT = SCRIPT_DIR.parent.parent
|
||||||
|
DEFAULT_REPORT = SCRIPT_DIR / "data" / "semantic-audit-v1.json"
|
||||||
|
DEFAULT_REVIEW_CSV = SCRIPT_DIR / ".artifacts" / "human-review-v1.csv"
|
||||||
|
DEFAULT_SAMPLE_SEED = 0xA11D17
|
||||||
|
DEFAULT_SAMPLE_FRACTION = 0.10
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuditRecord:
|
||||||
|
prompt: str
|
||||||
|
purpose: str
|
||||||
|
prompt_hash: str
|
||||||
|
origin: str
|
||||||
|
source: str
|
||||||
|
line: int | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SemanticCandidate:
|
||||||
|
left: int
|
||||||
|
right: int
|
||||||
|
similarity: float
|
||||||
|
|
||||||
|
|
||||||
|
def _relative(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
return str(path.resolve().relative_to(REPOSITORY_ROOT))
|
||||||
|
except ValueError:
|
||||||
|
return str(path.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_rank(seed: int, record: SourceRecord) -> str:
|
||||||
|
material = f"{seed}\0{prompt_hash(record.value['prompt'])}".encode("utf-8")
|
||||||
|
return hashlib.sha256(material).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _review_stratum(record: SourceRecord) -> tuple[str, str, str]:
|
||||||
|
language = record.value["lang"].split("-", 1)[0].casefold()
|
||||||
|
return record.value["purpose"], record.value["slice"], language
|
||||||
|
|
||||||
|
|
||||||
|
def stratified_review_sample(
|
||||||
|
records: Sequence[SourceRecord],
|
||||||
|
*,
|
||||||
|
fraction: float = DEFAULT_SAMPLE_FRACTION,
|
||||||
|
seed: int = DEFAULT_SAMPLE_SEED,
|
||||||
|
) -> list[SourceRecord]:
|
||||||
|
"""Choose exactly round(N*fraction), apportioned across purpose/slice/language."""
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
raise DataError("cannot sample an empty review population")
|
||||||
|
if not 0.0 < fraction <= 1.0:
|
||||||
|
raise DataError("review fraction must be in (0, 1]")
|
||||||
|
|
||||||
|
target = round(len(records) * fraction)
|
||||||
|
groups: dict[tuple[str, str, str], list[SourceRecord]] = defaultdict(list)
|
||||||
|
for record in records:
|
||||||
|
groups[_review_stratum(record)].append(record)
|
||||||
|
|
||||||
|
# Hamilton apportionment preserves small language/slice strata while still producing
|
||||||
|
# the exact requested global sample size.
|
||||||
|
allocations: dict[tuple[str, str, str], int] = {}
|
||||||
|
remainders: list[tuple[float, str, tuple[str, str, str]]] = []
|
||||||
|
allocated = 0
|
||||||
|
for key in sorted(groups):
|
||||||
|
quota = len(groups[key]) * target / len(records)
|
||||||
|
base = math.floor(quota)
|
||||||
|
allocations[key] = base
|
||||||
|
allocated += base
|
||||||
|
tie_break = hashlib.sha256(f"{seed}\0{key}".encode("utf-8")).hexdigest()
|
||||||
|
remainders.append((quota - base, tie_break, key))
|
||||||
|
for _, _, key in sorted(remainders, reverse=True)[: target - allocated]:
|
||||||
|
allocations[key] += 1
|
||||||
|
|
||||||
|
selected: list[SourceRecord] = []
|
||||||
|
for key in sorted(groups):
|
||||||
|
ordered = sorted(groups[key], key=lambda record: _stable_rank(seed, record))
|
||||||
|
selected.extend(ordered[: allocations[key]])
|
||||||
|
return sorted(selected, key=lambda record: _stable_rank(seed + 1, record))
|
||||||
|
|
||||||
|
|
||||||
|
def write_review_csv(path: Path, records: Sequence[SourceRecord]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
handle,
|
||||||
|
fieldnames=[
|
||||||
|
"promptHash",
|
||||||
|
"source",
|
||||||
|
"line",
|
||||||
|
"prompt",
|
||||||
|
"generatedPurpose",
|
||||||
|
"generatedSecondary",
|
||||||
|
"generatedMixed",
|
||||||
|
"generatedDifficulty",
|
||||||
|
"generatedSlice",
|
||||||
|
"generatedLanguage",
|
||||||
|
"reviewedPurpose",
|
||||||
|
"reviewedSecondary",
|
||||||
|
"reviewedDifficulty",
|
||||||
|
"reviewStatus",
|
||||||
|
"reviewNotes",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
for record in records:
|
||||||
|
value = record.value
|
||||||
|
writer.writerow(
|
||||||
|
{
|
||||||
|
"promptHash": prompt_hash(value["prompt"]),
|
||||||
|
"source": _relative(record.source),
|
||||||
|
"line": record.line,
|
||||||
|
"prompt": normalize_prompt(value["prompt"]),
|
||||||
|
"generatedPurpose": value["purpose"],
|
||||||
|
"generatedSecondary": value["secondary"] or "",
|
||||||
|
"generatedMixed": str(value["mixed"]).lower(),
|
||||||
|
"generatedDifficulty": value["difficulty"],
|
||||||
|
"generatedSlice": value["slice"],
|
||||||
|
"generatedLanguage": value["lang"],
|
||||||
|
"reviewedPurpose": "",
|
||||||
|
"reviewedSecondary": "",
|
||||||
|
"reviewedDifficulty": "",
|
||||||
|
"reviewStatus": "",
|
||||||
|
"reviewNotes": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def semantic_candidates(
|
||||||
|
embeddings: np.ndarray,
|
||||||
|
purposes: Sequence[str],
|
||||||
|
*,
|
||||||
|
same_label_threshold: float,
|
||||||
|
cross_label_threshold: float,
|
||||||
|
neighbors: int,
|
||||||
|
block_size: int,
|
||||||
|
) -> list[SemanticCandidate]:
|
||||||
|
"""Return de-duplicated high-cosine nearest-neighbor pairs."""
|
||||||
|
|
||||||
|
if embeddings.ndim != 2 or embeddings.shape[0] != len(purposes):
|
||||||
|
raise ValueError("embeddings and purposes must have matching record counts")
|
||||||
|
if len(purposes) < 2:
|
||||||
|
return []
|
||||||
|
if not (
|
||||||
|
0.0 < same_label_threshold <= 1.0
|
||||||
|
and 0.0 < cross_label_threshold <= 1.0
|
||||||
|
):
|
||||||
|
raise ValueError("semantic thresholds must be in (0, 1]")
|
||||||
|
if neighbors <= 0 or block_size <= 0:
|
||||||
|
raise ValueError("neighbors and block size must be positive")
|
||||||
|
|
||||||
|
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
|
||||||
|
if np.any(norms == 0):
|
||||||
|
raise ValueError("semantic embeddings must be non-zero")
|
||||||
|
normalized = embeddings.astype(np.float32, copy=False) / norms
|
||||||
|
top_count = min(neighbors, len(purposes) - 1)
|
||||||
|
found: dict[tuple[int, int], float] = {}
|
||||||
|
for start in range(0, len(purposes), block_size):
|
||||||
|
stop = min(start + block_size, len(purposes))
|
||||||
|
similarities = normalized[start:stop] @ normalized.T
|
||||||
|
local_rows = np.arange(stop - start)
|
||||||
|
similarities[local_rows, np.arange(start, stop)] = -1.0
|
||||||
|
candidate_columns = np.argpartition(
|
||||||
|
similarities, -top_count, axis=1
|
||||||
|
)[:, -top_count:]
|
||||||
|
for local_index, columns in enumerate(candidate_columns):
|
||||||
|
left = start + local_index
|
||||||
|
for right in columns:
|
||||||
|
similarity = float(similarities[local_index, right])
|
||||||
|
threshold = (
|
||||||
|
same_label_threshold
|
||||||
|
if purposes[left] == purposes[right]
|
||||||
|
else cross_label_threshold
|
||||||
|
)
|
||||||
|
if similarity < threshold:
|
||||||
|
continue
|
||||||
|
pair = (min(left, int(right)), max(left, int(right)))
|
||||||
|
found[pair] = max(found.get(pair, -1.0), similarity)
|
||||||
|
return [
|
||||||
|
SemanticCandidate(left, right, similarity)
|
||||||
|
for (left, right), similarity in sorted(
|
||||||
|
found.items(), key=lambda item: (-item[1], item[0])
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _select_device(torch: Any, requested: str) -> Any:
|
||||||
|
if requested != "auto":
|
||||||
|
return torch.device(requested)
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
return torch.device("cuda")
|
||||||
|
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
|
return torch.device("mps")
|
||||||
|
return torch.device("cpu")
|
||||||
|
|
||||||
|
|
||||||
|
def embed_prompts(
|
||||||
|
records: Sequence[AuditRecord],
|
||||||
|
*,
|
||||||
|
model_name: str,
|
||||||
|
model_revision: str,
|
||||||
|
device_name: str,
|
||||||
|
batch_size: int,
|
||||||
|
) -> tuple[np.ndarray, str]:
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
from transformers import AutoModel, AutoTokenizer
|
||||||
|
except ImportError as exc:
|
||||||
|
raise DataError(
|
||||||
|
"semantic-audit dependencies are missing; install requirements.txt"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
device = _select_device(torch, device_name)
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(
|
||||||
|
model_name, revision=model_revision, use_fast=True
|
||||||
|
)
|
||||||
|
model = AutoModel.from_pretrained(model_name, revision=model_revision).to(device)
|
||||||
|
model.eval()
|
||||||
|
chunks: list[np.ndarray] = []
|
||||||
|
with torch.inference_mode():
|
||||||
|
for start in range(0, len(records), batch_size):
|
||||||
|
batch = records[start : start + batch_size]
|
||||||
|
encoded = encode_fixed_shape(
|
||||||
|
tokenizer,
|
||||||
|
[record.prompt for record in batch],
|
||||||
|
torch,
|
||||||
|
)
|
||||||
|
encoded = {key: value.to(device) for key, value in encoded.items()}
|
||||||
|
hidden = model(**encoded).last_hidden_state
|
||||||
|
mask = encoded["attention_mask"].unsqueeze(-1).expand(hidden.size()).float()
|
||||||
|
pooled = (hidden * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1e-9)
|
||||||
|
pooled = torch.nn.functional.normalize(pooled, p=2, dim=1)
|
||||||
|
chunks.append(pooled.cpu().numpy())
|
||||||
|
return np.concatenate(chunks), str(device)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_records(
|
||||||
|
curated: Sequence[SourceRecord],
|
||||||
|
fixtures: Sequence[dict[str, str]],
|
||||||
|
fixtures_path: Path,
|
||||||
|
) -> list[AuditRecord]:
|
||||||
|
records = [
|
||||||
|
AuditRecord(
|
||||||
|
prompt=record.value["prompt"],
|
||||||
|
purpose=record.value["purpose"],
|
||||||
|
prompt_hash=prompt_hash(record.value["prompt"]),
|
||||||
|
origin="synthetic",
|
||||||
|
source=_relative(record.source),
|
||||||
|
line=record.line,
|
||||||
|
)
|
||||||
|
for record in curated
|
||||||
|
]
|
||||||
|
records.extend(
|
||||||
|
AuditRecord(
|
||||||
|
prompt=fixture["prompt"],
|
||||||
|
purpose=fixture["purpose"],
|
||||||
|
prompt_hash=prompt_hash(fixture["prompt"]),
|
||||||
|
origin="shipped-fixture",
|
||||||
|
source=_relative(fixtures_path),
|
||||||
|
line=None,
|
||||||
|
)
|
||||||
|
for fixture in fixtures
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _split_membership() -> dict[str, str]:
|
||||||
|
membership: dict[str, str] = {}
|
||||||
|
artifact_dir = SCRIPT_DIR / ".artifacts" / "dataset-v1"
|
||||||
|
for split in ("train", "validation"):
|
||||||
|
path = artifact_dir / f"{split}.jsonl"
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
value = json.loads(line)
|
||||||
|
membership[prompt_hash(value["prompt"])] = split
|
||||||
|
for line in DEFAULT_FROZEN_TEST.read_text(encoding="utf-8").splitlines():
|
||||||
|
value = json.loads(line)
|
||||||
|
membership[prompt_hash(value["prompt"])] = "test"
|
||||||
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
def audit(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
|
sources = (
|
||||||
|
[path.resolve() for path in args.source]
|
||||||
|
if args.source
|
||||||
|
else default_source_paths()
|
||||||
|
)
|
||||||
|
fixtures = load_classifiable_fixtures(args.fixtures.resolve())
|
||||||
|
source_records = load_sources(sources)
|
||||||
|
lexical_curation = curate_records(
|
||||||
|
source_records,
|
||||||
|
fixtures,
|
||||||
|
near_duplicate_threshold=args.word_duplicate_threshold,
|
||||||
|
)
|
||||||
|
curated = exclude_reviewed_duplicates(
|
||||||
|
lexical_curation.records,
|
||||||
|
load_reviewed_semantic_exclusions(args.curation_review.resolve()),
|
||||||
|
)
|
||||||
|
sample = stratified_review_sample(
|
||||||
|
curated.records,
|
||||||
|
fraction=args.review_fraction,
|
||||||
|
seed=args.review_seed,
|
||||||
|
)
|
||||||
|
write_review_csv(args.review_csv.resolve(), sample)
|
||||||
|
|
||||||
|
audit_records = _audit_records(
|
||||||
|
curated.records, fixtures, args.fixtures.resolve()
|
||||||
|
)
|
||||||
|
embeddings, device = embed_prompts(
|
||||||
|
audit_records,
|
||||||
|
model_name=args.model,
|
||||||
|
model_revision=args.model_revision,
|
||||||
|
device_name=args.device,
|
||||||
|
batch_size=args.batch_size,
|
||||||
|
)
|
||||||
|
candidates = semantic_candidates(
|
||||||
|
embeddings,
|
||||||
|
[record.purpose for record in audit_records],
|
||||||
|
same_label_threshold=args.same_label_threshold,
|
||||||
|
cross_label_threshold=args.cross_label_threshold,
|
||||||
|
neighbors=args.neighbors,
|
||||||
|
block_size=args.block_size,
|
||||||
|
)
|
||||||
|
membership = _split_membership()
|
||||||
|
|
||||||
|
candidate_values = []
|
||||||
|
leakage_candidates = 0
|
||||||
|
for candidate in candidates:
|
||||||
|
left = audit_records[candidate.left]
|
||||||
|
right = audit_records[candidate.right]
|
||||||
|
left_split = (
|
||||||
|
"shipped-fixture"
|
||||||
|
if left.origin == "shipped-fixture"
|
||||||
|
else membership.get(left.prompt_hash, "unknown")
|
||||||
|
)
|
||||||
|
right_split = (
|
||||||
|
"shipped-fixture"
|
||||||
|
if right.origin == "shipped-fixture"
|
||||||
|
else membership.get(right.prompt_hash, "unknown")
|
||||||
|
)
|
||||||
|
crosses_frozen_boundary = (
|
||||||
|
left_split == "train"
|
||||||
|
and right_split in {"validation", "test", "shipped-fixture"}
|
||||||
|
) or (
|
||||||
|
right_split == "train"
|
||||||
|
and left_split in {"validation", "test", "shipped-fixture"}
|
||||||
|
)
|
||||||
|
leakage_candidates += int(crosses_frozen_boundary)
|
||||||
|
candidate_values.append(
|
||||||
|
{
|
||||||
|
"similarity": round(candidate.similarity, 6),
|
||||||
|
"sameLabel": left.purpose == right.purpose,
|
||||||
|
"crossesFrozenBoundary": crosses_frozen_boundary,
|
||||||
|
"left": {
|
||||||
|
"promptHash": left.prompt_hash,
|
||||||
|
"purpose": left.purpose,
|
||||||
|
"origin": left.origin,
|
||||||
|
"split": left_split,
|
||||||
|
"source": left.source,
|
||||||
|
"line": left.line,
|
||||||
|
},
|
||||||
|
"right": {
|
||||||
|
"promptHash": right.prompt_hash,
|
||||||
|
"purpose": right.purpose,
|
||||||
|
"origin": right.origin,
|
||||||
|
"split": right_split,
|
||||||
|
"source": right.source,
|
||||||
|
"line": right.line,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"auditVersion": "purpose-semantic-audit-v1",
|
||||||
|
"embedding": {
|
||||||
|
"model": args.model,
|
||||||
|
"revision": args.model_revision,
|
||||||
|
"fixedInputShape": [1, MAX_LENGTH],
|
||||||
|
"truncation": {
|
||||||
|
"strategy": "head-tail-pair",
|
||||||
|
"headTokens": HEAD_TOKENS,
|
||||||
|
"tailTokens": TAIL_TOKENS,
|
||||||
|
},
|
||||||
|
"pooling": "attention-mask mean pooling followed by L2 normalization",
|
||||||
|
"device": device,
|
||||||
|
},
|
||||||
|
"population": {
|
||||||
|
"canonicalSourceRecords": len(source_records),
|
||||||
|
"curatedSyntheticRecords": len(curated.records),
|
||||||
|
"classifiableShippedFixtures": len(fixtures),
|
||||||
|
"auditedRecords": len(audit_records),
|
||||||
|
},
|
||||||
|
"candidatePolicy": {
|
||||||
|
"sameLabelCosineThreshold": args.same_label_threshold,
|
||||||
|
"crossLabelCosineThreshold": args.cross_label_threshold,
|
||||||
|
"nearestNeighborsPerRecord": args.neighbors,
|
||||||
|
"automaticExclusion": False,
|
||||||
|
"note": (
|
||||||
|
"Embedding similarity proposes review candidates only. It cannot safely "
|
||||||
|
"distinguish deliberate boundary pairs from duplicated labels."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"candidates": len(candidate_values),
|
||||||
|
"sameLabelCandidates": sum(item["sameLabel"] for item in candidate_values),
|
||||||
|
"crossLabelCandidates": sum(
|
||||||
|
not item["sameLabel"] for item in candidate_values
|
||||||
|
),
|
||||||
|
"frozenBoundaryCandidates": leakage_candidates,
|
||||||
|
},
|
||||||
|
"humanReviewSample": {
|
||||||
|
"status": "planned",
|
||||||
|
"fraction": args.review_fraction,
|
||||||
|
"seed": args.review_seed,
|
||||||
|
"populationRecords": len(curated.records),
|
||||||
|
"sampleRecords": len(sample),
|
||||||
|
"strata": ["purpose", "slice", "primary language"],
|
||||||
|
"generatedCSV": _relative(args.review_csv),
|
||||||
|
"samplePurposeCounts": dict(
|
||||||
|
sorted(Counter(record.value["purpose"] for record in sample).items())
|
||||||
|
),
|
||||||
|
"sampleSliceCounts": dict(
|
||||||
|
sorted(Counter(record.value["slice"] for record in sample).items())
|
||||||
|
),
|
||||||
|
"requiredFields": [
|
||||||
|
"reviewedPurpose",
|
||||||
|
"reviewedSecondary",
|
||||||
|
"reviewedDifficulty",
|
||||||
|
"reviewStatus",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"candidates": candidate_values,
|
||||||
|
}
|
||||||
|
write_json(args.report.resolve(), report)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--source", action="append", type=Path)
|
||||||
|
parser.add_argument("--fixtures", type=Path, default=DEFAULT_FIXTURES)
|
||||||
|
parser.add_argument("--report", type=Path, default=DEFAULT_REPORT)
|
||||||
|
parser.add_argument("--review-csv", type=Path, default=DEFAULT_REVIEW_CSV)
|
||||||
|
parser.add_argument(
|
||||||
|
"--curation-review", type=Path, default=DEFAULT_CURATION_REVIEW
|
||||||
|
)
|
||||||
|
parser.add_argument("--review-fraction", type=float, default=DEFAULT_SAMPLE_FRACTION)
|
||||||
|
parser.add_argument("--review-seed", type=int, default=DEFAULT_SAMPLE_SEED)
|
||||||
|
parser.add_argument("--model", default=DEFAULT_MODEL)
|
||||||
|
parser.add_argument("--model-revision", default=DEFAULT_MODEL_REVISION)
|
||||||
|
parser.add_argument("--device", default="auto")
|
||||||
|
parser.add_argument("--batch-size", type=int, default=64)
|
||||||
|
parser.add_argument("--block-size", type=int, default=256)
|
||||||
|
parser.add_argument("--neighbors", type=int, default=8)
|
||||||
|
parser.add_argument("--same-label-threshold", type=float, default=0.97)
|
||||||
|
parser.add_argument("--cross-label-threshold", type=float, default=0.985)
|
||||||
|
parser.add_argument("--word-duplicate-threshold", type=float, default=0.92)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if args.batch_size <= 0 or args.block_size <= 0 or args.neighbors <= 0:
|
||||||
|
parser.error("batch size, block size, and neighbors must be positive")
|
||||||
|
try:
|
||||||
|
report = audit(args)
|
||||||
|
except (DataError, OSError, UnicodeError, ValueError) as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(
|
||||||
|
f"Semantic audit: {report['population']['auditedRecords']} records, "
|
||||||
|
f"{report['summary']['candidates']} candidates "
|
||||||
|
f"({report['summary']['frozenBoundaryCandidates']} cross a frozen boundary); "
|
||||||
|
f"{report['humanReviewSample']['sampleRecords']} prompts sampled for review."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"datasetVersion": "purpose-dataset-v1",
|
||||||
|
"wordTrigramExclusionReview": {
|
||||||
|
"status": "complete",
|
||||||
|
"reviewedRecords": 18,
|
||||||
|
"confirmedTemplateDuplicates": 18,
|
||||||
|
"rejectedExclusions": 0,
|
||||||
|
"decision": "All 18 pairs preserve the same task, requested outcome, and purpose label; differences are generated identifiers, ticket numbers, cosmetic context prefixes, or difficulty/slice metadata. Keep the earliest member and exclude the later template copy.",
|
||||||
|
"reviewedDroppedPromptHashes": [
|
||||||
|
"f29de1d646055017f80346055bb3ac1468c49af7ab0c6249f45f19cf386f9fa7",
|
||||||
|
"dabbcd0a19d2918199b5d0d35fac49fd5413cb17a5164252b789114fc68ec43b",
|
||||||
|
"ac3a166e07a1db67475c773ef3d996d12fe627d11b7bb91a7c524c9c67c841b0",
|
||||||
|
"d5f9d735490874ded856105e10df99ed3c2c703c09ec7909fd07aa1e9e7a0c91",
|
||||||
|
"6376e5f6f0d7c3d6faae9e50975034113f43a7f959bcbbea67531d3b4f0cfd16",
|
||||||
|
"10933534254a1f73163e0ba7883225e2d0c896b565590753698c1052743af664",
|
||||||
|
"cf4deb437c380216601b176e238964bfce79bf2645d54e1eb659f8479b980186",
|
||||||
|
"fe6b2e05da51a12ea07d6fa015933196931fb1023b62c0eb267b80318f083ba0",
|
||||||
|
"c0fddb025279caf6438453b53c99b266ccfb61c6c19609b43c6f731d88812813",
|
||||||
|
"15c28d9f67ba103378db711c82193aba7330acbe602d9df54995a5db20e1bbb1",
|
||||||
|
"b3583a130811ba7d2f425042c41e1f69244c622764a0c952ed42bb5114e18735",
|
||||||
|
"bba760782054c4ed405611cf8b55bc99d710424bb3f9bab0682dc2c1cf4e2a52",
|
||||||
|
"bd4d2d8ea68a4e56eaed5c6465815abefa6c8ae78fb533eb81547dedac9c71ed",
|
||||||
|
"8c1ab1a866cadd294c3d2a6c1d6eefbede62549220c81e1dd28a886717aa285d",
|
||||||
|
"c074ce20e7cd81c28d390aed919bab30cabe9211817103e52c9b229e6a3c94ff",
|
||||||
|
"fdc04376376dbffcaf9928e9edcef29376203640b49c200c2c117f78891125a1",
|
||||||
|
"432e67ead52d6cd57e4a5034cd8bcde14bd2da13f88cb5532c42fd8fbd8daf05",
|
||||||
|
"24f3759ff4a3fb9fc77bed4c8525f30f9af2479a6cffbb0c4842043f99a7d8f1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"semanticDuplicateReview": {
|
||||||
|
"status": "complete",
|
||||||
|
"auditVersion": "purpose-semantic-audit-v1",
|
||||||
|
"reviewedCandidates": 4,
|
||||||
|
"excludedCandidates": 3,
|
||||||
|
"retainedCandidates": 1,
|
||||||
|
"decision": "Exclude three same-purpose generated template copies. One pair ('make the screen nicer' / 'Make this screen nicer') remains because it is a deliberate vague-language variant, lives entirely in validation, and does not contaminate the frozen evaluation boundary.",
|
||||||
|
"excluded": [
|
||||||
|
{
|
||||||
|
"droppedPromptHash": "7348d764d602cfff72dd2b1d369ad0375a96ea52b38174aa0bd6168a548bcc2f",
|
||||||
|
"matchedPromptHash": "3ad9cfbb664504f0e7ae84b905520937e7812d4e879fa706415782341f2cd400",
|
||||||
|
"similarity": 0.984897
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"droppedPromptHash": "a0c6e7984e92adc11ea6855c39688d733bf7665f5f965939325b15f3ba39e937",
|
||||||
|
"matchedPromptHash": "17f79a890a323c08770193a94b100dadaa00116405bdb7d14c1b89abc3c3066a",
|
||||||
|
"similarity": 0.980998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"droppedPromptHash": "bfd7791069fd04c13797f39ba99224ad68ca2deebbc912f08b10815954a7d223",
|
||||||
|
"matchedPromptHash": "dc48031efb18f25a56a3beddcdd814f26473b23cd496482ce1884ae6f697417a",
|
||||||
|
"similarity": 0.973329
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retained": [
|
||||||
|
{
|
||||||
|
"leftPromptHash": "70944e70e0062e947476f73b704ec01ecee19420d06fd174c58a41c04e8112f6",
|
||||||
|
"rightPromptHash": "cb3e63992cbf5830a222933dc7b44be39933baedcb46e335be02e3c379685517",
|
||||||
|
"similarity": 0.970376
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"humanLabelAndDifficultyReview": {
|
||||||
|
"status": "planned",
|
||||||
|
"populationRecords": 12193,
|
||||||
|
"sampleFraction": 0.1,
|
||||||
|
"sampleRecords": 1219,
|
||||||
|
"seed": 10558743,
|
||||||
|
"strata": [
|
||||||
|
"purpose",
|
||||||
|
"slice",
|
||||||
|
"primary language"
|
||||||
|
],
|
||||||
|
"reviewFields": [
|
||||||
|
"purpose",
|
||||||
|
"secondary",
|
||||||
|
"difficulty",
|
||||||
|
"review status",
|
||||||
|
"notes"
|
||||||
|
],
|
||||||
|
"generatedArtifact": "ml/purpose-classifier/.artifacts/human-review-v1.csv",
|
||||||
|
"completionRule": "Every sampled row must be marked accept, relabel, or reject by a human reviewer; relabel/reject decisions are applied to canonical source data before dataset-v1 can be called fully curated."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
{
|
{
|
||||||
"curation": {
|
"curation": {
|
||||||
"excludedDuplicates": {
|
"excludedDuplicates": {
|
||||||
"near": 18
|
"near": 18,
|
||||||
|
"semantic-reviewed": 3
|
||||||
},
|
},
|
||||||
"inputRecords": 12214,
|
"inputRecords": 12214,
|
||||||
"nearDuplicateMethod": "word-trigram Jaccard after SimHash LSH candidate search",
|
"nearDuplicateMethod": "word-trigram Jaccard after SimHash LSH candidate search",
|
||||||
"nearDuplicateThreshold": 0.92,
|
"nearDuplicateThreshold": 0.92,
|
||||||
"retainedRecords": 12196,
|
"retainedRecords": 12193,
|
||||||
|
"reviewPath": "ml/purpose-classifier/data/curation-review-v1.json",
|
||||||
|
"reviewSha256": "2db763df027b545b4fd654108dcadb0717f9c9c0a63555bf8cea26b791cda370",
|
||||||
"vagueEvalPolicy": "validation/test only"
|
"vagueEvalPolicy": "validation/test only"
|
||||||
},
|
},
|
||||||
"datasetVersion": "purpose-dataset-v1",
|
"datasetVersion": "purpose-dataset-v1",
|
||||||
@@ -23,7 +26,7 @@
|
|||||||
"shippedFixturesPath": "Tests/NucleicCoreTests/Fixtures/purpose-prompts.json",
|
"shippedFixturesPath": "Tests/NucleicCoreTests/Fixtures/purpose-prompts.json",
|
||||||
"shippedFixturesSha256": "6c8f0eedce35f9da7a407934009e87737b5e85c7371f6a118982d9fae2ea7aae",
|
"shippedFixturesSha256": "6c8f0eedce35f9da7a407934009e87737b5e85c7371f6a118982d9fae2ea7aae",
|
||||||
"syntheticPath": "ml/purpose-classifier/data/frozen-test-v1.jsonl",
|
"syntheticPath": "ml/purpose-classifier/data/frozen-test-v1.jsonl",
|
||||||
"syntheticSha256": "e5a6b501a43ce256092932ab05b0af01ff9137aee42d85b7d58bb4ce65f2943f"
|
"syntheticSha256": "5d34c8ac3d39c5b4e2e739c4c9058093beb6f790a42a1397cedefae6271654ab"
|
||||||
},
|
},
|
||||||
"ratios": {
|
"ratios": {
|
||||||
"test": 0.1,
|
"test": 0.1,
|
||||||
@@ -50,7 +53,7 @@
|
|||||||
"distribution": {
|
"distribution": {
|
||||||
"language": {
|
"language": {
|
||||||
"de": 7,
|
"de": 7,
|
||||||
"en": 1111,
|
"en": 1110,
|
||||||
"es": 9,
|
"es": 9,
|
||||||
"fr": 5,
|
"fr": 5,
|
||||||
"ja": 3,
|
"ja": 3,
|
||||||
@@ -63,7 +66,7 @@
|
|||||||
"frontendImpl": 157,
|
"frontendImpl": 157,
|
||||||
"planning": 132,
|
"planning": 132,
|
||||||
"quickFix": 140,
|
"quickFix": 140,
|
||||||
"refactor": 137,
|
"refactor": 136,
|
||||||
"review": 127,
|
"review": 127,
|
||||||
"writing": 129
|
"writing": 129
|
||||||
},
|
},
|
||||||
@@ -72,19 +75,19 @@
|
|||||||
"core": 507,
|
"core": 507,
|
||||||
"mixed": 80,
|
"mixed": 80,
|
||||||
"pasted-context": 84,
|
"pasted-context": 84,
|
||||||
"vague-eval": 292
|
"vague-eval": 291
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hardSyntheticRecords": 635,
|
"hardSyntheticRecords": 634,
|
||||||
"logicalRecords": 1229,
|
"logicalRecords": 1228,
|
||||||
"sha256": "e5a6b501a43ce256092932ab05b0af01ff9137aee42d85b7d58bb4ce65f2943f",
|
"sha256": "5d34c8ac3d39c5b4e2e739c4c9058093beb6f790a42a1397cedefae6271654ab",
|
||||||
"syntheticRecords": 1142
|
"syntheticRecords": 1141
|
||||||
},
|
},
|
||||||
"train": {
|
"train": {
|
||||||
"distribution": {
|
"distribution": {
|
||||||
"language": {
|
"language": {
|
||||||
"de": 100,
|
"de": 100,
|
||||||
"en": 9326,
|
"en": 9324,
|
||||||
"es": 100,
|
"es": 100,
|
||||||
"fr": 81,
|
"fr": 81,
|
||||||
"ja": 77,
|
"ja": 77,
|
||||||
@@ -92,24 +95,24 @@
|
|||||||
"zh": 72
|
"zh": 72
|
||||||
},
|
},
|
||||||
"purpose": {
|
"purpose": {
|
||||||
"backendImpl": 1225,
|
"backendImpl": 1226,
|
||||||
"debugging": 1245,
|
"debugging": 1245,
|
||||||
"frontendImpl": 1235,
|
"frontendImpl": 1234,
|
||||||
"planning": 1223,
|
"planning": 1223,
|
||||||
"quickFix": 1230,
|
"quickFix": 1230,
|
||||||
"refactor": 1225,
|
"refactor": 1223,
|
||||||
"review": 1230,
|
"review": 1230,
|
||||||
"writing": 1213
|
"writing": 1213
|
||||||
},
|
},
|
||||||
"slice": {
|
"slice": {
|
||||||
"boundary": 2065,
|
"boundary": 2066,
|
||||||
"core": 5820,
|
"core": 5818,
|
||||||
"mixed": 906,
|
"mixed": 906,
|
||||||
"pasted-context": 1035
|
"pasted-context": 1034
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"records": 9826,
|
"records": 9824,
|
||||||
"sha256": "ca0ec9fe6fef6bc4013bf09bdfed5877327f69c98f8e5552c082cbb4e76d9b7d"
|
"sha256": "40a5064c2a95d4394d71b3edbad7d03dce7d67b2e5ba9ab9320476e35e3a4953"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"distribution": {
|
"distribution": {
|
||||||
@@ -123,25 +126,25 @@
|
|||||||
"zh": 7
|
"zh": 7
|
||||||
},
|
},
|
||||||
"purpose": {
|
"purpose": {
|
||||||
"backendImpl": 184,
|
"backendImpl": 183,
|
||||||
"debugging": 157,
|
"debugging": 157,
|
||||||
"frontendImpl": 168,
|
"frontendImpl": 168,
|
||||||
"planning": 141,
|
"planning": 141,
|
||||||
"quickFix": 151,
|
"quickFix": 151,
|
||||||
"refactor": 148,
|
"refactor": 149,
|
||||||
"review": 140,
|
"review": 140,
|
||||||
"writing": 139
|
"writing": 139
|
||||||
},
|
},
|
||||||
"slice": {
|
"slice": {
|
||||||
"boundary": 196,
|
"boundary": 195,
|
||||||
"core": 541,
|
"core": 541,
|
||||||
"mixed": 83,
|
"mixed": 83,
|
||||||
"pasted-context": 95,
|
"pasted-context": 95,
|
||||||
"vague-eval": 313
|
"vague-eval": 314
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"records": 1228,
|
"records": 1228,
|
||||||
"sha256": "7eca32f98105eec1c1a34c324851d52004d8dc720ca5853a1e91fa0a0dd620f4"
|
"sha256": "3d0a3730931db360dd8346cf12d03901ac139acf5be1d9e84975ac591186c588"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -747,7 +747,6 @@
|
|||||||
{"prompt":"add a compact density option to the data grid — user setting, persists, rows go 44 -> 32 and the font drops a step","purpose":"frontendImpl","secondary":null,"mixed":false,"difficulty":0.5,"slice":"core","lang":"en"}
|
{"prompt":"add a compact density option to the data grid — user setting, persists, rows go 44 -> 32 and the font drops a step","purpose":"frontendImpl","secondary":null,"mixed":false,"difficulty":0.5,"slice":"core","lang":"en"}
|
||||||
{"prompt":"Ticket OPS-52152: retire the legacy replay path for LongbowFernSnapshotCoordinator\n\nBackground\nThe mobile clients now send monotonic cursors, but the worker still supports the pre-2024 offset token. That compatibility branch performs an extra database lookup on every page and owns a separate retry counter. Support confirmed that the oldest active client is version 8.14, which uses cursors.\n\nAcceptance notes\n- existing cursor tokens must remain valid during a two-release overlap\n- operators need one metric showing legacy traffic by client version\n- a rollback must not require restoring deleted columns\n- tenant isolation remains enforced in the storage query\n- SDK examples should show how to persist the continuation token\n- the public route and current 429 envelope cannot change\n\nOpen questions\nDo we delete the offset decoder immediately or quarantine it behind a flag? Who owns the mobile version gate? Can the database index be removed in the same deploy? Security wants evidence that forged cursors cannot select another tenant.\n\nSlack excerpt\nMina 09:42: “I can provide client adoption numbers, but not a guarantee about sideloaded builds.”\nRavi 09:47: “Please make the rollback trigger explicit; queue lag alone is too noisy.”\n\nAssess LongbowFernSnapshotCoordinator for durability, tenant isolation, races, and misleading observability. Separate blockers from questions and do not produce a patch.","purpose":"review","secondary":null,"mixed":false,"difficulty":0.79,"slice":"pasted-context","lang":"en"}
|
{"prompt":"Ticket OPS-52152: retire the legacy replay path for LongbowFernSnapshotCoordinator\n\nBackground\nThe mobile clients now send monotonic cursors, but the worker still supports the pre-2024 offset token. That compatibility branch performs an extra database lookup on every page and owns a separate retry counter. Support confirmed that the oldest active client is version 8.14, which uses cursors.\n\nAcceptance notes\n- existing cursor tokens must remain valid during a two-release overlap\n- operators need one metric showing legacy traffic by client version\n- a rollback must not require restoring deleted columns\n- tenant isolation remains enforced in the storage query\n- SDK examples should show how to persist the continuation token\n- the public route and current 429 envelope cannot change\n\nOpen questions\nDo we delete the offset decoder immediately or quarantine it behind a flag? Who owns the mobile version gate? Can the database index be removed in the same deploy? Security wants evidence that forged cursors cannot select another tenant.\n\nSlack excerpt\nMina 09:42: “I can provide client adoption numbers, but not a guarantee about sideloaded builds.”\nRavi 09:47: “Please make the rollback trigger explicit; queue lag alone is too noisy.”\n\nAssess LongbowFernSnapshotCoordinator for durability, tenant isolation, races, and misleading observability. Separate blockers from questions and do not produce a patch.","purpose":"review","secondary":null,"mixed":false,"difficulty":0.79,"slice":"pasted-context","lang":"en"}
|
||||||
{"prompt":"Propose an architecture for claim-event analytics with privacy, late corrections, financial snapshots, model features, retention, and reproducibility.","purpose":"planning","secondary":null,"mixed":false,"difficulty":0.91,"slice":"core","lang":"en"}
|
{"prompt":"Propose an architecture for claim-event analytics with privacy, late corrections, financial snapshots, model features, retention, and reproducibility.","purpose":"planning","secondary":null,"mixed":false,"difficulty":0.91,"slice":"core","lang":"en"}
|
||||||
{"prompt":"CalderaQuartzPlayerCoordinator: could this be clearer","purpose":"refactor","secondary":null,"mixed":false,"difficulty":0.58,"slice":"vague-eval","lang":"en"}
|
|
||||||
{"prompt":"Create an operator runbook for draining a NATS JetStream consumer, replaying a dead-letter subject, and confirming that lag has recovered.","purpose":"writing","secondary":null,"mixed":false,"difficulty":0.4,"slice":"core","lang":"en"}
|
{"prompt":"Create an operator runbook for draining a NATS JetStream consumer, replaying a dead-letter subject, and confirming that lag has recovered.","purpose":"writing","secondary":null,"mixed":false,"difficulty":0.4,"slice":"core","lang":"en"}
|
||||||
{"prompt":"Investigate why expired offers remain visible in saved-search results.","purpose":"debugging","secondary":null,"mixed":false,"difficulty":0.61,"slice":"boundary","lang":"en"}
|
{"prompt":"Investigate why expired offers remain visible in saved-search results.","purpose":"debugging","secondary":null,"mixed":false,"difficulty":0.61,"slice":"boundary","lang":"en"}
|
||||||
{"prompt":"Zephyr: Incident timeline — INC-49147\n\n08:02 deploy IsotopeOspreyJobFlow 6.18.0 begins in eu-west\n08:06 write latency rises from 22 ms to 410 ms; error rate remains below 0.2%\n08:08 autoscaler adds three workers; queue lag continues rising\n08:11 on-call disables feature flag `parallel_commit_v2`\n08:13 latency returns to 31 ms, but duplicate-event warnings increase\n08:19 traffic moved to the previous worker pool\n08:27 queue lag clears; no customer data loss observed\n\nWhat changed\nThe release moved metrics emission into the same Promise.all as the durable write and added NFC normalization to event payloads. Database CPU peaked at 64%, Redis stayed normal, and the downstream consumer reported 183 duplicate keys that its uniqueness constraint ignored.\n\nConstraints from incident command\n- no emergency schema change\n- preserve tenant ordering\n- canary must include a forced consumer rebalance\n- rollback decision must use two independent signals\n- ownership between storage and ingestion teams must be explicit\n\nThe next regular release window is in six days.\n\nMap a safe route from the current IsotopeOspreyJobFlow behavior to the desired one, comparing two approaches and naming telemetry, failure drills, and rollback responsibility.","purpose":"planning","secondary":null,"mixed":false,"difficulty":0.91,"slice":"pasted-context","lang":"en"}
|
{"prompt":"Zephyr: Incident timeline — INC-49147\n\n08:02 deploy IsotopeOspreyJobFlow 6.18.0 begins in eu-west\n08:06 write latency rises from 22 ms to 410 ms; error rate remains below 0.2%\n08:08 autoscaler adds three workers; queue lag continues rising\n08:11 on-call disables feature flag `parallel_commit_v2`\n08:13 latency returns to 31 ms, but duplicate-event warnings increase\n08:19 traffic moved to the previous worker pool\n08:27 queue lag clears; no customer data loss observed\n\nWhat changed\nThe release moved metrics emission into the same Promise.all as the durable write and added NFC normalization to event payloads. Database CPU peaked at 64%, Redis stayed normal, and the downstream consumer reported 183 duplicate keys that its uniqueness constraint ignored.\n\nConstraints from incident command\n- no emergency schema change\n- preserve tenant ordering\n- canary must include a forced consumer rebalance\n- rollback decision must use two independent signals\n- ownership between storage and ingestion teams must be explicit\n\nThe next regular release window is in six days.\n\nMap a safe route from the current IsotopeOspreyJobFlow behavior to the desired one, comparing two approaches and naming telemetry, failure drills, and rollback responsibility.","purpose":"planning","secondary":null,"mixed":false,"difficulty":0.91,"slice":"pasted-context","lang":"en"}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"auditVersion": "purpose-semantic-audit-v1",
|
||||||
|
"candidatePolicy": {
|
||||||
|
"automaticExclusion": false,
|
||||||
|
"crossLabelCosineThreshold": 0.985,
|
||||||
|
"nearestNeighborsPerRecord": 8,
|
||||||
|
"note": "Embedding similarity proposes review candidates only. It cannot safely distinguish deliberate boundary pairs from duplicated labels.",
|
||||||
|
"sameLabelCosineThreshold": 0.97
|
||||||
|
},
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"crossesFrozenBoundary": false,
|
||||||
|
"left": {
|
||||||
|
"line": 4639,
|
||||||
|
"origin": "synthetic",
|
||||||
|
"promptHash": "70944e70e0062e947476f73b704ec01ecee19420d06fd174c58a41c04e8112f6",
|
||||||
|
"purpose": "frontendImpl",
|
||||||
|
"source": "ml/purpose-classifier/data/purpose-prompts.jsonl",
|
||||||
|
"split": "validation"
|
||||||
|
},
|
||||||
|
"right": {
|
||||||
|
"line": 8435,
|
||||||
|
"origin": "synthetic",
|
||||||
|
"promptHash": "cb3e63992cbf5830a222933dc7b44be39933baedcb46e335be02e3c379685517",
|
||||||
|
"purpose": "frontendImpl",
|
||||||
|
"source": "ml/purpose-classifier/data/purpose-prompts.jsonl",
|
||||||
|
"split": "validation"
|
||||||
|
},
|
||||||
|
"sameLabel": true,
|
||||||
|
"similarity": 0.970376
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"embedding": {
|
||||||
|
"device": "cpu",
|
||||||
|
"fixedInputShape": [
|
||||||
|
1,
|
||||||
|
128
|
||||||
|
],
|
||||||
|
"model": "sentence-transformers/all-MiniLM-L6-v2",
|
||||||
|
"pooling": "attention-mask mean pooling followed by L2 normalization",
|
||||||
|
"revision": "1110a243fdf4706b3f48f1d95db1a4f5529b4d41",
|
||||||
|
"truncation": {
|
||||||
|
"headTokens": 63,
|
||||||
|
"strategy": "head-tail-pair",
|
||||||
|
"tailTokens": 62
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"humanReviewSample": {
|
||||||
|
"fraction": 0.1,
|
||||||
|
"generatedCSV": "ml/purpose-classifier/.artifacts/human-review-v1.csv",
|
||||||
|
"populationRecords": 12193,
|
||||||
|
"requiredFields": [
|
||||||
|
"reviewedPurpose",
|
||||||
|
"reviewedSecondary",
|
||||||
|
"reviewedDifficulty",
|
||||||
|
"reviewStatus"
|
||||||
|
],
|
||||||
|
"samplePurposeCounts": {
|
||||||
|
"backendImpl": 158,
|
||||||
|
"debugging": 154,
|
||||||
|
"frontendImpl": 155,
|
||||||
|
"planning": 152,
|
||||||
|
"quickFix": 150,
|
||||||
|
"refactor": 149,
|
||||||
|
"review": 150,
|
||||||
|
"writing": 151
|
||||||
|
},
|
||||||
|
"sampleRecords": 1219,
|
||||||
|
"sampleSliceCounts": {
|
||||||
|
"boundary": 245,
|
||||||
|
"core": 689,
|
||||||
|
"mixed": 108,
|
||||||
|
"pasted-context": 117,
|
||||||
|
"vague-eval": 60
|
||||||
|
},
|
||||||
|
"seed": 10558743,
|
||||||
|
"status": "planned",
|
||||||
|
"strata": [
|
||||||
|
"purpose",
|
||||||
|
"slice",
|
||||||
|
"primary language"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"population": {
|
||||||
|
"auditedRecords": 12280,
|
||||||
|
"canonicalSourceRecords": 12214,
|
||||||
|
"classifiableShippedFixtures": 87,
|
||||||
|
"curatedSyntheticRecords": 12193
|
||||||
|
},
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"summary": {
|
||||||
|
"candidates": 1,
|
||||||
|
"crossLabelCandidates": 0,
|
||||||
|
"frozenBoundaryCandidates": 0,
|
||||||
|
"sameLabelCandidates": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,13 +19,14 @@ from purpose_data import (
|
|||||||
DataError,
|
DataError,
|
||||||
load_classifiable_fixtures,
|
load_classifiable_fixtures,
|
||||||
load_jsonl,
|
load_jsonl,
|
||||||
normalize_prompt,
|
prompt_hash,
|
||||||
write_json,
|
write_json,
|
||||||
)
|
)
|
||||||
from train import (
|
from train import (
|
||||||
MAX_LENGTH,
|
MAX_LENGTH,
|
||||||
classification_metrics,
|
classification_metrics,
|
||||||
confidence_score,
|
confidence_score,
|
||||||
|
encode_fixed_shape,
|
||||||
expected_calibration_error,
|
expected_calibration_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,6 +44,20 @@ DEFAULT_FIXTURES = (
|
|||||||
/ "purpose-prompts.json"
|
/ "purpose-prompts.json"
|
||||||
)
|
)
|
||||||
DEFAULT_REPORT = SCRIPT_DIR / "outputs" / "purpose-lite-v1" / "frozen-eval.json"
|
DEFAULT_REPORT = SCRIPT_DIR / "outputs" / "purpose-lite-v1" / "frozen-eval.json"
|
||||||
|
ROUTING_LEVELS = ("quick", "light", "balanced", "deep", "max")
|
||||||
|
# Cost tiers mirrored from IntelligenceRouter.matrix for the two provider lanes. Keep this
|
||||||
|
# table in sync with Sources/NucleicCore/IntelligenceRouting.swift; the eval report names
|
||||||
|
# every violating prompt/level/lane so a matrix change cannot fail opaquely.
|
||||||
|
ROUTING_COST_TIERS = {
|
||||||
|
"planning": {"claude": (1, 2, 2, 3, 3), "codex": (1, 1, 2, 2, 2)},
|
||||||
|
"backendImpl": {"claude": (1, 1, 2, 3, 3), "codex": (0, 1, 2, 2, 2)},
|
||||||
|
"frontendImpl": {"claude": (1, 1, 2, 2, 3), "codex": (0, 1, 1, 2, 2)},
|
||||||
|
"quickFix": {"claude": (1, 1, 1, 2, 2), "codex": (0, 0, 1, 1, 2)},
|
||||||
|
"refactor": {"claude": (1, 1, 1, 2, 3), "codex": (0, 1, 1, 2, 2)},
|
||||||
|
"debugging": {"claude": (1, 1, 2, 3, 3), "codex": (1, 1, 2, 2, 2)},
|
||||||
|
"review": {"claude": (0, 1, 1, 2, 2), "codex": (0, 1, 1, 2, 2)},
|
||||||
|
"writing": {"claude": (0, 1, 1, 2, 2), "codex": (0, 0, 1, 1, 1)},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _device(torch: Any, requested: str) -> Any:
|
def _device(torch: Any, requested: str) -> Any:
|
||||||
@@ -85,6 +100,46 @@ def _synchronize(torch: Any, device: Any) -> None:
|
|||||||
torch.mps.synchronize()
|
torch.mps.synchronize()
|
||||||
|
|
||||||
|
|
||||||
|
def routing_tier_drift(
|
||||||
|
records: Sequence[dict[str, Any]],
|
||||||
|
actual: Sequence[int],
|
||||||
|
predicted: Sequence[int],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
violations = []
|
||||||
|
maximum = 0
|
||||||
|
checked_misroutes = 0
|
||||||
|
for record, expected_index, predicted_index in zip(records, actual, predicted):
|
||||||
|
if expected_index == predicted_index:
|
||||||
|
continue
|
||||||
|
checked_misroutes += 1
|
||||||
|
expected = LABELS[expected_index]
|
||||||
|
got = LABELS[predicted_index]
|
||||||
|
for lane in ("claude", "codex"):
|
||||||
|
for level_index, level in enumerate(ROUTING_LEVELS):
|
||||||
|
drift = abs(
|
||||||
|
ROUTING_COST_TIERS[expected][lane][level_index]
|
||||||
|
- ROUTING_COST_TIERS[got][lane][level_index]
|
||||||
|
)
|
||||||
|
maximum = max(maximum, drift)
|
||||||
|
if drift > 1:
|
||||||
|
violations.append(
|
||||||
|
{
|
||||||
|
"promptHash": prompt_hash(record["prompt"]),
|
||||||
|
"expected": expected,
|
||||||
|
"predicted": got,
|
||||||
|
"lane": lane,
|
||||||
|
"level": level,
|
||||||
|
"tierDrift": drift,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"checkedMisroutes": checked_misroutes,
|
||||||
|
"maximumTierDrift": maximum,
|
||||||
|
"violations": violations,
|
||||||
|
"passed": not violations,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def evaluate(args: argparse.Namespace) -> dict[str, Any]:
|
def evaluate(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
import torch
|
import torch
|
||||||
@@ -114,29 +169,63 @@ def evaluate(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
high_threshold = float(calibration["confidence"]["high"]["minimumScore"])
|
high_threshold = float(calibration["confidence"]["high"]["minimumScore"])
|
||||||
medium_threshold = float(calibration["confidence"]["medium"]["minimumScore"])
|
medium_threshold = float(calibration["confidence"]["medium"]["minimumScore"])
|
||||||
label_to_id = {label: index for index, label in enumerate(LABELS)}
|
label_to_id = {label: index for index, label in enumerate(LABELS)}
|
||||||
device = _device(torch, args.device)
|
|
||||||
tokenizer = AutoTokenizer.from_pretrained(args.model_dir, local_files_only=True)
|
tokenizer = AutoTokenizer.from_pretrained(args.model_dir, local_files_only=True)
|
||||||
model = AutoModelForSequenceClassification.from_pretrained(
|
onnx_session = None
|
||||||
args.model_dir, local_files_only=True
|
if args.onnx_model is not None:
|
||||||
).to(device)
|
try:
|
||||||
model.eval()
|
import onnxruntime as ort
|
||||||
|
except ImportError as exc:
|
||||||
|
raise DataError(
|
||||||
|
"ONNX evaluation requires onnxruntime from requirements.txt"
|
||||||
|
) from exc
|
||||||
|
if args.device not in ("auto", "cpu"):
|
||||||
|
raise DataError("ONNX evaluation currently measures the CPU provider")
|
||||||
|
session_options = ort.SessionOptions()
|
||||||
|
session_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||||
|
onnx_session = ort.InferenceSession(
|
||||||
|
str(args.onnx_model),
|
||||||
|
sess_options=session_options,
|
||||||
|
providers=["CPUExecutionProvider"],
|
||||||
|
)
|
||||||
|
onnx_input_names = {item.name for item in onnx_session.get_inputs()}
|
||||||
|
device = torch.device("cpu")
|
||||||
|
model = None
|
||||||
|
runtime_name = "onnxruntime-cpu"
|
||||||
|
else:
|
||||||
|
device = _device(torch, args.device)
|
||||||
|
model = AutoModelForSequenceClassification.from_pretrained(
|
||||||
|
args.model_dir, local_files_only=True
|
||||||
|
).to(device)
|
||||||
|
model.eval()
|
||||||
|
onnx_input_names = set()
|
||||||
|
runtime_name = "pytorch"
|
||||||
|
|
||||||
|
def predict_logits(encoded: dict[str, Any]) -> Any:
|
||||||
|
if onnx_session is not None:
|
||||||
|
inputs = {
|
||||||
|
key: value.numpy()
|
||||||
|
for key, value in encoded.items()
|
||||||
|
if key in onnx_input_names
|
||||||
|
}
|
||||||
|
return torch.from_numpy(onnx_session.run(["logits"], inputs)[0])
|
||||||
|
moved = {key: value.to(device) for key, value in encoded.items()}
|
||||||
|
return model(**moved).logits.cpu()
|
||||||
|
|
||||||
actual: list[int] = []
|
actual: list[int] = []
|
||||||
predicted: list[int] = []
|
predicted: list[int] = []
|
||||||
probabilities: list[float] = []
|
probabilities: list[float] = []
|
||||||
confidences: list[str] = []
|
confidences: list[str] = []
|
||||||
|
margins: list[float] = []
|
||||||
|
inference_batch_size = 1 if onnx_session is not None else args.batch_size
|
||||||
with torch.inference_mode():
|
with torch.inference_mode():
|
||||||
for start in range(0, len(records), args.batch_size):
|
for start in range(0, len(records), inference_batch_size):
|
||||||
batch = records[start : start + args.batch_size]
|
batch = records[start : start + inference_batch_size]
|
||||||
encoded = tokenizer(
|
encoded = encode_fixed_shape(
|
||||||
[normalize_prompt(record["prompt"]) for record in batch],
|
tokenizer,
|
||||||
padding="max_length",
|
[record["prompt"] for record in batch],
|
||||||
truncation=True,
|
torch,
|
||||||
max_length=MAX_LENGTH,
|
|
||||||
return_tensors="pt",
|
|
||||||
)
|
)
|
||||||
encoded = {key: value.to(device) for key, value in encoded.items()}
|
logits = predict_logits(encoded) / temperature
|
||||||
logits = model(**encoded).logits.cpu() / temperature
|
|
||||||
distribution = torch.softmax(logits, dim=-1)
|
distribution = torch.softmax(logits, dim=-1)
|
||||||
top = torch.topk(distribution, k=2, dim=-1)
|
top = torch.topk(distribution, k=2, dim=-1)
|
||||||
batch_probabilities = top.values[:, 0].tolist()
|
batch_probabilities = top.values[:, 0].tolist()
|
||||||
@@ -156,6 +245,7 @@ def evaluate(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
actual.append(label_to_id[record["purpose"]])
|
actual.append(label_to_id[record["purpose"]])
|
||||||
predicted.append(prediction)
|
predicted.append(prediction)
|
||||||
probabilities.append(probability)
|
probabilities.append(probability)
|
||||||
|
margins.append(margin)
|
||||||
confidences.append(confidence)
|
confidences.append(confidence)
|
||||||
|
|
||||||
metrics = classification_metrics(actual, predicted)
|
metrics = classification_metrics(actual, predicted)
|
||||||
@@ -169,6 +259,24 @@ def evaluate(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
[actual[index] for index in hard_indexes],
|
[actual[index] for index in hard_indexes],
|
||||||
[predicted[index] for index in hard_indexes],
|
[predicted[index] for index in hard_indexes],
|
||||||
)
|
)
|
||||||
|
scored_indexes = [
|
||||||
|
index
|
||||||
|
for index, record in enumerate(records)
|
||||||
|
if record.get("slice") != "vague-eval"
|
||||||
|
]
|
||||||
|
scored_metrics = classification_metrics(
|
||||||
|
[actual[index] for index in scored_indexes],
|
||||||
|
[predicted[index] for index in scored_indexes],
|
||||||
|
)
|
||||||
|
scored_hard_indexes = [
|
||||||
|
index
|
||||||
|
for index in hard_indexes
|
||||||
|
if records[index].get("slice") != "vague-eval"
|
||||||
|
]
|
||||||
|
scored_hard_metrics = classification_metrics(
|
||||||
|
[actual[index] for index in scored_hard_indexes],
|
||||||
|
[predicted[index] for index in scored_hard_indexes],
|
||||||
|
)
|
||||||
fixture_indexes = [
|
fixture_indexes = [
|
||||||
index
|
index
|
||||||
for index, record in enumerate(records)
|
for index, record in enumerate(records)
|
||||||
@@ -187,40 +295,95 @@ def evaluate(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
if accepted_indexes
|
if accepted_indexes
|
||||||
else 1.0
|
else 1.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def subset_report(indexes: Sequence[int]) -> dict[str, Any]:
|
||||||
|
subset_correct = [correctness[index] for index in indexes]
|
||||||
|
subset_accepted = [
|
||||||
|
index for index in indexes if confidences[index] != "low"
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
**classification_metrics(
|
||||||
|
[actual[index] for index in indexes],
|
||||||
|
[predicted[index] for index in indexes],
|
||||||
|
),
|
||||||
|
"confidenceCounts": dict(
|
||||||
|
sorted(Counter(confidences[index] for index in indexes).items())
|
||||||
|
),
|
||||||
|
"acceptedPrecision": (
|
||||||
|
sum(correctness[index] for index in subset_accepted)
|
||||||
|
/ len(subset_accepted)
|
||||||
|
if subset_accepted
|
||||||
|
else 1.0
|
||||||
|
),
|
||||||
|
"acceptedCoverage": len(subset_accepted) / len(indexes),
|
||||||
|
"meanTopProbability": statistics.mean(
|
||||||
|
probabilities[index] for index in indexes
|
||||||
|
),
|
||||||
|
"meanTopTwoMargin": statistics.mean(margins[index] for index in indexes),
|
||||||
|
"expectedCalibrationError": expected_calibration_error(
|
||||||
|
[probabilities[index] for index in indexes],
|
||||||
|
subset_correct,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
slice_reports = {}
|
||||||
|
for slice_name in sorted({record.get("slice", "unknown") for record in records}):
|
||||||
|
indexes = [
|
||||||
|
index
|
||||||
|
for index, record in enumerate(records)
|
||||||
|
if record.get("slice", "unknown") == slice_name
|
||||||
|
]
|
||||||
|
slice_reports[slice_name] = subset_report(indexes)
|
||||||
|
|
||||||
|
misclassifications = [
|
||||||
|
{
|
||||||
|
"promptHash": prompt_hash(records[index]["prompt"]),
|
||||||
|
"slice": records[index].get("slice", "unknown"),
|
||||||
|
"expected": LABELS[actual[index]],
|
||||||
|
"predicted": LABELS[predicted[index]],
|
||||||
|
"confidence": confidences[index],
|
||||||
|
"topProbability": round(probabilities[index], 6),
|
||||||
|
"topTwoMargin": round(margins[index], 6),
|
||||||
|
}
|
||||||
|
for index in range(len(records))
|
||||||
|
if not correctness[index]
|
||||||
|
]
|
||||||
latency_samples: list[float] = []
|
latency_samples: list[float] = []
|
||||||
latency_records = records[: args.latency_samples]
|
latency_records = records[: args.latency_samples]
|
||||||
if latency_records:
|
if latency_records:
|
||||||
with torch.inference_mode():
|
with torch.inference_mode():
|
||||||
for record in latency_records[: min(5, len(latency_records))]:
|
for record in latency_records[: min(5, len(latency_records))]:
|
||||||
encoded = tokenizer(
|
encoded = encode_fixed_shape(
|
||||||
normalize_prompt(record["prompt"]),
|
tokenizer,
|
||||||
padding="max_length",
|
[record["prompt"]],
|
||||||
truncation=True,
|
torch,
|
||||||
max_length=MAX_LENGTH,
|
|
||||||
return_tensors="pt",
|
|
||||||
)
|
)
|
||||||
model(**{key: value.to(device) for key, value in encoded.items()})
|
predict_logits(encoded)
|
||||||
_synchronize(torch, device)
|
_synchronize(torch, device)
|
||||||
for record in latency_records:
|
for record in latency_records:
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
encoded = tokenizer(
|
encoded = encode_fixed_shape(
|
||||||
normalize_prompt(record["prompt"]),
|
tokenizer,
|
||||||
padding="max_length",
|
[record["prompt"]],
|
||||||
truncation=True,
|
torch,
|
||||||
max_length=MAX_LENGTH,
|
|
||||||
return_tensors="pt",
|
|
||||||
)
|
)
|
||||||
model(**{key: value.to(device) for key, value in encoded.items()})
|
predict_logits(encoded)
|
||||||
_synchronize(torch, device)
|
_synchronize(torch, device)
|
||||||
latency_samples.append((time.perf_counter() - started) * 1000)
|
latency_samples.append((time.perf_counter() - started) * 1000)
|
||||||
|
|
||||||
|
tier_drift = routing_tier_drift(records, actual, predicted)
|
||||||
report = {
|
report = {
|
||||||
"modelVersion": calibration.get("modelVersion", args.model_dir.name),
|
"modelVersion": calibration.get("modelVersion", args.model_dir.name),
|
||||||
"device": str(device),
|
"device": str(device),
|
||||||
|
"runtime": runtime_name,
|
||||||
|
"artifact": str(args.onnx_model or args.model_dir),
|
||||||
"fixedInputShape": [1, MAX_LENGTH],
|
"fixedInputShape": [1, MAX_LENGTH],
|
||||||
"overall": metrics,
|
"overall": metrics,
|
||||||
|
"scoredClassification": scored_metrics,
|
||||||
"hardSlice": hard_metrics,
|
"hardSlice": hard_metrics,
|
||||||
|
"scoredHardSlice": scored_hard_metrics,
|
||||||
"shippedFixtures": fixture_metrics,
|
"shippedFixtures": fixture_metrics,
|
||||||
|
"bySlice": slice_reports,
|
||||||
"calibration": {
|
"calibration": {
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"expectedCalibrationError": expected_calibration_error(
|
"expectedCalibrationError": expected_calibration_error(
|
||||||
@@ -235,16 +398,26 @@ def evaluate(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
"median": statistics.median(latency_samples) if latency_samples else 0.0,
|
"median": statistics.median(latency_samples) if latency_samples else 0.0,
|
||||||
"p95": _percentile(latency_samples, 0.95),
|
"p95": _percentile(latency_samples, 0.95),
|
||||||
},
|
},
|
||||||
|
"routingTierDrift": tier_drift,
|
||||||
"gates": {
|
"gates": {
|
||||||
"accuracyAtLeast95Percent": metrics["accuracy"] >= 0.95,
|
"scoredAccuracyAtLeast95Percent": scored_metrics["accuracy"] >= 0.95,
|
||||||
"everyPurposeRecallAtLeast85Percent": min(
|
"everyPurposeRecallAtLeast85Percent": min(
|
||||||
metrics["perPurposeRecall"].values()
|
scored_metrics["perPurposeRecall"].values()
|
||||||
)
|
)
|
||||||
>= 0.85,
|
>= 0.85,
|
||||||
|
"vagueEvalLowConfidenceAtLeast90Percent": (
|
||||||
|
slice_reports.get("vague-eval", {}).get("confidenceCounts", {}).get(
|
||||||
|
"low", 0
|
||||||
|
)
|
||||||
|
/ max(1, slice_reports.get("vague-eval", {}).get("records", 0))
|
||||||
|
>= 0.90
|
||||||
|
),
|
||||||
"latencyP95AtMost20Milliseconds": (
|
"latencyP95AtMost20Milliseconds": (
|
||||||
not latency_samples or _percentile(latency_samples, 0.95) <= 20.0
|
not latency_samples or _percentile(latency_samples, 0.95) <= 20.0
|
||||||
),
|
),
|
||||||
|
"misroutesStayWithinOneCostTier": tier_drift["passed"],
|
||||||
},
|
},
|
||||||
|
"misclassifications": misclassifications,
|
||||||
}
|
}
|
||||||
write_json(args.report, report)
|
write_json(args.report, report)
|
||||||
return report
|
return report
|
||||||
@@ -253,6 +426,11 @@ def evaluate(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--model-dir", type=Path, default=DEFAULT_MODEL_DIR)
|
parser.add_argument("--model-dir", type=Path, default=DEFAULT_MODEL_DIR)
|
||||||
|
parser.add_argument(
|
||||||
|
"--onnx-model",
|
||||||
|
type=Path,
|
||||||
|
help="score a fixed-shape ONNX artifact instead of the PyTorch checkpoint",
|
||||||
|
)
|
||||||
parser.add_argument("--calibration", type=Path, default=DEFAULT_CALIBRATION)
|
parser.add_argument("--calibration", type=Path, default=DEFAULT_CALIBRATION)
|
||||||
parser.add_argument("--test", type=Path, default=DEFAULT_TEST)
|
parser.add_argument("--test", type=Path, default=DEFAULT_TEST)
|
||||||
parser.add_argument("--fixtures", type=Path, default=DEFAULT_FIXTURES)
|
parser.add_argument("--fixtures", type=Path, default=DEFAULT_FIXTURES)
|
||||||
@@ -279,8 +457,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
print(f"error: {exc}", file=sys.stderr)
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
print(
|
print(
|
||||||
f"Frozen eval: accuracy={report['overall']['accuracy']:.4%}, "
|
f"Frozen eval: scored={report['scoredClassification']['accuracy']:.4%}, "
|
||||||
f"hard={report['hardSlice']['accuracy']:.4%}, "
|
f"scored-hard={report['scoredHardSlice']['accuracy']:.4%}, "
|
||||||
f"p95={report['latencyMilliseconds']['p95']:.2f} ms."
|
f"p95={report['latencyMilliseconds']['p95']:.2f} ms."
|
||||||
)
|
)
|
||||||
if not args.no_gate and not all(report["gates"].values()):
|
if not args.no_gate and not all(report["gates"].values()):
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Export purpose-lite to fixed-shape fp16 and int8-QDQ ONNX artifacts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from purpose_data import DataError, load_jsonl, normalize_prompt, write_json
|
||||||
|
from train import (
|
||||||
|
HEAD_TOKENS,
|
||||||
|
MAX_LENGTH,
|
||||||
|
TAIL_TOKENS,
|
||||||
|
encode_fixed_shape,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
DEFAULT_MODEL_DIR = SCRIPT_DIR / "outputs" / "purpose-lite-v1" / "model"
|
||||||
|
DEFAULT_CALIBRATION = SCRIPT_DIR / "outputs" / "purpose-lite-v1" / "calibration.json"
|
||||||
|
DEFAULT_VALIDATION = SCRIPT_DIR / ".artifacts" / "dataset-v1" / "validation.jsonl"
|
||||||
|
DEFAULT_OUTPUT_DIR = SCRIPT_DIR / "outputs" / "purpose-lite-v1" / "export"
|
||||||
|
MODEL_VERSION = "purpose-lite-v1"
|
||||||
|
SHIPPING_BUDGET_BYTES = 25 * 1024 * 1024
|
||||||
|
GOLDEN_PROMPTS = (
|
||||||
|
"Fix the typo in the README.",
|
||||||
|
" Cafe\u0301\tdeploy\nnow ",
|
||||||
|
(
|
||||||
|
"EXPLAIN ANALYZE shows a sequential scan before the incident notes. "
|
||||||
|
+ "context " * 180
|
||||||
|
+ "Find the root cause and explain which query plan evidence proves it."
|
||||||
|
),
|
||||||
|
"レビューだけして、コードは変更しないでください。",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed_shape_inputs(model: Any) -> list[str]:
|
||||||
|
names = ["input_ids", "attention_mask"]
|
||||||
|
if getattr(model.config, "type_vocab_size", 0) > 1:
|
||||||
|
names.append("token_type_ids")
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_graph(path: Path, input_names: Sequence[str]) -> dict[str, Any]:
|
||||||
|
import onnx
|
||||||
|
|
||||||
|
model = onnx.load(path)
|
||||||
|
onnx.checker.check_model(model)
|
||||||
|
opset = max(
|
||||||
|
item.version for item in model.opset_import if item.domain in ("", "ai.onnx")
|
||||||
|
)
|
||||||
|
if opset < 17:
|
||||||
|
raise DataError(f"{path}: ONNX opset {opset} is below 17")
|
||||||
|
shapes = {}
|
||||||
|
for value in model.graph.input:
|
||||||
|
dimensions = [item.dim_value for item in value.type.tensor_type.shape.dim]
|
||||||
|
shapes[value.name] = dimensions
|
||||||
|
expected = {name: [1, MAX_LENGTH] for name in input_names}
|
||||||
|
if shapes != expected:
|
||||||
|
raise DataError(f"{path}: expected fixed inputs {expected}, got {shapes}")
|
||||||
|
custom_domains = sorted(
|
||||||
|
{
|
||||||
|
node.domain
|
||||||
|
for node in model.graph.node
|
||||||
|
if node.domain not in ("", "ai.onnx")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if custom_domains:
|
||||||
|
raise DataError(f"{path}: custom ONNX op domains are not allowed: {custom_domains}")
|
||||||
|
return {
|
||||||
|
"opset": opset,
|
||||||
|
"inputs": shapes,
|
||||||
|
"nodes": len(model.graph.node),
|
||||||
|
"operators": sorted({node.op_type for node in model.graph.node}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_tokenizer_contract(
|
||||||
|
output_dir: Path, model_dir: Path, tokenizer: Any, torch: Any
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
vocab_destination = output_dir / "vocab.txt"
|
||||||
|
vocabulary = tokenizer.get_vocab()
|
||||||
|
ordered_vocabulary = sorted(vocabulary.items(), key=lambda item: item[1])
|
||||||
|
if [token_id for _, token_id in ordered_vocabulary] != list(
|
||||||
|
range(len(ordered_vocabulary))
|
||||||
|
):
|
||||||
|
raise DataError("tokenizer vocabulary IDs are not contiguous")
|
||||||
|
vocab_destination.write_text(
|
||||||
|
"".join(f"{token}\n" for token, _ in ordered_vocabulary),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
tokenizer_json_source = model_dir / "tokenizer.json"
|
||||||
|
if not tokenizer_json_source.exists():
|
||||||
|
raise DataError(f"{tokenizer_json_source}: tokenizer JSON is missing")
|
||||||
|
tokenizer_json_destination = output_dir / "tokenizer.json"
|
||||||
|
shutil.copy2(tokenizer_json_source, tokenizer_json_destination)
|
||||||
|
|
||||||
|
golden_values = []
|
||||||
|
for prompt in GOLDEN_PROMPTS:
|
||||||
|
encoded = encode_fixed_shape(tokenizer, [prompt], torch)
|
||||||
|
golden_values.append(
|
||||||
|
{
|
||||||
|
"prompt": prompt,
|
||||||
|
"normalizedPrompt": normalize_prompt(prompt),
|
||||||
|
"inputIds": encoded["input_ids"][0].tolist(),
|
||||||
|
"attentionMask": encoded["attention_mask"][0].tolist(),
|
||||||
|
"tokenTypeIds": encoded.get(
|
||||||
|
"token_type_ids", torch.zeros_like(encoded["input_ids"])
|
||||||
|
)[0].tolist(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
write_json(output_dir / "tokenizer-goldens.json", golden_values)
|
||||||
|
contract = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"modelVersion": MODEL_VERSION,
|
||||||
|
"tokenizer": {
|
||||||
|
"family": "BERT WordPiece",
|
||||||
|
"vocabFile": vocab_destination.name,
|
||||||
|
"vocabSha256": _sha256(vocab_destination),
|
||||||
|
"tokenizerJSON": tokenizer_json_destination.name,
|
||||||
|
"tokenizerJSONSha256": _sha256(tokenizer_json_destination),
|
||||||
|
"lowercase": bool(getattr(tokenizer, "do_lower_case", True)),
|
||||||
|
},
|
||||||
|
"normalization": ["Unicode NFKC", "collapse Unicode whitespace", "trim"],
|
||||||
|
"input": {
|
||||||
|
"shape": [1, MAX_LENGTH],
|
||||||
|
"padding": "right",
|
||||||
|
"longPromptStrategy": "BERT sentence pair: head and tail",
|
||||||
|
"headTokens": HEAD_TOKENS,
|
||||||
|
"tailTokens": TAIL_TOKENS,
|
||||||
|
"specialTokenLayout": "[CLS] head [SEP] tail [SEP]",
|
||||||
|
},
|
||||||
|
"specialTokenIds": {
|
||||||
|
"padding": tokenizer.pad_token_id,
|
||||||
|
"unknown": tokenizer.unk_token_id,
|
||||||
|
"classification": tokenizer.cls_token_id,
|
||||||
|
"separator": tokenizer.sep_token_id,
|
||||||
|
},
|
||||||
|
"goldens": "tokenizer-goldens.json",
|
||||||
|
}
|
||||||
|
write_json(output_dir / "tokenizer-spec.json", contract)
|
||||||
|
return contract
|
||||||
|
|
||||||
|
|
||||||
|
def export(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
import onnx
|
||||||
|
import torch
|
||||||
|
from onnxconverter_common import float16
|
||||||
|
from onnxruntime.quantization import (
|
||||||
|
CalibrationDataReader,
|
||||||
|
QuantFormat,
|
||||||
|
QuantType,
|
||||||
|
quantize_static,
|
||||||
|
)
|
||||||
|
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||||
|
except ImportError as exc:
|
||||||
|
raise DataError(
|
||||||
|
"export dependencies are missing; install requirements.txt"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
output_dir: Path = args.output_dir
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(args.model_dir, local_files_only=True)
|
||||||
|
model = AutoModelForSequenceClassification.from_pretrained(
|
||||||
|
args.model_dir, local_files_only=True
|
||||||
|
)
|
||||||
|
model.eval()
|
||||||
|
input_names = _fixed_shape_inputs(model)
|
||||||
|
example = encode_fixed_shape(tokenizer, ["Plan a safe cache migration."], torch)
|
||||||
|
|
||||||
|
class LogitsModel(torch.nn.Module):
|
||||||
|
def __init__(self, inner: Any) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.inner = inner
|
||||||
|
|
||||||
|
def forward(self, *values: Any) -> Any:
|
||||||
|
inputs = dict(zip(input_names, values))
|
||||||
|
return self.inner(**inputs).logits
|
||||||
|
|
||||||
|
fp16_path = output_dir / f"{MODEL_VERSION}-fp16.onnx"
|
||||||
|
int8_path = output_dir / f"{MODEL_VERSION}-int8-qdq.onnx"
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
fp32_path = Path(temporary) / f"{MODEL_VERSION}-fp32.onnx"
|
||||||
|
torch.onnx.export(
|
||||||
|
LogitsModel(model),
|
||||||
|
tuple(example[name] for name in input_names),
|
||||||
|
fp32_path,
|
||||||
|
input_names=input_names,
|
||||||
|
output_names=["logits"],
|
||||||
|
opset_version=args.opset,
|
||||||
|
do_constant_folding=True,
|
||||||
|
dynamo=False,
|
||||||
|
)
|
||||||
|
fp32_model = onnx.load(fp32_path)
|
||||||
|
fp16_model = float16.convert_float_to_float16(
|
||||||
|
fp32_model,
|
||||||
|
keep_io_types=True,
|
||||||
|
disable_shape_infer=False,
|
||||||
|
)
|
||||||
|
onnx.save(fp16_model, fp16_path)
|
||||||
|
|
||||||
|
validation = load_jsonl(args.validation)
|
||||||
|
|
||||||
|
class Reader(CalibrationDataReader):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.index = 0
|
||||||
|
self.samples = validation[: args.calibration_records]
|
||||||
|
|
||||||
|
def get_next(self) -> dict[str, np.ndarray] | None:
|
||||||
|
if self.index >= len(self.samples):
|
||||||
|
return None
|
||||||
|
record = self.samples[self.index]
|
||||||
|
self.index += 1
|
||||||
|
encoded = encode_fixed_shape(tokenizer, [record["prompt"]], torch)
|
||||||
|
return {
|
||||||
|
name: encoded[name].numpy().astype(np.int64, copy=False)
|
||||||
|
for name in input_names
|
||||||
|
}
|
||||||
|
|
||||||
|
quantize_static(
|
||||||
|
fp32_path,
|
||||||
|
int8_path,
|
||||||
|
Reader(),
|
||||||
|
quant_format=QuantFormat.QDQ,
|
||||||
|
activation_type=QuantType.QUInt8,
|
||||||
|
weight_type=QuantType.QInt8,
|
||||||
|
per_channel=True,
|
||||||
|
# Gather is essential: MiniLM's 30k x 384 embedding table is more than half
|
||||||
|
# the checkpoint. Quantizing only MatMul/Gemm leaves a ~47 MB fp32 table and
|
||||||
|
# cannot meet the 25 MB parity-floor artifact budget.
|
||||||
|
op_types_to_quantize=["Gather", "MatMul", "Gemm"],
|
||||||
|
extra_options={
|
||||||
|
"ActivationSymmetric": False,
|
||||||
|
"WeightSymmetric": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
graph_reports = {
|
||||||
|
"fp16": _validate_graph(fp16_path, input_names),
|
||||||
|
"int8QDQ": _validate_graph(int8_path, input_names),
|
||||||
|
}
|
||||||
|
int8_size = int8_path.stat().st_size
|
||||||
|
if int8_size > args.shipping_budget_bytes:
|
||||||
|
raise DataError(
|
||||||
|
f"{int8_path}: {int8_size} bytes exceeds the "
|
||||||
|
f"{args.shipping_budget_bytes}-byte shipping budget"
|
||||||
|
)
|
||||||
|
|
||||||
|
tokenizer_contract = _write_tokenizer_contract(
|
||||||
|
output_dir, args.model_dir, tokenizer, torch
|
||||||
|
)
|
||||||
|
calibration_destination = output_dir / "calibration.json"
|
||||||
|
shutil.copy2(args.calibration, calibration_destination)
|
||||||
|
artifacts = {}
|
||||||
|
for name, path in (("fp16", fp16_path), ("int8QDQ", int8_path)):
|
||||||
|
artifacts[name] = {
|
||||||
|
"path": path.name,
|
||||||
|
"bytes": path.stat().st_size,
|
||||||
|
"sha256": _sha256(path),
|
||||||
|
}
|
||||||
|
report = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"modelVersion": MODEL_VERSION,
|
||||||
|
"sourceModel": str(args.model_dir),
|
||||||
|
"opset": args.opset,
|
||||||
|
"fixedInputShape": [1, MAX_LENGTH],
|
||||||
|
"inputNames": input_names,
|
||||||
|
"calibrationRecords": min(args.calibration_records, len(validation)),
|
||||||
|
"shippingArtifact": "int8QDQ",
|
||||||
|
"shippingBudgetBytes": args.shipping_budget_bytes,
|
||||||
|
"shippingBudgetPassed": int8_size <= args.shipping_budget_bytes,
|
||||||
|
"artifacts": artifacts,
|
||||||
|
"graphs": graph_reports,
|
||||||
|
"tokenizerSpecSha256": _sha256(output_dir / "tokenizer-spec.json"),
|
||||||
|
"tokenizerContract": tokenizer_contract["input"],
|
||||||
|
"calibrationSha256": _sha256(calibration_destination),
|
||||||
|
}
|
||||||
|
write_json(output_dir / "export-metrics.json", report)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--model-dir", type=Path, default=DEFAULT_MODEL_DIR)
|
||||||
|
parser.add_argument("--calibration", type=Path, default=DEFAULT_CALIBRATION)
|
||||||
|
parser.add_argument("--validation", type=Path, default=DEFAULT_VALIDATION)
|
||||||
|
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
||||||
|
parser.add_argument("--opset", type=int, default=17)
|
||||||
|
parser.add_argument("--calibration-records", type=int, default=256)
|
||||||
|
parser.add_argument(
|
||||||
|
"--shipping-budget-bytes", type=int, default=SHIPPING_BUDGET_BYTES
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if (
|
||||||
|
args.opset < 17
|
||||||
|
or args.calibration_records <= 0
|
||||||
|
or args.shipping_budget_bytes <= 0
|
||||||
|
):
|
||||||
|
parser.error("opset must be >=17 and record/budget values must be positive")
|
||||||
|
try:
|
||||||
|
report = export(args)
|
||||||
|
except (DataError, OSError, ValueError) as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(
|
||||||
|
f"Exported {MODEL_VERSION}: fp16={report['artifacts']['fp16']['bytes']} bytes, "
|
||||||
|
f"int8-QDQ={report['artifacts']['int8QDQ']['bytes']} bytes."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+46
-2
@@ -13,10 +13,12 @@ from typing import Any, Sequence
|
|||||||
from purpose_data import (
|
from purpose_data import (
|
||||||
HARD_SLICES,
|
HARD_SLICES,
|
||||||
LABELS,
|
LABELS,
|
||||||
|
CurationResult,
|
||||||
DataError,
|
DataError,
|
||||||
SourceRecord,
|
SourceRecord,
|
||||||
curate_records,
|
curate_records,
|
||||||
distribution,
|
distribution,
|
||||||
|
exclude_reviewed_duplicates,
|
||||||
file_sha256,
|
file_sha256,
|
||||||
jsonl_bytes,
|
jsonl_bytes,
|
||||||
load_classifiable_fixtures,
|
load_classifiable_fixtures,
|
||||||
@@ -42,6 +44,7 @@ DEFAULT_FIXTURES = (
|
|||||||
DEFAULT_OUTPUT_DIR = SCRIPT_DIR / ".artifacts" / "dataset-v1"
|
DEFAULT_OUTPUT_DIR = SCRIPT_DIR / ".artifacts" / "dataset-v1"
|
||||||
DEFAULT_FROZEN_TEST = DATA_DIR / "frozen-test-v1.jsonl"
|
DEFAULT_FROZEN_TEST = DATA_DIR / "frozen-test-v1.jsonl"
|
||||||
DEFAULT_SPLIT_MANIFEST = DATA_DIR / "dataset-v1-manifest.json"
|
DEFAULT_SPLIT_MANIFEST = DATA_DIR / "dataset-v1-manifest.json"
|
||||||
|
DEFAULT_CURATION_REVIEW = DATA_DIR / "curation-review-v1.json"
|
||||||
DATASET_VERSION = "purpose-dataset-v1"
|
DATASET_VERSION = "purpose-dataset-v1"
|
||||||
DEFAULT_SEED = 0xC1A551F1
|
DEFAULT_SEED = 0xC1A551F1
|
||||||
|
|
||||||
@@ -85,6 +88,21 @@ def _sha256_bytes(value: bytes) -> str:
|
|||||||
return hashlib.sha256(value).hexdigest()
|
return hashlib.sha256(value).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def load_reviewed_semantic_exclusions(path: Path) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
review = value["semanticDuplicateReview"]
|
||||||
|
decisions = review["excluded"]
|
||||||
|
status = review["status"]
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError, KeyError, TypeError) as exc:
|
||||||
|
raise DataError(f"{path}: cannot read semantic exclusion review: {exc}") from exc
|
||||||
|
if status != "complete":
|
||||||
|
raise DataError(f"{path}: semantic duplicate review is not complete")
|
||||||
|
if not isinstance(decisions, list):
|
||||||
|
raise DataError(f"{path}: semantic excluded must be an array")
|
||||||
|
return decisions
|
||||||
|
|
||||||
|
|
||||||
def _records(values: Sequence[SourceRecord]) -> list[dict[str, Any]]:
|
def _records(values: Sequence[SourceRecord]) -> list[dict[str, Any]]:
|
||||||
return [record.value for record in values]
|
return [record.value for record in values]
|
||||||
|
|
||||||
@@ -102,8 +120,9 @@ def _build_manifest(
|
|||||||
output_hashes: dict[str, str],
|
output_hashes: dict[str, str],
|
||||||
seed: int,
|
seed: int,
|
||||||
near_duplicate_threshold: float,
|
near_duplicate_threshold: float,
|
||||||
|
curation_review_path: Path | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return {
|
manifest = {
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"datasetVersion": DATASET_VERSION,
|
"datasetVersion": DATASET_VERSION,
|
||||||
"seed": seed,
|
"seed": seed,
|
||||||
@@ -159,6 +178,10 @@ def _build_manifest(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if curation_review_path is not None:
|
||||||
|
manifest["curation"]["reviewPath"] = _relative(curation_review_path)
|
||||||
|
manifest["curation"]["reviewSha256"] = file_sha256(curation_review_path)
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
def prepare(
|
def prepare(
|
||||||
@@ -171,6 +194,7 @@ def prepare(
|
|||||||
refresh_frozen_test: bool,
|
refresh_frozen_test: bool,
|
||||||
seed: int,
|
seed: int,
|
||||||
near_duplicate_threshold: float,
|
near_duplicate_threshold: float,
|
||||||
|
curation_review_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
records = load_sources(sources)
|
records = load_sources(sources)
|
||||||
fixtures = load_classifiable_fixtures(fixtures_path)
|
fixtures = load_classifiable_fixtures(fixtures_path)
|
||||||
@@ -178,11 +202,23 @@ def prepare(
|
|||||||
if classifiable_fixture_count != len(fixtures):
|
if classifiable_fixture_count != len(fixtures):
|
||||||
raise DataError("fixture accounting mismatch")
|
raise DataError("fixture accounting mismatch")
|
||||||
|
|
||||||
curated = curate_records(
|
lexical_curation = curate_records(
|
||||||
records,
|
records,
|
||||||
fixtures,
|
fixtures,
|
||||||
near_duplicate_threshold=near_duplicate_threshold,
|
near_duplicate_threshold=near_duplicate_threshold,
|
||||||
)
|
)
|
||||||
|
reviewed_curation = (
|
||||||
|
exclude_reviewed_duplicates(
|
||||||
|
lexical_curation.records,
|
||||||
|
load_reviewed_semantic_exclusions(curation_review_path),
|
||||||
|
)
|
||||||
|
if curation_review_path is not None
|
||||||
|
else CurationResult(records=lexical_curation.records, duplicates=[])
|
||||||
|
)
|
||||||
|
curated = CurationResult(
|
||||||
|
records=reviewed_curation.records,
|
||||||
|
duplicates=lexical_curation.duplicates + reviewed_curation.duplicates,
|
||||||
|
)
|
||||||
splits = split_records(
|
splits = split_records(
|
||||||
curated.records,
|
curated.records,
|
||||||
fixture_count=classifiable_fixture_count,
|
fixture_count=classifiable_fixture_count,
|
||||||
@@ -246,6 +282,7 @@ def prepare(
|
|||||||
output_hashes=output_hashes,
|
output_hashes=output_hashes,
|
||||||
seed=seed,
|
seed=seed,
|
||||||
near_duplicate_threshold=near_duplicate_threshold,
|
near_duplicate_threshold=near_duplicate_threshold,
|
||||||
|
curation_review_path=curation_review_path,
|
||||||
)
|
)
|
||||||
# The frozen path can be overridden in tests or experiments.
|
# The frozen path can be overridden in tests or experiments.
|
||||||
manifest["frozenEval"]["syntheticPath"] = _relative(frozen_test_path)
|
manifest["frozenEval"]["syntheticPath"] = _relative(frozen_test_path)
|
||||||
@@ -285,6 +322,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
parser.add_argument("--seed", type=int, default=DEFAULT_SEED)
|
parser.add_argument("--seed", type=int, default=DEFAULT_SEED)
|
||||||
parser.add_argument("--near-duplicate-threshold", type=float, default=0.92)
|
parser.add_argument("--near-duplicate-threshold", type=float, default=0.92)
|
||||||
|
parser.add_argument(
|
||||||
|
"--curation-review",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_CURATION_REVIEW,
|
||||||
|
help="completed semantic duplicate review applied after lexical deduplication",
|
||||||
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -305,6 +348,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
refresh_frozen_test=args.refresh_frozen_test,
|
refresh_frozen_test=args.refresh_frozen_test,
|
||||||
seed=args.seed,
|
seed=args.seed,
|
||||||
near_duplicate_threshold=args.near_duplicate_threshold,
|
near_duplicate_threshold=args.near_duplicate_threshold,
|
||||||
|
curation_review_path=args.curation_review.resolve(),
|
||||||
)
|
)
|
||||||
except (DataError, OSError, UnicodeError, json.JSONDecodeError) as exc:
|
except (DataError, OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
print(f"error: {exc}", file=sys.stderr)
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
|||||||
@@ -364,6 +364,73 @@ def curate_records(
|
|||||||
return CurationResult(records=kept, duplicates=duplicates)
|
return CurationResult(records=kept, duplicates=duplicates)
|
||||||
|
|
||||||
|
|
||||||
|
def exclude_reviewed_duplicates(
|
||||||
|
records: Sequence[SourceRecord],
|
||||||
|
decisions: Sequence[dict[str, Any]],
|
||||||
|
) -> CurationResult:
|
||||||
|
"""Apply human-reviewed semantic exclusions, failing closed on corpus drift."""
|
||||||
|
|
||||||
|
by_hash = {prompt_hash(record.value["prompt"]): record for record in records}
|
||||||
|
if len(by_hash) != len(records):
|
||||||
|
raise DataError("reviewed exclusions require an exact-deduplicated population")
|
||||||
|
|
||||||
|
drops: dict[str, tuple[str, float]] = {}
|
||||||
|
for index, decision in enumerate(decisions, 1):
|
||||||
|
try:
|
||||||
|
dropped_hash = decision["droppedPromptHash"]
|
||||||
|
matched_hash = decision["matchedPromptHash"]
|
||||||
|
similarity = float(decision["similarity"])
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise DataError(
|
||||||
|
f"semantic exclusion {index}: invalid decision fields"
|
||||||
|
) from exc
|
||||||
|
if (
|
||||||
|
not isinstance(dropped_hash, str)
|
||||||
|
or not isinstance(matched_hash, str)
|
||||||
|
or len(dropped_hash) != 64
|
||||||
|
or len(matched_hash) != 64
|
||||||
|
or not 0.0 <= similarity <= 1.0
|
||||||
|
):
|
||||||
|
raise DataError(f"semantic exclusion {index}: invalid hashes/similarity")
|
||||||
|
if dropped_hash == matched_hash:
|
||||||
|
raise DataError(f"semantic exclusion {index}: cannot match itself")
|
||||||
|
if dropped_hash in drops:
|
||||||
|
raise DataError(f"semantic exclusion {index}: duplicate dropped hash")
|
||||||
|
drops[dropped_hash] = (matched_hash, similarity)
|
||||||
|
|
||||||
|
missing = sorted((set(drops) | {item[0] for item in drops.values()}) - set(by_hash))
|
||||||
|
if missing:
|
||||||
|
raise DataError(
|
||||||
|
"reviewed semantic exclusion no longer matches the curated corpus: "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
kept = []
|
||||||
|
duplicates = []
|
||||||
|
for record in records:
|
||||||
|
dropped_hash = prompt_hash(record.value["prompt"])
|
||||||
|
decision = drops.get(dropped_hash)
|
||||||
|
if decision is None:
|
||||||
|
kept.append(record)
|
||||||
|
continue
|
||||||
|
matched_hash, similarity = decision
|
||||||
|
matched = by_hash[matched_hash]
|
||||||
|
if record.value["purpose"] != matched.value["purpose"]:
|
||||||
|
raise DataError(
|
||||||
|
"reviewed semantic duplicate labels no longer agree: "
|
||||||
|
f"{dropped_hash} vs {matched_hash}"
|
||||||
|
)
|
||||||
|
duplicates.append(
|
||||||
|
Duplicate(
|
||||||
|
dropped=record,
|
||||||
|
matched_prompt_hash=matched_hash,
|
||||||
|
kind="semantic-reviewed",
|
||||||
|
similarity=similarity,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return CurationResult(records=kept, duplicates=duplicates)
|
||||||
|
|
||||||
|
|
||||||
def _stable_digest(seed: int, prompt: str) -> str:
|
def _stable_digest(seed: int, prompt: str) -> str:
|
||||||
material = f"{seed}\0{normalized_key(prompt)}".encode("utf-8")
|
material = f"{seed}\0{normalized_key(prompt)}".encode("utf-8")
|
||||||
return hashlib.sha256(material).hexdigest()
|
return hashlib.sha256(material).hexdigest()
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
numpy==2.5.1
|
numpy==2.5.1
|
||||||
|
onnx==1.22.0
|
||||||
|
onnxconverter-common==1.16.0
|
||||||
|
onnxruntime==1.28.0
|
||||||
transformers==5.14.1
|
transformers==5.14.1
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(MODULE_DIR))
|
||||||
|
|
||||||
|
import audit_data
|
||||||
|
from purpose_data import SourceRecord
|
||||||
|
|
||||||
|
|
||||||
|
def record(index, purpose, slice_name="core", language="en"):
|
||||||
|
return SourceRecord(
|
||||||
|
value={
|
||||||
|
"prompt": f"prompt {index}",
|
||||||
|
"purpose": purpose,
|
||||||
|
"secondary": None,
|
||||||
|
"mixed": False,
|
||||||
|
"difficulty": 0.5,
|
||||||
|
"slice": slice_name,
|
||||||
|
"lang": language,
|
||||||
|
},
|
||||||
|
source=Path("source.jsonl"),
|
||||||
|
line=index + 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSampleTests(unittest.TestCase):
|
||||||
|
def test_sample_is_exact_and_deterministic(self):
|
||||||
|
records = [
|
||||||
|
record(index, audit_data.LABELS[index % len(audit_data.LABELS)])
|
||||||
|
for index in range(101)
|
||||||
|
]
|
||||||
|
first = audit_data.stratified_review_sample(records, fraction=0.1, seed=42)
|
||||||
|
second = audit_data.stratified_review_sample(records, fraction=0.1, seed=42)
|
||||||
|
self.assertEqual(10, len(first))
|
||||||
|
self.assertEqual(
|
||||||
|
[item.value["prompt"] for item in first],
|
||||||
|
[item.value["prompt"] for item in second],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_review_csv_has_blank_reviewer_fields(self):
|
||||||
|
records = [record(0, "planning")]
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
path = Path(temporary) / "review.csv"
|
||||||
|
audit_data.write_review_csv(path, records)
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("reviewedPurpose", text)
|
||||||
|
self.assertIn("prompt 0", text)
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticCandidateTests(unittest.TestCase):
|
||||||
|
def test_threshold_depends_on_label_agreement(self):
|
||||||
|
embeddings = np.asarray(
|
||||||
|
[
|
||||||
|
[1.0, 0.0],
|
||||||
|
[0.98, 0.2],
|
||||||
|
[0.98, -0.2],
|
||||||
|
],
|
||||||
|
dtype=np.float32,
|
||||||
|
)
|
||||||
|
candidates = audit_data.semantic_candidates(
|
||||||
|
embeddings,
|
||||||
|
["planning", "planning", "writing"],
|
||||||
|
same_label_threshold=0.97,
|
||||||
|
cross_label_threshold=0.99,
|
||||||
|
neighbors=2,
|
||||||
|
block_size=2,
|
||||||
|
)
|
||||||
|
pairs = {(item.left, item.right) for item in candidates}
|
||||||
|
self.assertIn((0, 1), pairs)
|
||||||
|
self.assertNotIn((0, 2), pairs)
|
||||||
|
|
||||||
|
def test_candidate_pairs_are_deduplicated(self):
|
||||||
|
embeddings = np.asarray([[1.0, 0.0], [1.0, 0.0]], dtype=np.float32)
|
||||||
|
candidates = audit_data.semantic_candidates(
|
||||||
|
embeddings,
|
||||||
|
["review", "review"],
|
||||||
|
same_label_threshold=0.9,
|
||||||
|
cross_label_threshold=0.9,
|
||||||
|
neighbors=1,
|
||||||
|
block_size=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(candidates))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(MODULE_DIR))
|
||||||
|
|
||||||
|
import eval as purpose_eval
|
||||||
|
|
||||||
|
|
||||||
|
class TierDriftTests(unittest.TestCase):
|
||||||
|
def test_current_routing_matrix_bounds_every_label_pair(self):
|
||||||
|
records = [{"prompt": f"prompt {index}"} for index in range(8 * 8)]
|
||||||
|
actual = []
|
||||||
|
predicted = []
|
||||||
|
for expected in range(8):
|
||||||
|
for got in range(8):
|
||||||
|
actual.append(expected)
|
||||||
|
predicted.append(got)
|
||||||
|
report = purpose_eval.routing_tier_drift(records, actual, predicted)
|
||||||
|
self.assertTrue(report["passed"])
|
||||||
|
self.assertLessEqual(report["maximumTierDrift"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -84,6 +84,26 @@ class CurationTests(unittest.TestCase):
|
|||||||
near_duplicate_threshold=0.92,
|
near_duplicate_threshold=0.92,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_reviewed_semantic_exclusions_fail_closed_on_corpus_drift(self):
|
||||||
|
first = source(example(0, prompt="Plan the cache migration", purpose="planning"), 1)
|
||||||
|
second = source(
|
||||||
|
example(1, prompt="Propose a cache migration plan", purpose="planning"), 2
|
||||||
|
)
|
||||||
|
decision = {
|
||||||
|
"droppedPromptHash": purpose_data.prompt_hash(second.value["prompt"]),
|
||||||
|
"matchedPromptHash": purpose_data.prompt_hash(first.value["prompt"]),
|
||||||
|
"similarity": 0.98,
|
||||||
|
}
|
||||||
|
result = purpose_data.exclude_reviewed_duplicates(
|
||||||
|
[first, second], [decision]
|
||||||
|
)
|
||||||
|
self.assertEqual([first], result.records)
|
||||||
|
self.assertEqual("semantic-reviewed", result.duplicates[0].kind)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
purpose_data.DataError, "no longer matches the curated corpus"
|
||||||
|
):
|
||||||
|
purpose_data.exclude_reviewed_duplicates([first], [decision])
|
||||||
|
|
||||||
|
|
||||||
class SplitTests(unittest.TestCase):
|
class SplitTests(unittest.TestCase):
|
||||||
def test_split_is_deterministic_stratified_and_keeps_vague_out_of_train(self):
|
def test_split_is_deterministic_stratified_and_keeps_vague_out_of_train(self):
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import sys
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
|
||||||
MODULE_DIR = Path(__file__).resolve().parents[1]
|
MODULE_DIR = Path(__file__).resolve().parents[1]
|
||||||
sys.path.insert(0, str(MODULE_DIR))
|
sys.path.insert(0, str(MODULE_DIR))
|
||||||
@@ -42,5 +44,45 @@ class MetricsTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FixedShapeTokenizerTests(unittest.TestCase):
|
||||||
|
class Tokenizer:
|
||||||
|
pad_token_id = 0
|
||||||
|
cls_token_id = 1
|
||||||
|
sep_token_id = 2
|
||||||
|
padding_side = "right"
|
||||||
|
model_input_names = ["input_ids", "attention_mask", "token_type_ids"]
|
||||||
|
|
||||||
|
def __call__(self, texts, **_):
|
||||||
|
return {
|
||||||
|
"input_ids": [
|
||||||
|
list(range(10, 10 + int(text.split()[-1]))) for text in texts
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def num_special_tokens_to_add(pair=False):
|
||||||
|
return 3 if pair else 2
|
||||||
|
|
||||||
|
def test_long_input_keeps_head_and_tail_in_fixed_pair_shape(self):
|
||||||
|
encoded = train.encode_fixed_shape(
|
||||||
|
self.Tokenizer(), ["tokens 200"], torch
|
||||||
|
)
|
||||||
|
self.assertEqual((1, train.MAX_LENGTH), tuple(encoded["input_ids"].shape))
|
||||||
|
row = encoded["input_ids"][0].tolist()
|
||||||
|
self.assertEqual(list(range(10, 10 + train.HEAD_TOKENS)), row[1:64])
|
||||||
|
self.assertEqual(2, row[64])
|
||||||
|
self.assertEqual(
|
||||||
|
list(range(10 + 200 - train.TAIL_TOKENS, 10 + 200)),
|
||||||
|
row[65:127],
|
||||||
|
)
|
||||||
|
self.assertEqual(2, row[127])
|
||||||
|
self.assertEqual(1, encoded["token_type_ids"][0, 65].item())
|
||||||
|
|
||||||
|
def test_short_input_is_padded_as_one_sequence(self):
|
||||||
|
encoded = train.encode_fixed_shape(self.Tokenizer(), ["tokens 3"], torch)
|
||||||
|
self.assertEqual([1, 10, 11, 12, 2], encoded["input_ids"][0, :5].tolist())
|
||||||
|
self.assertEqual(0, encoded["attention_mask"][0, 5].item())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -22,12 +22,95 @@ DEFAULT_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
|||||||
# Reproducibility requires a model commit, not a mutable `main` branch.
|
# Reproducibility requires a model commit, not a mutable `main` branch.
|
||||||
DEFAULT_MODEL_REVISION = "1110a243fdf4706b3f48f1d95db1a4f5529b4d41"
|
DEFAULT_MODEL_REVISION = "1110a243fdf4706b3f48f1d95db1a4f5529b4d41"
|
||||||
MAX_LENGTH = 128
|
MAX_LENGTH = 128
|
||||||
|
HEAD_TAIL_SPECIAL_TOKENS = 3
|
||||||
|
HEAD_TOKENS = (MAX_LENGTH - HEAD_TAIL_SPECIAL_TOKENS + 1) // 2
|
||||||
|
TAIL_TOKENS = MAX_LENGTH - HEAD_TAIL_SPECIAL_TOKENS - HEAD_TOKENS
|
||||||
|
|
||||||
|
|
||||||
def prepare_text(prompt: str) -> str:
|
def prepare_text(prompt: str) -> str:
|
||||||
return normalize_prompt(prompt)
|
return normalize_prompt(prompt)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_fixed_shape(
|
||||||
|
tokenizer: Any,
|
||||||
|
texts: Sequence[str],
|
||||||
|
torch: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Tokenize to 1x128 while retaining both context and a tail-buried request.
|
||||||
|
|
||||||
|
Pasted logs and stack traces frequently put the actual ask after the context. Plain
|
||||||
|
right truncation made generated boundary examples identical even when their final
|
||||||
|
request — and therefore their label — differed. Long inputs use BERT's sentence-pair
|
||||||
|
framing: [CLS] first 63 content tokens [SEP] last 62 content tokens [SEP].
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized = [prepare_text(text) for text in texts]
|
||||||
|
raw = tokenizer(
|
||||||
|
normalized,
|
||||||
|
add_special_tokens=False,
|
||||||
|
padding=False,
|
||||||
|
truncation=False,
|
||||||
|
return_attention_mask=False,
|
||||||
|
return_token_type_ids=False,
|
||||||
|
verbose=False,
|
||||||
|
)
|
||||||
|
if not isinstance(raw.get("input_ids"), list):
|
||||||
|
raise DataError("tokenizer did not return input_ids")
|
||||||
|
if tokenizer.pad_token_id is None:
|
||||||
|
raise DataError("purpose-lite tokenizer must define a padding token")
|
||||||
|
if tokenizer.cls_token_id is None or tokenizer.sep_token_id is None:
|
||||||
|
raise DataError("purpose-lite tokenizer must define BERT CLS and SEP tokens")
|
||||||
|
if tokenizer.padding_side != "right":
|
||||||
|
raise DataError("purpose-lite tokenizer must use right padding")
|
||||||
|
|
||||||
|
input_rows: list[list[int]] = []
|
||||||
|
mask_rows: list[list[int]] = []
|
||||||
|
type_rows: list[list[int]] = []
|
||||||
|
include_token_types = "token_type_ids" in tokenizer.model_input_names
|
||||||
|
single_budget = MAX_LENGTH - tokenizer.num_special_tokens_to_add(pair=False)
|
||||||
|
pair_budget = MAX_LENGTH - tokenizer.num_special_tokens_to_add(pair=True)
|
||||||
|
if pair_budget != HEAD_TOKENS + TAIL_TOKENS:
|
||||||
|
raise DataError(
|
||||||
|
"purpose-lite tokenizer special-token layout changed; expected three "
|
||||||
|
"tokens for head-tail inputs"
|
||||||
|
)
|
||||||
|
|
||||||
|
for content in raw["input_ids"]:
|
||||||
|
if len(content) <= single_budget:
|
||||||
|
first = content
|
||||||
|
second = None
|
||||||
|
else:
|
||||||
|
first = content[:HEAD_TOKENS]
|
||||||
|
second = content[-TAIL_TOKENS:]
|
||||||
|
if second is None:
|
||||||
|
input_ids = [tokenizer.cls_token_id] + first + [tokenizer.sep_token_id]
|
||||||
|
token_types = [0] * len(input_ids)
|
||||||
|
else:
|
||||||
|
input_ids = (
|
||||||
|
[tokenizer.cls_token_id]
|
||||||
|
+ first
|
||||||
|
+ [tokenizer.sep_token_id]
|
||||||
|
+ second
|
||||||
|
+ [tokenizer.sep_token_id]
|
||||||
|
)
|
||||||
|
token_types = [0] * (len(first) + 2) + [1] * (len(second) + 1)
|
||||||
|
if len(input_ids) > MAX_LENGTH:
|
||||||
|
raise DataError("fixed-shape tokenizer exceeded its 128-token contract")
|
||||||
|
padding = MAX_LENGTH - len(input_ids)
|
||||||
|
input_rows.append(input_ids + [tokenizer.pad_token_id] * padding)
|
||||||
|
mask_rows.append([1] * len(input_ids) + [0] * padding)
|
||||||
|
if include_token_types:
|
||||||
|
type_rows.append(token_types + [0] * padding)
|
||||||
|
|
||||||
|
encoded = {
|
||||||
|
"input_ids": torch.tensor(input_rows, dtype=torch.long),
|
||||||
|
"attention_mask": torch.tensor(mask_rows, dtype=torch.long),
|
||||||
|
}
|
||||||
|
if include_token_types:
|
||||||
|
encoded["token_type_ids"] = torch.tensor(type_rows, dtype=torch.long)
|
||||||
|
return encoded
|
||||||
|
|
||||||
|
|
||||||
def classification_metrics(
|
def classification_metrics(
|
||||||
actual: Sequence[int], predicted: Sequence[int]
|
actual: Sequence[int], predicted: Sequence[int]
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -267,6 +350,11 @@ def train(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
config.purpose_classifier_version = "purpose-lite-v1"
|
config.purpose_classifier_version = "purpose-lite-v1"
|
||||||
config.purpose_classifier_max_length = MAX_LENGTH
|
config.purpose_classifier_max_length = MAX_LENGTH
|
||||||
config.purpose_classifier_fixed_shape = [1, MAX_LENGTH]
|
config.purpose_classifier_fixed_shape = [1, MAX_LENGTH]
|
||||||
|
config.purpose_classifier_truncation = {
|
||||||
|
"strategy": "head-tail-pair",
|
||||||
|
"headTokens": HEAD_TOKENS,
|
||||||
|
"tailTokens": TAIL_TOKENS,
|
||||||
|
}
|
||||||
model.to(device)
|
model.to(device)
|
||||||
|
|
||||||
class PromptDataset(Dataset):
|
class PromptDataset(Dataset):
|
||||||
@@ -282,13 +370,7 @@ def train(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
|
|
||||||
def collate(items: Sequence[tuple[str, int]]) -> dict[str, Any]:
|
def collate(items: Sequence[tuple[str, int]]) -> dict[str, Any]:
|
||||||
texts, labels = zip(*items)
|
texts, labels = zip(*items)
|
||||||
encoded = tokenizer(
|
encoded = encode_fixed_shape(tokenizer, list(texts), torch)
|
||||||
list(texts),
|
|
||||||
padding="max_length",
|
|
||||||
truncation=True,
|
|
||||||
max_length=MAX_LENGTH,
|
|
||||||
return_tensors="pt",
|
|
||||||
)
|
|
||||||
encoded["labels"] = torch.tensor(labels, dtype=torch.long)
|
encoded["labels"] = torch.tensor(labels, dtype=torch.long)
|
||||||
return encoded
|
return encoded
|
||||||
|
|
||||||
@@ -311,6 +393,10 @@ def train(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
num_workers=args.workers,
|
num_workers=args.workers,
|
||||||
pin_memory=device.type == "cuda",
|
pin_memory=device.type == "cuda",
|
||||||
)
|
)
|
||||||
|
validation_scorable = torch.tensor(
|
||||||
|
[record.get("slice") != "vague-eval" for record in validation_records],
|
||||||
|
dtype=torch.bool,
|
||||||
|
)
|
||||||
|
|
||||||
optimizer = torch.optim.AdamW(
|
optimizer = torch.optim.AdamW(
|
||||||
model.parameters(), lr=args.learning_rate, weight_decay=args.weight_decay
|
model.parameters(), lr=args.learning_rate, weight_decay=args.weight_decay
|
||||||
@@ -350,7 +436,15 @@ def train(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
|
|
||||||
logits, labels = _evaluate(torch, model, validation_loader, device)
|
logits, labels = _evaluate(torch, model, validation_loader, device)
|
||||||
predictions = logits.argmax(dim=-1).tolist()
|
predictions = logits.argmax(dim=-1).tolist()
|
||||||
metrics = classification_metrics(labels.tolist(), predictions)
|
scored_labels = labels[validation_scorable].tolist()
|
||||||
|
scored_predictions = [
|
||||||
|
prediction
|
||||||
|
for prediction, scorable in zip(
|
||||||
|
predictions, validation_scorable.tolist()
|
||||||
|
)
|
||||||
|
if scorable
|
||||||
|
]
|
||||||
|
metrics = classification_metrics(scored_labels, scored_predictions)
|
||||||
metrics["epoch"] = epoch
|
metrics["epoch"] = epoch
|
||||||
metrics["meanTrainingLoss"] = running_loss / len(train_loader)
|
metrics["meanTrainingLoss"] = running_loss / len(train_loader)
|
||||||
history.append(metrics)
|
history.append(metrics)
|
||||||
@@ -367,15 +461,23 @@ def train(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
|
|
||||||
model = AutoModelForSequenceClassification.from_pretrained(best_dir).to(device)
|
model = AutoModelForSequenceClassification.from_pretrained(best_dir).to(device)
|
||||||
logits, labels = _evaluate(torch, model, validation_loader, device)
|
logits, labels = _evaluate(torch, model, validation_loader, device)
|
||||||
temperature = _fit_temperature(torch, logits, labels)
|
temperature = _fit_temperature(
|
||||||
|
torch,
|
||||||
|
logits[validation_scorable],
|
||||||
|
labels[validation_scorable],
|
||||||
|
)
|
||||||
calibrated = torch.softmax(logits / temperature, dim=-1)
|
calibrated = torch.softmax(logits / temperature, dim=-1)
|
||||||
top = torch.topk(calibrated, k=2, dim=-1)
|
top = torch.topk(calibrated, k=2, dim=-1)
|
||||||
top_probabilities = top.values[:, 0].tolist()
|
top_probabilities = top.values[:, 0].tolist()
|
||||||
margins = (top.values[:, 0] - top.values[:, 1]).tolist()
|
margins = (top.values[:, 0] - top.values[:, 1]).tolist()
|
||||||
predictions = top.indices[:, 0].tolist()
|
predictions = top.indices[:, 0].tolist()
|
||||||
correct = [
|
correct = [
|
||||||
prediction == actual
|
prediction == actual and scorable
|
||||||
for prediction, actual in zip(predictions, labels.tolist())
|
for prediction, actual, scorable in zip(
|
||||||
|
predictions,
|
||||||
|
labels.tolist(),
|
||||||
|
validation_scorable.tolist(),
|
||||||
|
)
|
||||||
]
|
]
|
||||||
thresholds = choose_confidence_thresholds(
|
thresholds = choose_confidence_thresholds(
|
||||||
top_probabilities,
|
top_probabilities,
|
||||||
@@ -397,12 +499,30 @@ def train(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
"baseModel": args.model,
|
"baseModel": args.model,
|
||||||
"baseModelRevision": args.model_revision,
|
"baseModelRevision": args.model_revision,
|
||||||
"fixedInputShape": [1, MAX_LENGTH],
|
"fixedInputShape": [1, MAX_LENGTH],
|
||||||
|
"truncation": {
|
||||||
|
"strategy": "head-tail-pair",
|
||||||
|
"headTokens": HEAD_TOKENS,
|
||||||
|
"tailTokens": TAIL_TOKENS,
|
||||||
|
},
|
||||||
"device": str(device),
|
"device": str(device),
|
||||||
"trainingSeconds": time.perf_counter() - started,
|
"trainingSeconds": time.perf_counter() - started,
|
||||||
"trainRecords": len(train_records),
|
"trainRecords": len(train_records),
|
||||||
"validationRecords": len(validation_records),
|
"validationRecords": len(validation_records),
|
||||||
|
"scoredValidationRecords": int(validation_scorable.sum().item()),
|
||||||
|
"vagueAbstentionValidationRecords": int(
|
||||||
|
(~validation_scorable).sum().item()
|
||||||
|
),
|
||||||
"bestValidationAccuracy": best_accuracy,
|
"bestValidationAccuracy": best_accuracy,
|
||||||
"bestValidation": classification_metrics(labels.tolist(), predictions),
|
"bestValidation": classification_metrics(
|
||||||
|
labels[validation_scorable].tolist(),
|
||||||
|
[
|
||||||
|
prediction
|
||||||
|
for prediction, scorable in zip(
|
||||||
|
predictions, validation_scorable.tolist()
|
||||||
|
)
|
||||||
|
if scorable
|
||||||
|
],
|
||||||
|
),
|
||||||
"history": history,
|
"history": history,
|
||||||
"calibration": calibration,
|
"calibration": calibration,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user