Spaces:
Running
Running
| # Validation | |
| Registry-backed segment validation. Single source of truth for category metadata: `inspector/services/validation/registry.py`, hand-mirrored to `inspector/frontend/src/tabs/segments/domain/registry.ts`. Parity asserted by `inspector/frontend/src/__tests__/registry/parity.test.ts`. | |
| Two orderings exist and are distinct: | |
| - `accordion_order` (registry field) β UI render order in `ValidationPanel.svelte`. | |
| - dict insertion order in `_REGISTRY` β drives `PER_SEGMENT_CATEGORIES` / `_flags_to_categories` / `category_counts` emission. **These two orders are NOT the same** (e.g. `low_confidence_v2` is insertion-pos 6 but `accordion_order=6` while `audio_bleeding`/`boundary_adj`/`repetitions` carry `accordion_order` 7/8/9 yet sit at insertion positions 8/9/7). When adding a category, set BOTH and keep registry.py β registry.ts byte-aligned. | |
| ## Categories | |
| Accordion order (registry `accordion_order`). `card_type` = FE card subcomponent dispatch. | |
| | order | kind | severity | scope | detection | where | card_type | key module | | |
| |---|---|---|---|---|---|---|---| | |
| | 1 | `failed` | error | per_segment | empty `matched_ref` | server+client | generic | `classifier.py` / `detail.py` | | |
| | 2 | `missing_verses` | error | per_verse | verse in `word_counts` not covered by any seg key | server | missingVerses | `_structural.py::_check_structural_errors` | | |
| | 3 | `missing_words` | error | per_verse | word-index gap in verse coverage map | server | missingWords | `_missing.py::_build_missing_words` | | |
| | 4 | `structural_errors` | error | per_chapter | `t_from>=t_to`, `w_from<1`, `w_to<w_from`, time overlap | server | error | `_structural.py` | | |
| | 5 | `low_confidence` | warning | per_segment | `confidence < LOW_CONFIDENCE_THRESHOLD` (count); `< LOW_CONFIDENCE_DETAIL_THRESHOLD` (detail list, synthetic `low_confidence_detail`) | server | generic | `classifier.py::classify_flags` | | |
| | 6 | `low_confidence_v2` | warning | per_segment | `segment_uid β probe_failed_uids` (MFA tight-beam probe sidecar, `load_probe_v2`) | server | generic | `classifier.py` + `_build_detail_lists` | | |
| | 7 | `audio_bleeding` | warning | per_segment | by-ayah only: `not seg_belongs_to_entry(matched_ref, entry_ref)` | server | generic | `classifier.py` | | |
| | 8 | `boundary_adj` | warning | per_segment | persisted `is_boundary_adj` (fall-through `compute_is_boundary_adj`) | server | generic | `classifier.py::_check_boundary_adj` | | |
| | 9 | `repetitions` | warning | per_segment | `seg.wrap_word_ranges` truthy (`has_repeated_words` alone does NOT classify) | server | generic | `classifier.py` | | |
| | 10 | `cross_verse` | warning | per_segment | `s_ayah != e_ayah` | server | generic | `classifier.py` | | |
| | 11 | `qalqala` | info | per_segment | persisted `qalqala_letter` non-null (fall-through `compute_qalqala_letter`) | server | generic | `classifier.py` + `segments/qalqala.py` | | |
| | 12 | `muqattaat` | info | per_segment | `s_word==1 and (surah,s_ayah) β MUQATTAAT_VERSES` | server | generic | `classifier.py` | | |
| | 13 | `basmala_amin` | info | per_segment | per-chapter scan: first seg overlapping `1:1`, last overlapping `1:7`, + missed-Basmala augmentation gated on `len(deleted_basmala_chapters) >= MISSED_BASMALA_FLAG_MIN_DELETED` | server | generic | `detail.py::_build_detail_lists` | | |
| Detection runs server-side in the validate pass. The client classifies snapshots/issue records for display only (`utils/validation/classified-issues.ts`), it does not own primary detection. Parent-repo `validators/validate_segments.py` (pipeline, gitignored) references the same `kind` set β accordion + post-pipeline validator must stay aligned. | |
| Suppression: `is_suppressed_for(seg, cat)` = `is_ignored_for` (reads `seg.ignored_categories`, legacy `"_all"` marker, legacy `ignored=True` bool) OR `is_resolved_by_edit` (transient `seg._resolved_by_edit`, injected by the validate route from `build_resolved_by_edit_index`, stripped before return). `failed` is gated only on `is_ignored_for` (resolved-by-edit does not suppress errors). | |
| ## Server engine β `inspector/services/validation/` | |
| | File | Role | | |
| |---|---| | |
| | `__init__.py` | Orchestrator `validate_reciter_segments(reciter)`. `ThreadPoolExecutor(4)` fan-out of the 4 independent bucket reads (`load_detailed` / `_load_resolved_idx_cached` / `load_probe_v2` / `load_seg_verses`). `canonical=None` throughout (phonemizer out of runtime). Builds `category_counts` + per-category arrays + `split_group_index`. Re-exports registry symbols + classifier API. | | |
| | `registry.py` | `_REGISTRY` dict of frozen `IssueDefinition` rows. Derived tuples: `ALL/PER_SEGMENT/PER_VERSE/PER_CHAPTER/CAN_IGNORE/AUTO_SUPPRESS/PERSISTS_IGNORE_CATEGORIES`. `filter_persistent_ignores`, `registry_as_dict`. | | |
| | `classifier.py` | Per-segment classification β single source of truth. `classify_flags`/`classify_segment`/`classify_segment_full`/`classify_entry`. `is_ignored_for`/`is_resolved_by_edit`/`is_suppressed_for`. `compute_is_boundary_adj` (raw rule, no suppression) + `_check_boundary_adj` (persisted-field short-circuit + suppression). qalqala persisted-field short-circuit (local import of `compute_qalqala_letter`). | | |
| | `snapshot_classifier.py` | `classify_snapshot(snap)` β routes a loose SegSnapshot dict (history op-log shape) through `classify_segment`; no logic reimplemented. Used by save-flow history enrichment. | | |
| | `detail.py` | `_build_detail_lists` β single entry walk producing every per-category detail array + `verse_segments` coverage map + `sequence_gaps` + `basmala_amin` (per-chapter scan, missed-Basmala augmentation). `_compute_surah_offsets` / `_word_ord` (prefix-sum, O(1) ordinals). Each item carries `classified_issues`. Also identity helpers `resolve_segment_by_uid` / `resolve_segment_for_issue` / `filter_stale_issues`. | | |
| | `_missing.py` | `_build_missing_words` β verse coverage gaps β issue dicts with `auto_fix`/`auto_fix_up`/`auto_fix_down` targeting; merges `sequence_gaps`. | | |
| | `_structural.py` | `_check_structural_errors` β reads `segments.json` (verse-aggregated) via `load_seg_verses`. Returns `(errors, missing_verses, stats)`. | | |
| qalqala helper canonical path: `inspector/services/segments/qalqala.py::compute_qalqala_letter`. `services.qalqala` is an alias resolving to the same module (used by `classifier.py` fall-through and the retired perf report). | |
| ### Routes β `inspector/routes/segments/validation.py` (`/api/seg`, bp `seg_val`) | |
| | route | handler | notes | | |
| |---|---|---| | |
| | `GET /api/seg/validate/<reciter>` | `seg_validate` | full-reciter validate. Cached via `cache.get/set_seg_validate_cache`; invalidated on save (`invalidate_seg_caches`). `orjson_cached_response`. **No per-chapter param** (dropped β accordions are global). | | |
| | `GET /api/seg/stats/<reciter>` | `seg_stats` | `compute_stats`; cached `cache.get/set_seg_stats_cache`. | | |
| | `GET /api/seg/edit-history/<reciter>` | `seg_edit_history` | `load_edit_history`. | | |
| There is no separate `trigger-validation` route; `/validate` is the single classify entry point and the cache is the dedupe layer. | |
| ## Client β `inspector/frontend/src/tabs/segments/` | |
| `utils/validation/`: | |
| | File | Role | | |
| |---|---| | |
| | `classified-issues.ts` | Build/normalize per-segment issue records consumed by the UI from the response. | | |
| | `card-lead-seg.ts` | Lead-segment selection for a card. | | |
| | `conf-class.ts` | `getConfClass()` β CSS class from confidence score. | | |
| | `missing-verse-context.ts` | Surrounding-segment lookup for a missing-verse card. | | |
| | `refresh.ts` | Fetch `/api/seg/validate` β `setValidation`. | | |
| | `resolve-issue.ts` | Mark issue resolved (ignore / fix / split-resolved). | | |
| | `split-group.ts` | Group split-resolved issues into chains (consumes `split_group_index`). | | |
| | `stale.ts` | Stale-state predicates β when classification needs re-run. | | |
| `domain/registry.ts` β TS twin of `registry.py` (camelCase fields). Exports same derived category tuples + `filterPersistentIgnores` + `ERROR_CAT_LABELS`. | |
| Store: `stores/validation.ts` β `segValidation` writable (`SegValidateResponse | null`), `splitGroupIndex` derived, `accordionViewActive` derived from `valUiOpenCategory`, `setValidation`/`clearValidation`. | |
| Components: `components/validation/{ValidationPanel,ErrorCard,GenericIssueCard,MissingVersesCard,MissingWordsCard,WaslBoundary,AccordionGuideModal}.svelte`. `ValidationPanel` filters stale items by live uid (`filterStaleIssues`) before render and owns accordion order. | |
| ## Registry-pair invariant | |
| `registry.py` β `registry.ts` β parent `validators/validate_segments.py` must align. Adding/reordering a category: | |
| 1. Add row to `_REGISTRY` in `inspector/services/validation/registry.py` (set `accordion_order` AND mind dict insertion position). | |
| 2. Mirror row in `inspector/frontend/src/tabs/segments/domain/registry.ts` (camelCase). `parity.test.ts` fails otherwise. | |
| 3. Add detection hook: per-segment β `classify_flags` in `classifier.py`; cross-segment scan β `detail.py` / `_missing.py` / `_structural.py`. | |
| 4. Wire the count into `category_counts` + a response array key in `__init__.py::validate_reciter_segments`. | |
| 5. Add accordion position in `ValidationPanel.svelte` and a card subcomponent if `card_type` is new. | |
| 6. Update parent `validators/validate_segments.py` if it should also fire post-pipeline. | |
| `IssueDefinition` flags govern persistence/suppression: `can_ignore` (Ignore button), `auto_suppress` (edit writes cat into `ignored_categories` for per-segment scope), `persists_ignore` (survives save serialization β `filter_persistent_ignores` strips the rest before write). | |
| ## Persisted classifier fields | |
| Two fields stamped on every `detailed.json` segment, read at validate time to collapse per-validate CPU to a dict lookup. | |
| | field | type | source-of-truth helper | rule | | |
| |---|---|---|---| | |
| | `qalqala_letter` | `str \| None` | `services/segments/qalqala.py::compute_qalqala_letter` | last Arabic letter of `dk_text_for_ref(matched_ref)` if β `QALQALA_LETTERS` (Ω Ψ· Ψ¨ Ψ¬ Ψ―), else `None` | | |
| | `is_boundary_adj` | `bool` | `services/validation/classifier.py::compute_is_boundary_adj` | raw rule, NO suppression: one-word seg (`s_word==e_word`) outside muqattaΚΌat / single-word-verse / `STANDALONE_REFS` / `STANDALONE_WORDS` allow-lists | | |
| Stamped by (all call the same source-of-truth helper β byte-equivalent across writers): | |
| - **Save flow** β `services/segments/save.py::_stamp_persisted_classifier_fields(seg, single_word_verses)` on every mutated seg. `is_boundary_adj` computed structural-only (`canonical=None`). | |
| - **Extraction pipeline** β `.local/extraction` outputs (passes `canonical` to capture the phonemic side, where applicable). | |
| - **Backfill** β `scripts/backfills/backfill_qalqala_letter.py`, `scripts/backfills/backfill_boundary_adj.py`. | |
| Read path (`classifier.py`): | |
| - qalqala: `classify_flags` reads `seg["qalqala_letter"]` when the key is present; **legacy fall-through** computes `compute_qalqala_letter(seg)` (local import to avoid a load cycle). Same helper β identical value. | |
| - boundary_adj: `_check_boundary_adj` short-circuits on `is_suppressed_for` first, then returns persisted `seg["is_boundary_adj"]` when present, else falls through to `compute_is_boundary_adj`. Persisted value is the raw rule; suppression layers on top at read time. | |
| Text source: `compute_qalqala_letter` and `compute_is_boundary_adj` derive seg text from `dk_text_for_ref(matched_ref)` (Migration #5 dropped per-seg `matched_text`). The `seg.get("matched_text") or dk_text_for_ref(...)` fall-through is the back-compat path for any pre-Migration-5 seg still carrying `matched_text`. | |
| **Phonemizer is out of the validate runtime.** `compute_is_boundary_adj` accepts `canonical` for back-compat but ignores it (retired in Migration #5 with `phonemes_asr`); the rule is structural-only. `validate_reciter_segments` passes `canonical=None`. The only `quranic_phonemizer` consumer left is the offline `scripts/backfills/backfill_boundary_adj.py`; no `canonical_phonemes.pkl` loads during validate; eager-boot init removed from `app.py`. | |
| ## Snapshot lifecycle | |
| Save flow (`services/segments/save.py`) routes history op-log snapshots through `classify_snapshot` (β `classify_segment`), stamping `classified_issues` on each `targets_before` / `targets_after` and `snapshots.{before,after}` dict (via `_history_visible_categories`, with `probe_failed_uids` so v2 flags appear in the delta). `snapshot_classifier.py` is distinct from the live pass only in that it derives positional inputs from the bare snapshot and skips heavy detail rendering β no separate classification logic. | |
| The before/after category delta on each op record drives the per-batch resolved-issue stats in the edit-history view and the validation-summary line in the save-preview surface. | |
| `failed` resolution: once a ref is committed the seg's confidence becomes 1.0 and the empty-`matched_ref` condition no longer holds, so the category auto-drops on the next validate. | |
| ## Bench / drift harness | |
| `bench/` (`snapshot.py` / `drift.py` / `measure.py`) + committed canonical snapshots `bench/ground_truth/<slug>.json` are the drift gate for any change to a perf-sensitive validation path. The gate asserts **byte-equivalent per-category output** against the ground-truth snapshot across the WIP reciter set. Any change to compute placement, caching, parallelism, or a persisted-field writer must pass drift before landing. | |
| Backfill scripts use **parallel-then-promote**: write to a staging path (`archive/backfill/<slug>/detailed.json`), drift-check in-memory against `bench/ground_truth/<slug>.json`, then atomically promote to `reciters/<slug>/detailed.json` only on byte-equivalent match. All persisted-field writers (save / extraction / backfill / classifier fall-through) must produce identical values β drift check is the only guarantee of that. | |
| (The `bench/` tree and parent-repo `validators/` are gitignored / live outside the inspector working tree; paths above are canonical for when the harness is checked out.) | |