From 781b9ac71b90b348c54bec06c6cd102495e4344a Mon Sep 17 00:00:00 2001 From: andy-zen-dev Date: Sat, 10 Oct 2026 14:18:08 +0300 Subject: [PATCH 1/3] feat: integrate Scriptorium Reader 0.2 preview Refs #273, #267, #269, #271. Integrate the exact reviewed implementations from #268 (50fbb9b), #270 (752f3c4) and #272 (610dc80) into one master-based delivery. Preserve the canonical public API and all historical artifacts. Add installable analyze/diagnostic/site commands, source-free CSV, reader labels, numeric fidelity, bounded input and private CLI errors. Keep the Pages deployment interlock and scientific acceptance gates unchanged. Add focused regressions and full-repository installed-package CI. No same-run merge acceptance; old PRs remain until this integration passes. --- .github/workflows/reader-package.yml | 51 ++ README.md | 296 +++------- RESEARCH_INDEX.md | 251 +++++++++ docs/FROZEN_DIAGNOSTIC_REPORT.md | 70 +++ docs/FULL_WORK_SITE_REPORT.md | 48 ++ docs/LOCAL_ANALYSIS.md | 78 +++ docs/READER_PREVIEW.md | 79 +++ .../CHANGELOG.d/2026-10-06-scrip-site-007.md | 15 + .../2026-10-07-scrip-metric-003.md | 13 + .../CHANGELOG.d/2026-10-07-scrip-site-008.md | 17 + .../CHANGELOG.d/2026-10-10-scrip-site-010.md | 27 + project/STATE_AND_QUEUE.md | 50 +- pyproject.toml | 20 + scriptorium/__main__.py | 5 + scriptorium/analyze.py | 510 ++++++++++++++++++ scriptorium/cli.py | 65 +++ scriptorium/csv_safety.py | 32 ++ scriptorium/frozen_diagnostic_report.py | 467 ++++++++++++++++ scriptorium/metric_labels.py | 31 ++ scriptorium/site_frozen_diagnostic.py | 73 +++ scriptorium/site_renderer.py | 68 ++- site/publication-manifest.json | 13 + tests/test_analyze.py | 105 ++++ tests/test_analyze_csv.py | 149 +++++ tests/test_analyze_human_labels.py | 80 +++ tests/test_analyze_precision.py | 107 ++++ tests/test_analyze_safety.py | 172 ++++++ tests/test_cli_error_privacy.py | 107 ++++ tests/test_frozen_diagnostic_report.py | 271 ++++++++++ tests/test_frozen_diagnostic_units.py | 57 ++ tests/test_local_analysis_input_limits.py | 120 +++++ tests/test_reader_cli.py | 108 ++++ tests/test_site_contract.py | 12 +- tests/test_site_frozen_diagnostic.py | 229 ++++++++ tools/reader_package_smoke.py | 45 ++ 35 files changed, 3593 insertions(+), 248 deletions(-) create mode 100644 .github/workflows/reader-package.yml create mode 100644 RESEARCH_INDEX.md create mode 100644 docs/FROZEN_DIAGNOSTIC_REPORT.md create mode 100644 docs/FULL_WORK_SITE_REPORT.md create mode 100644 docs/LOCAL_ANALYSIS.md create mode 100644 docs/READER_PREVIEW.md create mode 100644 project/CHANGELOG.d/2026-10-06-scrip-site-007.md create mode 100644 project/CHANGELOG.d/2026-10-07-scrip-metric-003.md create mode 100644 project/CHANGELOG.d/2026-10-07-scrip-site-008.md create mode 100644 project/CHANGELOG.d/2026-10-10-scrip-site-010.md create mode 100644 pyproject.toml create mode 100644 scriptorium/__main__.py create mode 100644 scriptorium/analyze.py create mode 100644 scriptorium/cli.py create mode 100644 scriptorium/csv_safety.py create mode 100644 scriptorium/frozen_diagnostic_report.py create mode 100644 scriptorium/metric_labels.py create mode 100644 scriptorium/site_frozen_diagnostic.py create mode 100644 tests/test_analyze.py create mode 100644 tests/test_analyze_csv.py create mode 100644 tests/test_analyze_human_labels.py create mode 100644 tests/test_analyze_precision.py create mode 100644 tests/test_analyze_safety.py create mode 100644 tests/test_cli_error_privacy.py create mode 100644 tests/test_frozen_diagnostic_report.py create mode 100644 tests/test_frozen_diagnostic_units.py create mode 100644 tests/test_local_analysis_input_limits.py create mode 100644 tests/test_reader_cli.py create mode 100644 tests/test_site_frozen_diagnostic.py create mode 100644 tools/reader_package_smoke.py diff --git a/.github/workflows/reader-package.yml b/.github/workflows/reader-package.yml new file mode 100644 index 00000000..90e95c02 --- /dev/null +++ b/.github/workflows/reader-package.yml @@ -0,0 +1,51 @@ +name: Scriptorium Reader package + +on: + pull_request: + paths: + - "pyproject.toml" + - "scriptorium/**" + - "tests/**" + - "tools/reader_package_smoke.py" + - ".github/workflows/reader-package.yml" + push: + branches: [master] + paths: + - "pyproject.toml" + - "scriptorium/**" + - "tests/**" + - "tools/reader_package_smoke.py" + - ".github/workflows/reader-package.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + installed-reader: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.13" + - name: Run complete canonical suite + run: python -m unittest discover -s tests -v + - name: Build and install reader wheel + shell: bash + run: | + set -euo pipefail + python -m pip wheel --no-deps . --wheel-dir dist + python -m venv "$RUNNER_TEMP/reader-venv" + "$RUNNER_TEMP/reader-venv/bin/python" -m pip install --no-index --no-deps dist/*.whl + - name: Exercise installed package outside checkout + shell: bash + run: | + set -euo pipefail + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/reader-venv/bin/python" -I "$GITHUB_WORKSPACE/tools/reader_package_smoke.py" "$GITHUB_WORKSPACE" + "$RUNNER_TEMP/reader-venv/bin/scriptorium" --help diff --git a/README.md b/README.md index 4cec5ff6..122add30 100644 --- a/README.md +++ b/README.md @@ -1,233 +1,87 @@ -# Scriptorium - -Scriptorium is an open-source toolkit for quantitative analysis of literary text. - -The first milestone is a reproducible implementation of the public parts of the -FantLab linguistic analyzer, benchmarked field-by-field against published FantLab -results. Later milestones add author-voice profiles, profile comparison, richer -literary metrics, book/tag profiles, corpus exploration, and a GitHub Pages browser. - -The project treats source provenance as part of the result. Calibration texts must be -legally usable and at least 300,000 characters including spaces; copyrighted text is -not committed merely because it is accessible online. Shorter texts and excerpts are -still valid analyzer inputs, but their results carry representativeness warnings. - -## What works today - -Scriptorium now has a standard-library-only deterministic analysis core: - -- versioned CRLF/CR → LF and Unicode NFC normalization; -- deterministic word candidates and sentence spans with normalized-text offsets; -- explicit dash-led dialogue paragraphs and candidate author-remark spans; -- first-wave character, word, mean word/sentence length, dialogue and punctuation metrics; -- a versioned punctuation-v2 policy that does not double-count ASCII hyphens retained - inside current word tokens as dash punctuation, while keeping the rule explicitly - inferred rather than claiming FantLab parity; -- dictionary-free unique-vocabulary counting plus explicit external-dictionary active - vocabulary and UASZ-3000/10000/100000 candidates; -- immutable dictionary dependency identity using profile + normalized lexeme-set SHA-256; -- a provider-neutral `scriptorium-pos-v1` aggregation layer for defined/undefined POS, - the 17 displayed FantLab buckets, a complete 17×17 POS-bigram matrix, and positions - 1..20 while preserving unresolved pylem categories and homonyms; -- an exact hash-pinned `pylem==0.0.18` compatibility sidecar verified on an isolated - Ubuntu 22.04 / Python 3.9 lane, with source-free transport into the modern Scriptorium - runtime and full-work frozen-candidate diagnostics; -- reviewed source-free morphology diagnostics that decompose undefined POS boundaries and - measure the pinned AOT literature homonym-weight signal without changing production - POS selection; -- versioned JSON artifacts/schemas with explicit `inferred` vs `extension` status; -- a local-text FantLab benchmark CLI that emits expected/actual/delta plus source hashes - and refuses to turn incomplete source provenance, an unproven dictionary, or unknown - decimal precision into parity; -- source-free exact-revision replay for the frozen 239-chapter Russian Wikisource - *Anna Karenina* candidate, with title/timestamp/MediaWiki-SHA-1 and composite-digest - verification before a full-work diagnostic can run; -- source-free exact-revision replay for the frozen 129-chapter Russian Wikisource - *Resurrection* candidate, with a versioned source-specific extraction contract for its - heterogeneous Wikisource page shapes and the same fail-closed source-match boundary; -- a versioned static-publication allow-list plus a deterministic, fail-closed static - renderer for derived/public work-showcase and provenance-only pages; -- golden tests for text boundaries, dialogue spans, metric formulas, vocabulary windows, - punctuation overlap, POS aggregation rules, source replay, publication safety and - benchmark gate behavior. - -The FantLab-shaped values are **inferred candidates**, not claimed reproduction. -See [`docs/TEXT_MODEL.md`](docs/TEXT_MODEL.md), -[`docs/DIALOGUE_MODEL.md`](docs/DIALOGUE_MODEL.md), -[`docs/VOCABULARY_MODEL.md`](docs/VOCABULARY_MODEL.md), -[`docs/POS_MODEL.md`](docs/POS_MODEL.md), -[`docs/METRIC_PROFILE.md`](docs/METRIC_PROFILE.md), -[`docs/BENCHMARKING.md`](docs/BENCHMARKING.md), -[`docs/SITE_CONTRACT.md`](docs/SITE_CONTRACT.md), and -[`docs/SITE_RENDERER.md`](docs/SITE_RENDERER.md). - -The POS aggregation artifact remains provider-neutral, while exact pinned pylem execution -is now verified through an isolated legacy sidecar. Runtime `N` remains unresolved because -pinned pylem collapses noun and cardinal to the same public POS string; cross-bucket -homonyms and extra AOT categories are not guessed into a bucket. Full-work diagnostics -preserve those boundaries rather than promoting provider signals into FantLab rules. See -[`docs/AOT_PYLEM_COMPATIBILITY.md`](docs/AOT_PYLEM_COMPATIBILITY.md) and -[`docs/POS_MODEL.md`](docs/POS_MODEL.md). - -### Benchmark a local text +# Scriptorium · Reader preview + +**Understand the language of a text without uploading the manuscript.** +Scriptorium measures vocabulary, sentence length, dialogue and punctuation, then produces +readable offline reports and reproducible data. This integrated **0.2.0a1 preview** brings +the reader site and the local analyzer into the canonical repository. + +## Try your own text + +With Python 3.13 or later, from this checkout: ```bash -python -m scriptorium.benchmark \ - --reference benchmarks/fantlab/work488.json \ - --text /path/to/local-text.txt \ - --scriptorium-revision \ - --edition-match unknown \ - --output comparison.json +python -m pip install . +scriptorium analyze manuscript.txt --format html --output report.html +scriptorium analyze manuscript.txt --output analysis.json +scriptorium analyze manuscript.txt --format csv --output metrics.csv ``` -An optional external dictionary can enable the dictionary-dependent vocabulary actuals: +Open `report.html` locally. All **29 metric rows** retain their units and algorithm +profiles; unavailable measurements stay unknown. The report has readable metric names, +a four-value overview, exact numeric values and keyboard-accessible tables. JSON and +CSV include source digests, not manuscript content. Choose a new output filename for +each run: existing files are never overwritten. + +No runtime dependencies, account, server or telemetry are required. Installation may +fetch build tools; see [offline wheel installation](docs/READER_PREVIEW.md). +`python -m scriptorium` exposes the same commands from a checkout. + +## Open a real literary result + +The repository already contains a source-free, historical full-work diagnostic for +**Anna Karenina**. It has **28 expected/actual/delta rows: 23 numeric results and five +unavailable values**. No novel download is needed: ```bash - --dictionary /path/to/dictionary.txt \ - --dictionary-profile my-dictionary-v1 +scriptorium diagnostic benchmarks/fantlab/work270306-wikisource-diagnostic.json \ + --title "Anna Karenina — full-work diagnostic" --output anna-report.html +scriptorium site --repo-root . --output build/site ``` -Supplying a dictionary makes that analysis reproducible, not FantLab-compatible. Until -FantLab's production dictionary identity/version is established, dictionary-dependent -benchmark rows remain diagnostic `unresolved` results even when their numbers happen to -match. - -The harness never fetches or commits the local text. `Шутиха` currently has no -source-matched legally usable full text in the project, so this reference remains a -diagnostic target rather than parity evidence. The harness currently maps 28 implemented -FantLab fields: four general, four dialogue, six vocabulary and fourteen punctuation -fields. POS aggregation has its own versioned artifact and hosted frozen-work pylem -transport, but the local benchmark CLI still does not silently instantiate the legacy -provider or claim FantLab morphology parity. Decimal fields remain unresolved until -FantLab display precision is independently established. See -[`docs/BENCHMARKING.md`](docs/BENCHMARKING.md). - -The repository also contains a full-work **diagnostic-only** comparison for *Anna -Karenina*. [`benchmarks/fantlab/work270306.json`](benchmarks/fantlab/work270306.json) -records FantLab's public 19 September 2022 values, while -[`benchmarks/fantlab/work270306-wikisource-diagnostic.json`](benchmarks/fantlab/work270306-wikisource-diagnostic.json) -records expected/actual/delta evidence from a replay of the 239 frozen public-domain -Wikisource revisions. The replayed source is bound to SHA-256 -`1dcf2af815f6288099f77a038d873690fb0dc72edf81d2094fd29f3d5a30c205`; novel prose is -not committed or uploaded. FantLab does not disclose the analyzer-input edition or -bytes, so every result remains source-unmatched diagnostic evidence and M2 parity stays -0/5. - -A second frozen public-domain candidate is now available for *Resurrection*. -[`corpus/candidates/source-edition-traces/tolstoy-resurrection-ru.json`](corpus/candidates/source-edition-traces/tolstoy-resurrection-ru.json) -records its provenance and fail-closed admissibility boundary, while the compact -[`tolstoy-resurrection-ru.revisions.json`](corpus/candidates/source-edition-traces/tolstoy-resurrection-ru.revisions.json) -pins all **129** chapter revision IDs, timestamps and MediaWiki SHA-1 identities without -storing source prose. Exact replay reconstructs a **890,835-character** composite bound -to raw and `scriptorium-text-v1` normalized SHA-256 -`2725a60a810d8aae4beff9dbc73ff85cf6da066b1c5272aaebf21addbe4ccaa0`. -FantLab displays 881,244 characters for its 2022 analysis, but does not disclose the -analyzer-input bytes or edition; this candidate is therefore eligible only for -**diagnostic-only** comparison and does not move the 0/5 source-matched reproduction gate. - -A source-free [word/dash policy sensitivity artifact](benchmarks/fantlab/work270306-policy-sensitivity.json) -records the investigation that led to the punctuation boundary without turning the -unmatched work into a tuning target. It showed that excluding numeric-only tokens removes -only 17 of the +16,083 word difference, and that 1,611 ASCII hyphens occur inside current -word tokens. Historical punctuation-v1 counted those glyphs as dash events too. The -current `scriptorium-punctuation-v2` candidate removes that double role: ASCII hyphens -retained inside `scriptorium-text-v1` word tokens are lexical connectors for punctuation -purposes and are not also dash events. The historical artifact remains v1 evidence rather -than being silently rewritten. - -This still does **not** establish FantLab's hyphen rule. The public methodology only says -punctuation frequencies are measured and the work surface labels its `-` row as `тире`; -FantLab does not publish the classifier. Even the historical sensitivity result after -excluding all token-internal ASCII hyphens remained about +14.913 dash events per 1000 -words above the source-unmatched FantLab result. Punctuation-v2 is therefore an internal -consistency improvement under a new profile, not a parity promotion. - -### Public showcase - -The repository includes source-free public slices for real public-domain literary -sources: - -- [`showcase/anna-karenina-part1-ch1-opening.json`](showcase/anna-karenina-part1-ch1-opening.json) - shows the first general/punctuation metrics on the opening four prose paragraphs of - *Anna Karenina*, Part I, Chapter I. -- [`showcase/anna-karenina-part1-ch2-dialogue.json`](showcase/anna-karenina-part1-ch2-dialogue.json) - shows the inferred dialogue profile on two dash-led dialogue paragraphs from Chapter II. -- [`public-artifacts/tolstoy-anna-karenina-ru-morphology-diagnostic.json`](public-artifacts/tolstoy-anna-karenina-ru-morphology-diagnostic.json) - publishes the reviewed full-work morphology diagnostic for the frozen *Anna Karenina* - candidate: 269,358 current Scriptorium word tokens, 117,554 conservatively defined POS, - 151,804 undefined POS, 59,254 direct cross-bucket ambiguities, and only 5,972 ambiguity - rows with a unique maximum under the pinned AOT literature homonym-weight signal. The - artifact is source-free, production POS resolution is unchanged, FantLab source identity - remains unknown, and the page is diagnostic-only rather than parity evidence. -- [`public-artifacts/tolstoy-resurrection-ru-provenance.json`](public-artifacts/tolstoy-resurrection-ru-provenance.json) - exposes the full frozen *Resurrection* source identity: 129 pinned revisions, - extraction/composition profiles, immutable counts and SHA-256 identities, legal/source - provenance, and the explicit `unknown` FantLab source-edition boundary. It contains no - novel prose and makes no parity claim. - -The Anna Karenina excerpt artifacts are bound to exact Russian Wikisource revisions and -store provenance, hashes and derived metrics only. They are intentionally marked -**illustrative excerpts**, not corpus entries and not FantLab parity evidence. Existing -artifacts preserve the metric profile under which they were generated. The full-work -morphology page deliberately uses the existing provenance-first publication contract: it -makes the frozen source identity and source-match boundary visible while surfacing only a -reviewed aggregate morphology summary in the evidence note. No token rows, runtime -candidate rows, source prose or recovered FantLab rule are published. The *Resurrection* -Pages slice is likewise provenance-first and keeps source-match and M2 parity fail-closed. - -The publication boundary is explicit rather than directory-based. -[`site/publication-manifest.json`](site/publication-manifest.json) allow-lists public -artifacts and freezes stable slugs plus source-text/admissibility safety metadata under -[`scriptorium-publication-manifest-v1`](schemas/scriptorium-publication-manifest-v1.schema.json). -The static renderer consumes only that manifest and re-validates canonical evidence -before writing pages: +Open `anna-report.html` or `build/site/index.html`. The canonical site retains its four +earlier evidence pages and adds the full-work report with a working homepage action. +**A successful local build is not a live GitHub Pages deployment.** The existing Pages +activation interlock is unchanged; this preview does not enable publishing by itself. + +## What the results mean + +FantLab-shaped metrics are **inferred compatibility candidates**, not demonstrated +parity. Scriptorium-only features are **extensions**. The historical Anna diagnostic +has no established identity match to FantLab's input text. **M2 remains 0/5**; none of +M1–M5 is declared complete by this release. The AOT-lineage morphology target and +field-by-field parity criteria remain unchanged. + +Texts shorter than 300,000 characters including spaces are valid inputs with explicit +representativeness limits. That length is a corpus-admission floor, not a guarantee of +quality; legal provenance and source identity require separate evidence. Source books, +private manuscripts and dictionary contents are not included in reports or uploaded. +Hashes can identify known works; share derived reports deliberately. + +## Safety and reproducibility + +The reader CLI bounds manuscripts to 32 MiB and dictionaries to 8 MiB, rejects special +files and symlinks, and uses a create-only atomic writer. Operational errors do not echo +private paths or source text. CSV neutralizes formula-like **text** while preserving +numeric cells; spreadsheet import/save behavior is not universally guaranteed. ```bash -python -m scriptorium.site_renderer --output build/site +python -m unittest discover -s tests -v +python -m pip wheel --no-deps . --wheel-dir dist ``` -Generated pages include a root index, stable `/works//` views, derived metric or -frozen-provenance evidence labels and selected provenance metadata. Artifact-controlled -text is HTML-escaped, provenance links must be HTTP(S), and source selection prose is not -copied into the output. The renderer validates the whole build before replacing the -disposable output tree and emits `build.json` with exact input digests for reproducibility. -See [`docs/SITE_RENDERER.md`](docs/SITE_RENDERER.md). - -The reviewed GitHub Actions Pages pipeline now runs tests, builds the canonical static -site twice, requires a byte-identical rebuild and uploads only the disposable generated -site tree on relevant pull requests and `master` changes. Publication-source provenance -changes also trigger the build, preventing a source trace from drifting away from a -published derived artifact without CI noticing. **Live GitHub Pages deployment is still -disabled**: deployment requires the explicit repository variable -`SCRIPTORIUM_PAGES_DEPLOY_ENABLED=true`, and repository Pages activation/settings remain -a separate owner/admin effect. See [`docs/SITE_CONTRACT.md`](docs/SITE_CONTRACT.md). - -## Project state - -This is an hourly autonomous project operated with an EndlessZen-derived work cycle. -The durable project contract lives in: - -- [`project/PROJECT_MANIFEST.md`](project/PROJECT_MANIFEST.md) -- [`project/STATE_AND_QUEUE.md`](project/STATE_AND_QUEUE.md) -- [`project/CHANGELOG.md`](project/CHANGELOG.md) -- [`AGENTS.md`](AGENTS.md) - -The first public numeric reference is FantLab's analysis of Henry Lion Oldie's -`Шутиха`; its published values are recorded in -[`benchmarks/fantlab/work488.json`](benchmarks/fantlab/work488.json). No novel text is -stored with that reference. The frozen *Anna Karenina* diagnostic adds the first -full-work expected/actual comparison without promoting a source-unmatched candidate to -parity; frozen *Resurrection* adds a second reproducible full-work source identity ready -for the same diagnostic-only discipline. - -## Methodology status - -FantLab publicly describes sentence/dialogue metrics, vocabulary windows, POS -statistics and bigrams, punctuation/character features, and the weighted construction -of author profiles. It also says some corrective coefficients and implementation -know-how are unpublished. Scriptorium therefore distinguishes benchmarked -**reproduced** behavior from evidence-based **inferred** behavior and Scriptorium-only -**extensions**. - -See [`docs/RESEARCH_EVIDENCE.md`](docs/RESEARCH_EVIDENCE.md) and -[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). +The Reader package workflow tests the installed wheel outside the checkout; the existing +Pages workflow runs the complete canonical suite and a byte-identical site rebuild. +The full Python public API, morphology modules, benchmark data and corpus records remain +in place. No external package-registry release is performed by CI. + +## Documentation + +[Reader installation and commands](docs/READER_PREVIEW.md) · +[Local analysis and dictionaries](docs/LOCAL_ANALYSIS.md) · +[Frozen diagnostic methodology](docs/FROZEN_DIAGNOSTIC_REPORT.md) · +[Canonical site and publication boundary](docs/FULL_WORK_SITE_REPORT.md) + +The previous detailed README is preserved as [Research and evidence index](RESEARCH_INDEX.md). +It retains the project history, corpus investigations and original reference links. +The remaining unpublished EPUB/batch Workbench prototypes are **not** advertised as +features of this integrated preview; they need focused adoption and repository tests. diff --git a/RESEARCH_INDEX.md b/RESEARCH_INDEX.md new file mode 100644 index 00000000..ab95f4c3 --- /dev/null +++ b/RESEARCH_INDEX.md @@ -0,0 +1,251 @@ +# Scriptorium + +Scriptorium is an open-source toolkit for quantitative analysis of literary text. + +The first milestone is a reproducible implementation of the public parts of the +FantLab linguistic analyzer, benchmarked field-by-field against published FantLab +results. Later milestones add author-voice profiles, profile comparison, richer +literary metrics, book/tag profiles, corpus exploration, and a GitHub Pages browser. + +The project treats source provenance as part of the result. Calibration texts must be +legally usable and at least 300,000 characters including spaces; copyrighted text is +not committed merely because it is accessible online. Shorter texts and excerpts are +still valid analyzer inputs, but their results carry representativeness warnings. + +## Open a real diagnostic report + +From a checkout with Python 3.13, generate a readable offline report from the existing +full-work Anna Karenina comparison — no novel download is needed: + +```bash +python -m scriptorium.frozen_diagnostic_report \ + benchmarks/fantlab/work270306-wikisource-diagnostic.json \ + --title "Anna Karenina — full-work diagnostic" \ + --output anna-diagnostic.html +``` + +Open `anna-diagnostic.html` in a browser. It shows 28 historical expected/actual/delta +rows, explicit units and missing values, source hashes and diagnostic-only evidence +labels. This is a presentation of existing results, not a new analyzer run or FantLab +parity. Output is create-only; use a new filename on repeat runs. See +[`docs/FROZEN_DIAGNOSTIC_REPORT.md`](docs/FROZEN_DIAGNOSTIC_REPORT.md). + +## What works today + +Scriptorium now has a standard-library-only deterministic analysis core: + +- versioned CRLF/CR → LF and Unicode NFC normalization; +- deterministic word candidates and sentence spans with normalized-text offsets; +- explicit dash-led dialogue paragraphs and candidate author-remark spans; +- first-wave character, word, mean word/sentence length, dialogue and punctuation metrics; +- a versioned punctuation-v2 policy that does not double-count ASCII hyphens retained + inside current word tokens as dash punctuation, while keeping the rule explicitly + inferred rather than claiming FantLab parity; +- dictionary-free unique-vocabulary counting plus explicit external-dictionary active + vocabulary and UASZ-3000/10000/100000 candidates; +- immutable dictionary dependency identity using profile + normalized lexeme-set SHA-256; +- a provider-neutral `scriptorium-pos-v1` aggregation layer for defined/undefined POS, + the 17 displayed FantLab buckets, a complete 17×17 POS-bigram matrix, and positions + 1..20 while preserving unresolved pylem categories and homonyms; +- an exact hash-pinned `pylem==0.0.18` compatibility sidecar verified on an isolated + Ubuntu 22.04 / Python 3.9 lane, with source-free transport into the modern Scriptorium + runtime and full-work frozen-candidate diagnostics; +- reviewed source-free morphology diagnostics that decompose undefined POS boundaries and + measure the pinned AOT literature homonym-weight signal without changing production + POS selection; +- versioned JSON artifacts/schemas with explicit `inferred` vs `extension` status; +- a local-text FantLab benchmark CLI that emits expected/actual/delta plus source hashes + and refuses to turn incomplete source provenance, an unproven dictionary, or unknown + decimal precision into parity; +- source-free exact-revision replay for the frozen 239-chapter Russian Wikisource + *Anna Karenina* candidate, with title/timestamp/MediaWiki-SHA-1 and composite-digest + verification before a full-work diagnostic can run; +- source-free exact-revision replay for the frozen 129-chapter Russian Wikisource + *Resurrection* candidate, with a versioned source-specific extraction contract for its + heterogeneous Wikisource page shapes and the same fail-closed source-match boundary; +- a versioned static-publication allow-list plus a deterministic, fail-closed static + renderer for derived/public work-showcase and provenance-only pages; +- golden tests for text boundaries, dialogue spans, metric formulas, vocabulary windows, + punctuation overlap, POS aggregation rules, source replay, publication safety and + benchmark gate behavior. + +The FantLab-shaped values are **inferred candidates**, not claimed reproduction. +See [`docs/TEXT_MODEL.md`](docs/TEXT_MODEL.md), +[`docs/DIALOGUE_MODEL.md`](docs/DIALOGUE_MODEL.md), +[`docs/VOCABULARY_MODEL.md`](docs/VOCABULARY_MODEL.md), +[`docs/POS_MODEL.md`](docs/POS_MODEL.md), +[`docs/METRIC_PROFILE.md`](docs/METRIC_PROFILE.md), +[`docs/BENCHMARKING.md`](docs/BENCHMARKING.md), +[`docs/SITE_CONTRACT.md`](docs/SITE_CONTRACT.md), and +[`docs/SITE_RENDERER.md`](docs/SITE_RENDERER.md). + +The POS aggregation artifact remains provider-neutral, while exact pinned pylem execution +is now verified through an isolated legacy sidecar. Runtime `N` remains unresolved because +pinned pylem collapses noun and cardinal to the same public POS string; cross-bucket +homonyms and extra AOT categories are not guessed into a bucket. Full-work diagnostics +preserve those boundaries rather than promoting provider signals into FantLab rules. See +[`docs/AOT_PYLEM_COMPATIBILITY.md`](docs/AOT_PYLEM_COMPATIBILITY.md) and +[`docs/POS_MODEL.md`](docs/POS_MODEL.md). + +### Benchmark a local text + +```bash +python -m scriptorium.benchmark \ + --reference benchmarks/fantlab/work488.json \ + --text /path/to/local-text.txt \ + --scriptorium-revision \ + --edition-match unknown \ + --output comparison.json +``` + +An optional external dictionary can enable the dictionary-dependent vocabulary actuals: + +```bash + --dictionary /path/to/dictionary.txt \ + --dictionary-profile my-dictionary-v1 +``` + +Supplying a dictionary makes that analysis reproducible, not FantLab-compatible. Until +FantLab's production dictionary identity/version is established, dictionary-dependent +benchmark rows remain diagnostic `unresolved` results even when their numbers happen to +match. + +The harness never fetches or commits the local text. `Шутиха` currently has no +source-matched legally usable full text in the project, so this reference remains a +diagnostic target rather than parity evidence. The harness currently maps 28 implemented +FantLab fields: four general, four dialogue, six vocabulary and fourteen punctuation +fields. POS aggregation has its own versioned artifact and hosted frozen-work pylem +transport, but the local benchmark CLI still does not silently instantiate the legacy +provider or claim FantLab morphology parity. Decimal fields remain unresolved until +FantLab display precision is independently established. See +[`docs/BENCHMARKING.md`](docs/BENCHMARKING.md). + +The repository also contains a full-work **diagnostic-only** comparison for *Anna +Karenina*. [`benchmarks/fantlab/work270306.json`](benchmarks/fantlab/work270306.json) +records FantLab's public 19 September 2022 values, while +[`benchmarks/fantlab/work270306-wikisource-diagnostic.json`](benchmarks/fantlab/work270306-wikisource-diagnostic.json) +records expected/actual/delta evidence from a replay of the 239 frozen public-domain +Wikisource revisions. The replayed source is bound to SHA-256 +`1dcf2af815f6288099f77a038d873690fb0dc72edf81d2094fd29f3d5a30c205`; novel prose is +not committed or uploaded. FantLab does not disclose the analyzer-input edition or +bytes, so every result remains source-unmatched diagnostic evidence and M2 parity stays +0/5. + +A second frozen public-domain candidate is now available for *Resurrection*. +[`corpus/candidates/source-edition-traces/tolstoy-resurrection-ru.json`](corpus/candidates/source-edition-traces/tolstoy-resurrection-ru.json) +records its provenance and fail-closed admissibility boundary, while the compact +[`tolstoy-resurrection-ru.revisions.json`](corpus/candidates/source-edition-traces/tolstoy-resurrection-ru.revisions.json) +pins all **129** chapter revision IDs, timestamps and MediaWiki SHA-1 identities without +storing source prose. Exact replay reconstructs a **890,835-character** composite bound +to raw and `scriptorium-text-v1` normalized SHA-256 +`2725a60a810d8aae4beff9dbc73ff85cf6da066b1c5272aaebf21addbe4ccaa0`. +FantLab displays 881,244 characters for its 2022 analysis, but does not disclose the +analyzer-input bytes or edition; this candidate is therefore eligible only for +**diagnostic-only** comparison and does not move the 0/5 source-matched reproduction gate. + +A source-free [word/dash policy sensitivity artifact](benchmarks/fantlab/work270306-policy-sensitivity.json) +records the investigation that led to the punctuation boundary without turning the +unmatched work into a tuning target. It showed that excluding numeric-only tokens removes +only 17 of the +16,083 word difference, and that 1,611 ASCII hyphens occur inside current +word tokens. Historical punctuation-v1 counted those glyphs as dash events too. The +current `scriptorium-punctuation-v2` candidate removes that double role: ASCII hyphens +retained inside `scriptorium-text-v1` word tokens are lexical connectors for punctuation +purposes and are not also dash events. The historical artifact remains v1 evidence rather +than being silently rewritten. + +This still does **not** establish FantLab's hyphen rule. The public methodology only says +punctuation frequencies are measured and the work surface labels its `-` row as `тире`; +FantLab does not publish the classifier. Even the historical sensitivity result after +excluding all token-internal ASCII hyphens remained about +14.913 dash events per 1000 +words above the source-unmatched FantLab result. Punctuation-v2 is therefore an internal +consistency improvement under a new profile, not a parity promotion. + +### Public showcase + +The repository includes source-free public slices for real public-domain literary +sources: + +- [`showcase/anna-karenina-part1-ch1-opening.json`](showcase/anna-karenina-part1-ch1-opening.json) + shows the first general/punctuation metrics on the opening four prose paragraphs of + *Anna Karenina*, Part I, Chapter I. +- [`showcase/anna-karenina-part1-ch2-dialogue.json`](showcase/anna-karenina-part1-ch2-dialogue.json) + shows the inferred dialogue profile on two dash-led dialogue paragraphs from Chapter II. +- [`public-artifacts/tolstoy-anna-karenina-ru-morphology-diagnostic.json`](public-artifacts/tolstoy-anna-karenina-ru-morphology-diagnostic.json) + publishes the reviewed full-work morphology diagnostic for the frozen *Anna Karenina* + candidate: 269,358 current Scriptorium word tokens, 117,554 conservatively defined POS, + 151,804 undefined POS, 59,254 direct cross-bucket ambiguities, and only 5,972 ambiguity + rows with a unique maximum under the pinned AOT literature homonym-weight signal. The + artifact is source-free, production POS resolution is unchanged, FantLab source identity + remains unknown, and the page is diagnostic-only rather than parity evidence. +- [`public-artifacts/tolstoy-resurrection-ru-provenance.json`](public-artifacts/tolstoy-resurrection-ru-provenance.json) + exposes the full frozen *Resurrection* source identity: 129 pinned revisions, + extraction/composition profiles, immutable counts and SHA-256 identities, legal/source + provenance, and the explicit `unknown` FantLab source-edition boundary. It contains no + novel prose and makes no parity claim. + +The Anna Karenina excerpt artifacts are bound to exact Russian Wikisource revisions and +store provenance, hashes and derived metrics only. They are intentionally marked +**illustrative excerpts**, not corpus entries and not FantLab parity evidence. Existing +artifacts preserve the metric profile under which they were generated. The full-work +morphology page deliberately uses the existing provenance-first publication contract: it +makes the frozen source identity and source-match boundary visible while surfacing only a +reviewed aggregate morphology summary in the evidence note. No token rows, runtime +candidate rows, source prose or recovered FantLab rule are published. The *Resurrection* +Pages slice is likewise provenance-first and keeps source-match and M2 parity fail-closed. + +The publication boundary is explicit rather than directory-based. +[`site/publication-manifest.json`](site/publication-manifest.json) allow-lists public +artifacts and freezes stable slugs plus source-text/admissibility safety metadata under +[`scriptorium-publication-manifest-v1`](schemas/scriptorium-publication-manifest-v1.schema.json). +The static renderer consumes only that manifest and re-validates canonical evidence +before writing pages: + +```bash +python -m scriptorium.site_renderer --output build/site +``` + +Generated pages include a root index, stable `/works//` views, derived metric or +frozen-provenance evidence labels and selected provenance metadata. Artifact-controlled +text is HTML-escaped, provenance links must be HTTP(S), and source selection prose is not +copied into the output. The renderer validates the whole build before replacing the +disposable output tree and emits `build.json` with exact input digests for reproducibility. +See [`docs/SITE_RENDERER.md`](docs/SITE_RENDERER.md). + +The reviewed GitHub Actions Pages pipeline now runs tests, builds the canonical static +site twice, requires a byte-identical rebuild and uploads only the disposable generated +site tree on relevant pull requests and `master` changes. Publication-source provenance +changes also trigger the build, preventing a source trace from drifting away from a +published derived artifact without CI noticing. **Live GitHub Pages deployment is still +disabled**: deployment requires the explicit repository variable +`SCRIPTORIUM_PAGES_DEPLOY_ENABLED=true`, and repository Pages activation/settings remain +a separate owner/admin effect. See [`docs/SITE_CONTRACT.md`](docs/SITE_CONTRACT.md). + +## Project state + +This is an hourly autonomous project operated with an EndlessZen-derived work cycle. +The durable project contract lives in: + +- [`project/PROJECT_MANIFEST.md`](project/PROJECT_MANIFEST.md) +- [`project/STATE_AND_QUEUE.md`](project/STATE_AND_QUEUE.md) +- [`project/CHANGELOG.md`](project/CHANGELOG.md) +- [`AGENTS.md`](AGENTS.md) + +The first public numeric reference is FantLab's analysis of Henry Lion Oldie's +`Шутиха`; its published values are recorded in +[`benchmarks/fantlab/work488.json`](benchmarks/fantlab/work488.json). No novel text is +stored with that reference. The frozen *Anna Karenina* diagnostic adds the first +full-work expected/actual comparison without promoting a source-unmatched candidate to +parity; frozen *Resurrection* adds a second reproducible full-work source identity ready +for the same diagnostic-only discipline. + +## Methodology status + +FantLab publicly describes sentence/dialogue metrics, vocabulary windows, POS +statistics and bigrams, punctuation/character features, and the weighted construction +of author profiles. It also says some corrective coefficients and implementation +know-how are unpublished. Scriptorium therefore distinguishes benchmarked +**reproduced** behavior from evidence-based **inferred** behavior and Scriptorium-only +**extensions**. + +See [`docs/RESEARCH_EVIDENCE.md`](docs/RESEARCH_EVIDENCE.md) and +[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). diff --git a/docs/FROZEN_DIAGNOSTIC_REPORT.md b/docs/FROZEN_DIAGNOSTIC_REPORT.md new file mode 100644 index 00000000..faead049 --- /dev/null +++ b/docs/FROZEN_DIAGNOSTIC_REPORT.md @@ -0,0 +1,70 @@ +# Read a frozen full-work diagnostic + +From a repository checkout with Python 3.13, turn the existing aggregate Anna Karenina +comparison into a standalone report without downloading the novel: + +```bash +python -m scriptorium.frozen_diagnostic_report \ + benchmarks/fantlab/work270306-wikisource-diagnostic.json \ + --title "Anna Karenina — full-work diagnostic" \ + --output anna-diagnostic.html +``` + +Open `anna-diagnostic.html` in a browser. It needs no network, JavaScript, remote fonts +or external assets. Omit `--output` to write HTML to standard output. The file option is +create-only: an existing output is never overwritten, including after invalid input. +Use a new filename for a second report. Without `--title`, the candidate ID is shown. + +## What the report contains + +The existing historical diagnostic has 28 rows: 23 numeric actuals and five missing +actuals, with 26 `unresolved` and two `not_run` results. Tables group general, dialogue, +vocabulary and punctuation metrics and show units, expected values, actual values and +raw deltas. Captions, row/column headers, a skip link, focus styles and keyboard-focusable +horizontal table regions keep the wide tables inspectable on small screens. + +Replay hashes, chapter count, analyzer revision, Python version and hosted-run identity +are retained. Omitted families and observations are explicitly historical: the report +is not a new replay and does not imply that an old provider limitation is current. + +## Scientific boundary + +This renderer accepts the reviewed `scriptorium-frozen-diagnostic-v1` shape only. Its +source edition is unknown; parity admissibility remains false. The displayed M2 `0/5` +is the value recorded in that historical artifact, not a live project-progress query. +Numeric resemblance, including a zero delta, is never promoted to pass/fail or a +similarity score. No analyzer formula, source body or historical JSON is modified. + +Display uses up to six decimal places and scientific notation for very small nonzero +values. This is readability formatting, **not** FantLab display rounding or a tolerance +change. Percentage deltas are percentage points; missing values are not zero. +Integer delta arithmetic is exact. The `1e-9` absolute consistency check for floating +raw deltas handles serialized arithmetic only; it cannot admit a parity result. + +## Validation and privacy + +The CLI rejects duplicate JSON keys, unexpected fields, invalid hashes/locators, +non-finite or out-of-range numbers, delta inconsistency, `not_run` with an actual value, +pass/fail promotion, source-text publication flags, and source-match/M2 upgrades. +All artifact-controlled strings are HTML-escaped. + +Inputs must already be curated source-free aggregate artifacts. This is not a manuscript +sanitizer: reviewed observations and reason strings are intentionally displayed, so a +producer must not put private source prose in those fields. The renderer never fetches +source text or uploads anything. Generated HTML stays local and is not committed. + +## Verification and publication + +```bash +python -m unittest discover -s tests -p 'test_frozen_diagnostic_report.py' -v +python -m unittest discover -s tests -v +``` + +The focused suite includes the exact canonical diagnostic blob and deterministic output, +large-integer precision, tiny deltas, escaping, invalid-input rejection and preservation +of an existing output. The existing Pages PR workflow runs the full repository suite and +a byte-identical static-site rebuild on the PR head. + +This unit supplies a standalone report, not a new live Pages route. Adding the report to +the publication allow-list and renderer is a subsequent integration; Pages activation +and the existing deployment interlock are unchanged. diff --git a/docs/FULL_WORK_SITE_REPORT.md b/docs/FULL_WORK_SITE_REPORT.md new file mode 100644 index 00000000..e101d22a --- /dev/null +++ b/docs/FULL_WORK_SITE_REPORT.md @@ -0,0 +1,48 @@ +# Full-work reports in the canonical static site + +`scriptorium-static-site-v2` extends the existing publication-manifest-v1 renderer with +one reviewed, source-free historical diagnostic. It also adds a reader-first homepage +with a real report action, explicit page types, shared keyboard focus/skip links and a +responsive card grid. Existing four routes and their scientific labels are retained. + +```bash +python -m scriptorium.site_renderer --repo-root . --output build/site +``` + +The generated homepage links to `works/anna-karenina-full-work-diagnostic/`. That page +reuses `frozen_diagnostic_report` from SCRIP-SITE-007: 28 historical fields, 23 numeric +actuals, five missing actuals, expected/actual/raw deltas, correct mean-length denominators +and replay identities. A backlink returns to the work list. No metric is recalculated. + +## Publication boundary + +The manifest must explicitly include the frozen report. Directory discovery is not +supported. `scriptorium/site_frozen_diagnostic.py` registers the exact existing JSON path, +entry ID, slug, publication/compatibility/source-text flags and SHA-256 of the reviewed +snapshot. The standard schema validator runs even after the byte digest matches; the +parsed object must agree with the verified bytes. This narrow registration intentionally +rejects unreviewed new snapshots, changed observations, altered labels and path aliases. +A new snapshot requires scientific/security review and an explicit registration change. + +The registration does not grant corpus admission or identify FantLab's input. The entry +remains diagnostic-only, inferred, with `corpus_admissibility=not_admissible`; the original +source-free JSON is not rewritten. M2 remains 0/5. The four existing artifacts keep their +own previously reviewed corpus and compatibility flags. + +All entries are validated and rendered before the existing disposable output is replaced. +A broken input, unapproved digest or report-integration mismatch leaves the previous +valid build intact. `build.json` records the exact consumed input hashes and five routes. +The existing workflow performs the full suite and a byte-identical canonical rebuild. + +## Acceptance and delivery + +Tests exercise the canonical five-entry manifest, all internal links/fragments, historical +row counts, source-free output, digest/flag tampering, missing/aliased files, output +preservation, title escaping and deterministic builds. Shared navigation/card/action links +have minimum 44px targets; tables are named, focusable scrolling regions with scoped +headers. The full report retains its own standalone stylesheet and historical labels. + +A successful build is not a live deployment. The existing Pages deployment variable, +repository configuration and later fresh PR acceptance gates are unchanged. No workflow +permissions or external services are added. Visual screenshots require an environment +where browser navigation is allowed; structural tests are not visual verification. diff --git a/docs/LOCAL_ANALYSIS.md b/docs/LOCAL_ANALYSIS.md new file mode 100644 index 00000000..c8e16436 --- /dev/null +++ b/docs/LOCAL_ANALYSIS.md @@ -0,0 +1,78 @@ +# Analyze a local text + +From the repository root with Python 3.13, run the existing deterministic engine through +`scriptorium.analyze`. No third-party packages or network access are required. + +```bash +python -m scriptorium.analyze manuscript.txt --output analysis.json +python -m scriptorium.analyze manuscript.txt --format html --title "My manuscript" --output report.html +``` + +JSON is the default; omitting `--output` writes the selected format to standard output. +The HTML file is self-contained and can be opened offline. Its tables include units, +compatibility labels, raw punctuation counts and keyboard-accessible scrolling regions. + +## Reproducibility and scientific limits + +The `scriptorium-local-analysis-v1` wrapper retains all 29 current engine rows without +changing their values or formulas. It records the metric contract, normalization/metric/ +dialogue/vocabulary/punctuation profiles, raw-byte and normalized-text SHA-256 identities, +input size and dictionary identity. `--scriptorium-revision ` records an explicit +caller-supplied revision; without it the revision is null, not guessed. The HTML exposes +profiles under **Analysis profiles**. + +Inputs must be UTF-8. CRLF/CR and Unicode composition use the existing normalization +profile. Empty inputs are supported: counts may be zero, but undefined means or rates +remain null. A null vocabulary metric can also mean a missing dictionary or an incomplete +UASZ window; HTML therefore says **Unavailable**, not a fabricated zero or pass. + +The 300,000-character threshold includes spaces. Shorter inputs are valid local analyses +with limited representativeness; meeting the length floor alone is not corpus admission, +proof of representativeness, an author profile or FantLab parity. FantLab-shaped rows +remain inferred; Scriptorium-only rows remain extensions. No morphology backend is +silently instantiated. + +## Optional external dictionary + +Supply a UTF-8 file with one lexeme per line and a non-empty profile together: + +```bash +python -m scriptorium.analyze manuscript.txt --dictionary dictionary.txt \ + --dictionary-profile my-dictionary-v1 --output with-dictionary.json +``` + +The engine performs its existing NFC/case-folded set normalization. A supplied dictionary +can enable active dictionary/nondictionary counts and complete-window UASZ values; short +inputs still lack complete larger windows. Results retain profile, normalized lexeme-set +SHA-256 and count, not dictionary contents. Reproducibility does not establish equivalence +to FantLab's undisclosed production dictionary. + +## Privacy and file safety + +Manuscripts and dictionary contents are not embedded in the returned aggregate artifacts, +sent to a service, or added to the corpus. Titles, dictionary profile labels and optional +revision metadata are caller-controlled and intentionally appear in reports. Hashes and +aggregate metrics can themselves identify a known work: treat generated reports as +private unless deliberately shared. There is no telemetry or external asset loading. + +Output is create-only, including existing files, hard-link aliases and dangling symlinks. +The writer stages derived output in the destination directory, flushes it, then uses an +atomic hard-link creation without replacement. The filesystem must support hard links; +otherwise the command reports an error rather than falling back to unsafe overwriting. +Handled write failures remove temporary files and leave previous output untouched. A +process/OS crash may leave a hidden `.scriptorium-*.tmp` derived-report file. On POSIX, +new reports have mode 0600. Parent directories are created as needed. + +## Library and checks + +```python +from pathlib import Path +from scriptorium.analyze import build_local_analysis, render_html + +artifact = build_local_analysis(Path("manuscript.txt").read_bytes()) +html = render_html(artifact, title="Local analysis") +``` + +Run focused checks with `python -m unittest discover -s tests -p 'test_analyze*.py' -v`. +Tests exercise the real engine, deterministic output, Unicode normalization, dictionary +limits, privacy, output aliases, failed publication and empty inputs. diff --git a/docs/READER_PREVIEW.md b/docs/READER_PREVIEW.md new file mode 100644 index 00000000..e2339ce0 --- /dev/null +++ b/docs/READER_PREVIEW.md @@ -0,0 +1,79 @@ +# Reader preview 0.2.0a1 + +Refs #273. This is a substantial integration preview, not completion of the scientific +v1 milestone. It combines #268, #270 and #272 with selected reader improvements from +local handoffs. It does not replace the canonical package with the minimal initializer +used by the old standalone Workbench. + +## Install and use + +Python >=3.13 is required. The distribution is `scriptorium-reader`; the import package +and console command remain `scriptorium`. Install into a dedicated virtual environment +so a previous experimental Workbench distribution cannot overwrite the same package. + +```bash +python -m venv .venv +# Activate .venv with the command appropriate to your shell. +python -m pip install . +scriptorium --version +scriptorium --help +``` + +To prepare an offline installation on another compatible machine: + +```bash +python -m pip wheel --no-deps . --wheel-dir dist +# Transfer the wheel; the destination does not need the source checkout to analyze text. +python -m pip install --no-index --no-deps dist/scriptorium_reader-0.2.0a1-py3-none-any.whl +``` + +Building may fetch setuptools/wheel. Runtime processing and installation of a prepared +wheel with `--no-index --no-deps` do not need a package index. No package is automatically +published to an external registry. + +## Three real commands + +- `analyze manuscript.txt --format json|html|csv --output report.ext` computes all 29 + current metric rows with the unchanged deterministic core. `--dictionary` and + `--dictionary-profile` must be supplied together. Titles/profile labels are intentionally + visible; do not put private data in them. +- `diagnostic artifact.json --title "A reviewed result" --output report.html` presents + the canonical frozen-diagnostic schema. It is not a fresh analyzer run. Input is + bounded to 8 MiB, duplicate JSON keys are rejected, and operational errors are path-free. + Use curated source-free artifacts: the renderer is not a manuscript sanitizer. +- `site --repo-root /path/to/Scriptorium --output build/site` runs the existing allow-list + renderer. Site data are not bundled into the wheel: point it at a repository checkout. + This maintenance/build command retains its existing repository-path diagnostics. + +The legacy module commands remain available. Prefer `scriptorium diagnostic` over the +legacy `python -m scriptorium.frozen_diagnostic_report` CLI when reading external files; +the new wrapper supplies bounded I/O without altering the historical renderer bytes. + +## Numeric and security contracts + +JSON values and all core algorithm versions are unchanged. HTML floats now use their +round-trip decimal representation. CSV v1 requires exactly the current 29 metric IDs, +nonempty units/definition evidence and explicit source/profile fields. Null numeric +values become empty cells. These serialization checks are not proof of authenticity or +FantLab parity. CSV text formula neutralization includes Unicode formatting prefixes; +spreadsheet-specific import and re-save behavior remains outside this guarantee. + +Manuscripts are limited to 32 MiB and dictionaries to 8 MiB. Byte limits are checked +before and after reading. POSIX nonblocking/no-follow flags prevent FIFO blocking and +final-component symlink swaps; equivalent race guarantees on other platforms are not +claimed. Parent directory symlinks are not sandboxed. New outputs use mode 0600 on +POSIX and atomic hard-link creation: a filesystem without hard-link support fails +rather than overwriting. Process/OS crashes can leave a hidden derived-output temporary +file. The limits bound input bytes, not total analyzer working memory. + +## Acceptance and adoption + +Run all canonical tests, not only a reconstructed subset. Reader CI builds the wheel, +installs it into a clean virtual environment, runs from outside the checkout, checks +the exported public API and exercises all three commands. Pages CI separately rebuilds +the actual five-route site twice. Neither CI success nor a PR is a live deployment. + +The new PR targets master directly. Old #268/#270/#272 are provenance/de-duplication +references and remain open until the integrated result is accepted. A later fresh +exact-head acceptance is required; no same-run merge is authorized. Preserve all +historical artifacts, AOT compatibility, scientific tolerances and corpus/rights gates. diff --git a/project/CHANGELOG.d/2026-10-06-scrip-site-007.md b/project/CHANGELOG.d/2026-10-06-scrip-site-007.md new file mode 100644 index 00000000..ac90704e --- /dev/null +++ b/project/CHANGELOG.d/2026-10-06-scrip-site-007.md @@ -0,0 +1,15 @@ +## 2026-10-06 — readable frozen diagnostic report (#267) + +- Adopt SCRIP-SITE-007 into a normal product PR: standalone offline HTML/CLI for the + existing source-free full-work diagnostic, with units, explicit missing values, + historical evidence labels, replay identity and keyboard-accessible tables. +- Reject malformed/overstated input, duplicate keys, non-finite values and delta drift; + preserve integer precision and tiny nonzero deltas; keep output create-only. +- Add 23 focused regressions, including the unchanged canonical Anna diagnostic blob. +- Reconcile the Oct 2 owner selection correction into the compact product backlog: + independent report/CLI/Pages work does not wait on corpus or bookkeeping blockers. +- SCRIP-SITE-008's old local patch-transform self-test is not integration evidence: its + dedented replacement produces an IndentationError on the renderer function shape. + Correct and verify it against actual repository files before adoption. +- No analyzer formulas, historical benchmark JSON, corpus gates, M2 tolerances or Pages + activation/settings are changed. New code still requires later exact-head acceptance. diff --git a/project/CHANGELOG.d/2026-10-07-scrip-metric-003.md b/project/CHANGELOG.d/2026-10-07-scrip-metric-003.md new file mode 100644 index 00000000..54b6ac49 --- /dev/null +++ b/project/CHANGELOG.d/2026-10-07-scrip-metric-003.md @@ -0,0 +1,13 @@ +# SCRIP-METRIC-003 — local text analysis + +Refs #269. Adopt the pending local-analysis implementation into the real metric engine: +UTF-8 input to deterministic JSON or readable offline HTML, optional explicit dictionary, +29 unchanged engine rows, source hashes/profiles, short-input and unavailable-value labels. + +Add 22 focused tests, including direct engine equality, actual CLI file output, dictionary +privacy, normalization identity, empty input, threshold boundaries, non-finite rejection, +create-only aliases and atomic publication failure. README and LOCAL_ANALYSIS document +working commands and report privacy. No source text, corpus admission, morphology +substitution, analyzer formula, parity gate or Pages activation changes. + +Full exact-head CI and later-run acceptance remain required before merge. diff --git a/project/CHANGELOG.d/2026-10-07-scrip-site-008.md b/project/CHANGELOG.d/2026-10-07-scrip-site-008.md new file mode 100644 index 00000000..b33758af --- /dev/null +++ b/project/CHANGELOG.d/2026-10-07-scrip-site-008.md @@ -0,0 +1,17 @@ +# SCRIP-SITE-008 — canonical full-work report route + +Refs #271; stacked on #268 at 50fbb9b9c0da59a5eb78c6826381b8acd3dd8aae. + +Static-site-v2 adds one digest-approved historical Anna Karenina full-work report to the +existing publication allow-list, with a real homepage action and backlink. Reuses the +standalone validator/renderer; 28 rows, 23 actuals and five missing actuals remain historical +source-unmatched evidence. The original four routes and source JSON bytes are preserved. + +Add 15 integration regressions, extend the canonical manifest safety test for the fifth +entry, and record source hashes in the existing build manifest. Keyboard skip/focus/table +contracts and responsive cards accompany the real reader flow. The compact existing queue +now points to #268, #270 and #271 rather than stale recovery or duplicate local handoffs. + +Local actual five-route build and byte-identical rebuild passed. Full hosted CI and later +fresh acceptance remain required. Browser navigation returned ERR_BLOCKED_BY_ADMINISTRATOR; +VISUAL_NOT_VERIFIED. No corpus, analyzer, M2, deployment activation or access-policy changes. diff --git a/project/CHANGELOG.d/2026-10-10-scrip-site-010.md b/project/CHANGELOG.d/2026-10-10-scrip-site-010.md new file mode 100644 index 00000000..9ebdf0d3 --- /dev/null +++ b/project/CHANGELOG.d/2026-10-10-scrip-site-010.md @@ -0,0 +1,27 @@ +# SCRIP-SITE-010 — integrated Reader preview 0.2.0a1 + +Refs #273, #267, #269, #271; incorporates #268/#270/#272 into one new PR against master. + +Reuse the corrected frozen report and five-route canonical site rather than duplicating +local previews. Adopt the local analyzer with HTML reader labels/summary, round-trip +numeric presentation, 29-row CSV, bounded regular-file input and path-private errors. +Add installed analyze/diagnostic/site commands while preserving the existing package +public API and morphology modules. Preserve the old README as RESEARCH_INDEX.md and +provide a reader-first landing document, installation guide and focused regression tests. + +Add read-only package CI: complete repository suite, wheel build, clean offline install, +actual installed public API and three-command smoke outside the source checkout. The +existing Pages double-build/deployment interlock is unchanged. Tests for labels require +actual visible span content, not only CSS/data attributes; precision tests identify the +canonical metric code independently of its human label. + +At authoring: 36 selected local analyzer/CSV/privacy/input tests pass against exact cached +core bytes. Full canonical CI, installed full-package smoke, actual five-route build and +later fresh acceptance must be established on the new GitHub head. This entry does not +assert their outcome. Native PR/check records own final verification and merge facts. + +No manuscript or dictionary data, historical artifact, metric formula/profile, AOT target, +corpus admission, M1-M5 completion, package-registry publication or Pages activation changes. +Old PRs remain open until the integrated result is accepted; do not merge this semantic +change in its authoring run. Interactive source writes succeeded today, which does not +establish hourly-worker capability. No scheduler changes. diff --git a/project/STATE_AND_QUEUE.md b/project/STATE_AND_QUEUE.md index 365c980b..899c29a4 100644 --- a/project/STATE_AND_QUEUE.md +++ b/project/STATE_AND_QUEUE.md @@ -1,23 +1,33 @@ # STATE_AND_QUEUE — Scriptorium -STATE_REVISION: 289 +STATE_REVISION: 292 PHASE: M1 — Deterministic FantLab surface -LAST_COMMITTED_RUN_AT: 2026-10-02T09:12:28Z -LAST_RESULT: SCRIP-CORPUS-087 / Issue #263 / PR #264 completed. Independent exact-head review re-read the six-file recovery patch at `950404bb86c191b7eacdf21eb34c231f1d906fc4`, confirmed 24/24 pull-request-triggered workflow runs completed successfully with no submitted reviews or unresolved review threads, and found no semantic merge blocker. PR #264 was squash-merged with expected-head protection as `a9df79ec04664011f6dddf3593031424b15c1073`; Issue #263 closed completed. Embedded text-layer status remains unverified; literary body, >=300k admission, FantLab-input identity, diagnostics, M2 and parity remain closed. +LAST_COMMITTED_RUN_AT: 2026-10-10T11:16:27Z +LAST_RESULT: Owner-requested all-open-PR review and integrated product delivery for #273. Exact-head COMMENT reviews were posted on #268, #270 and #272. The new Reader preview integrates their code into one PR against master, plus CSV, reader labels, bounded/path-private CLI and packaging. These are branch/PR effects, not merge or live publication. Full integrated CI and later fresh acceptance remain required; native GitHub records own their outcomes. LAST_VERIFIED_PROGRESS: M1 deterministic metric families remain executable inferred candidates. Public corpus/showcase evidence includes exact frozen/admitted works and source-free derived outputs, while no retained work has an independently established FantLab analyzer-input identity. Perelman now has independently reviewed exact PDF/DjVu identities, pagination, current Wikisource/FantLab surface boundaries, zero-PDF-PageLabels evidence, front-matter anchors, transition/tail anchors, restored-page interior observations, and canonical broad mid-body exact-PDF anchors that expose an unlabelled carrier page while keeping global mapping closed. Darwin/Rachinsky retains canonical reviewed dependency/replay/provider evidence through SCRIP-CORPUS-084. M2 stays 0/5. ## Current unit ```text -UNIT_ID: SCRIP-CORPUS-087 -ISSUE: #263 (closed completed) -STATUS: COMPLETE -PR: #264 (squash-merged as a9df79ec04664011f6dddf3593031424b15c1073) -REVIEWED_HEAD: 950404bb86c191b7eacdf21eb34c231f1d906fc4 -NEXT_ACTION: Resume normal-flow P1 SCRIP-CORPUS continuation. - Prefer an executable Petersburg/Perelman/Darwin-Rachinsky extraction or provenance unit that strengthens a >=300k candidate while keeping unsupported parity gates closed. +UNIT_ID: SCRIP-SITE-010 +ISSUE: #273 +STATUS: REVIEW_PENDING +PR: Resolve the new integrated Reader PR linked from #273; GitHub owns head/check/merge facts. +BASE: abe7bb889ef98437ba9faef5a79b023499556991 (master at authoring) +NEXT_ACTION: Inspect the new exact head and fix applicable integration failures in the same PR. + Later fresh acceptance may merge only after actual repository gates pass. + Preserve old #268/#270/#272 until the integrated result is accepted. ``` +The 2026-10-10 owner explicitly requested a major update in a NEW PR after reviewing all +open PRs. This integration replaces the stalled separate delivery ordering, not the +scientific mission. It includes #268 at `50fbb9b9c0da59a5eb78c6826381b8acd3dd8aae`, +#270 at `752f3c491af2f1c563a0c5ae31e9452f93a1c0a3`, and #272 at +`610dc8044c0f03a70c646eb48db51e6bd8ce19c7`. Do not merge #272 into its parent feature +branch or duplicate these changes from old ZIPs. #266 is already merged and is not a +recovery blocker. Interactive review/issue/blob/tree writes succeeded today; this does +not prove scheduled-context capability. Recheck capabilities without bypassing denial. + ## Current milestone gate M0 is closed. M1 remains open. General, dialogue, vocabulary and punctuation metric families are executable inferred candidates. POS retains separate work-page-compatible and diagnostic-only methodology surfaces; runtime `N` noun/cardinal ambiguity is unresolved. @@ -26,11 +36,27 @@ M2 remains open at **0/5 source-matched works**. Public-source freezing, bibliog ## Queue -Recovery/review-ready work and failing required checks preempt normal selection. +The 2026-10-02 owner correction authorizes solo product/backlog ownership and independent +analyzer, CLI and public-experience delivery. Recovery and failing checks preempt only +when executable; unchanged corpus, write or bookkeeping blockers are scoped dependencies, +not a global ordering rule. Preserve scientific/security and later-run acceptance gates. + +Next user-visible milestone: install the canonical Reader, analyze a local text as +JSON/HTML/CSV, and open a real full-work diagnostic through the five-route static site. +This incremental milestone does not declare M1/M2 complete. | Priority | Unit | Mode | Deliverable | Gate / dependency | | --- | --- | --- | --- | --- | -| P1 | SCRIP-CORPUS continuation | corpus / provenance | Add or strengthen legally usable >=300k candidates, prioritizing diversity beyond 19th-century Russian classics; Petersburg, Perelman and Darwin/Rachinsky have candidate-specific extraction/renderer work available | Preserve translation/edition identity and legal provenance; renderer/OCR work must keep body/admission/FantLab/M2 gates closed until separately verified | +| P1 | SCRIP-SITE-010 / #273 | integration | Complete the single master-targeting Reader PR | New exact-head full suite, installed wheel smoke, unchanged artifacts; fix actual failures in the same branch | +| P2 | SCRIP-SITE-010 acceptance | delivery | Accept the earlier integration, reconcile old PRs and verify Pages postconditions | Later fresh acceptance; real checks/protection; Pages activation remains separate | +| P3 | Reader visual acceptance | public experience | Inspect actual desktop/mobile report and homepage; repair evidenced accessibility issues | Build real pages; permitted browser only; unavailable visual execution blocks this scope alone | +| P4 | SCRIP-COMPARE-002 pending handoff | analyzer / comparison | Adopt one tested two-diagnostic comparison CLI | Deduplicate integrated code, preserve profile/null boundaries, use canonical Anna/Grin bytes | +| P5 | SCRIP-CORPUS continuation | corpus / provenance | Advance one independent legally usable >=300k diversity candidate | New executable evidence only; preserve translation/edition and FantLab-input gates | + +The unintegrated EPUB/batch Workbench prototypes remain pending auxiliary material, +not released repository capabilities. Selectively adopt one ready slice only after +fresh compatibility/tests; never overwrite the canonical public API with a subset stub. +Prior browser navigation was blocked; no previous structural tests are visual PASS. ## Retained corpus / provenance orientation diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a32b9f0d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "scriptorium-reader" +version = "0.2.0a1" +description = "Private literary-text analysis and evidence-labelled reader reports" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [] + +[project.scripts] +scriptorium = "scriptorium.cli:main" + +[tool.setuptools.packages.find] +include = ["scriptorium*"] + +[tool.setuptools] +include-package-data = false diff --git a/scriptorium/__main__.py b/scriptorium/__main__.py new file mode 100644 index 00000000..54e43225 --- /dev/null +++ b/scriptorium/__main__.py @@ -0,0 +1,5 @@ +"""Run the canonical Reader CLI with python -m scriptorium.""" +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scriptorium/analyze.py b/scriptorium/analyze.py new file mode 100644 index 00000000..486bf4ba --- /dev/null +++ b/scriptorium/analyze.py @@ -0,0 +1,510 @@ +"""Privacy-safe local text analysis with JSON and standalone HTML output.""" + +from __future__ import annotations + +import argparse +import csv +from hashlib import sha256 +from decimal import Decimal +from html import escape +import io +import json +import math +import os +import stat +import tempfile +from pathlib import Path +from typing import Final, Iterable + +from .metrics import analyze_deterministic_metrics +from .metric_labels import METRIC_LABELS +from .csv_safety import safe_csv_text + + +LOCAL_ANALYSIS_SCHEMA: Final = "scriptorium-local-analysis-v1" +LOCAL_CSV_SCHEMA: Final = "scriptorium-local-analysis-csv-v1" +REPRESENTATIVE_CHARACTERS: Final = 300_000 +MAX_ANALYSIS_BYTES: Final = 32 * 1024 * 1024 +MAX_DICTIONARY_BYTES: Final = 8 * 1024 * 1024 + + +class LocalInputError(ValueError): + """An intentionally safe, path-free local file rejection.""" + + +def build_local_analysis( + source_bytes: bytes, + *, + dictionary_words: Iterable[str] | None = None, + dictionary_profile: str | None = None, + scriptorium_revision: str | None = None, +) -> dict[str, object]: + """Analyze UTF-8 source bytes without embedding source text in the result.""" + + text = source_bytes.decode("utf-8") + analysis = analyze_deterministic_metrics( + text, + dictionary_words=dictionary_words, + dictionary_profile=dictionary_profile, + ) + normalized_characters = analysis["metrics"]["fantlab.general.characters"]["value"] + if isinstance(normalized_characters, bool) or not isinstance(normalized_characters, int): + raise ValueError("engine character count must be an integer") + meets_threshold = normalized_characters >= REPRESENTATIVE_CHARACTERS + + return { + "schema_version": LOCAL_ANALYSIS_SCHEMA, + "metric_contract_id": analysis["metric_contract_id"], + "source": { + "raw_sha256": sha256(source_bytes).hexdigest(), + "byte_count": len(source_bytes), + "normalized_sha256": analysis["normalized_sha256"], + "normalized_character_count": normalized_characters, + "source_text_embedded": False, + }, + "analyzer": { + "scriptorium_revision": _none_if_blank(scriptorium_revision), + "profiles": analysis["profiles"], + "dependencies": analysis["dependencies"], + }, + "representativeness": { + "calibration_threshold_characters": REPRESENTATIVE_CHARACTERS, + "meets_calibration_threshold": meets_threshold, + "note": ( + "Length meets the standing calibration/profile corpus floor; legal and provenance " + "admission remain separate gates." + if meets_threshold + else "Short input is valid for local analysis but is less representative for " + "author/profile or parity-corpus use." + ), + }, + "metrics": analysis["metrics"], + } + + +def render_html(artifact: dict[str, object], *, title: str | None = None, saved_artifact: bool = False) -> str: + """Render one local-analysis artifact as deterministic standalone HTML.""" + + if artifact.get("schema_version") != LOCAL_ANALYSIS_SCHEMA: + raise ValueError("unsupported local analysis schema") + source = _mapping(artifact.get("source"), "source") + analyzer = _mapping(artifact.get("analyzer"), "analyzer") + representativeness = _mapping(artifact.get("representativeness"), "representativeness") + metrics = _mapping(artifact.get("metrics"), "metrics") + if source.get("source_text_embedded") is not False: + raise ValueError("local analysis must not embed source text") + + display_title = _nonempty_text(title) if title is not None else "Scriptorium local analysis" + groups = ( + ("General", _rows(metrics, ("fantlab.general.", "scriptorium.general."))), + ("Dialogue", _rows(metrics, ("fantlab.dialogue.",))), + ("Vocabulary", _rows(metrics, ("fantlab.vocabulary.",))), + ("Punctuation", _rows(metrics, ("fantlab.punctuation.",))), + ) + # Key figures are taken from the *same* current metric artifact as the tables. + # This is a reader-first summary, not another calculation or a quality score. + highlight_fields = ( + ("Words", "fantlab.general.words"), + ("Sentences · Scriptorium", "scriptorium.general.sentences"), + ("Dialogue share", "fantlab.dialogue.share_percent"), + ("Distinct surface words", "fantlab.vocabulary.unique_words"), + ) + overview = '

At a glance

' + for label, metric_id in highlight_fields: + metric = _mapping(metrics.get(metric_id), metric_id) + value = _format_value(metric.get("value")) + unit = _human_unit(str(metric.get("unit", ""))) + overview += ( + '
' + f'
{escape(label)}
' + f'
{escape(value)} {escape(unit) if value != "Unavailable" else ""}
' + '
' + ) + overview += '
' + included = {metric_id for _, rows in groups for metric_id, _ in rows} + other = [(metric_id, _mapping(metrics[metric_id], metric_id)) + for metric_id in sorted(metrics) if metric_id not in included] + groups = (*groups, ("Other", other)) + sections = "".join(_render_metric_group(name, rows) for name, rows in groups if rows) + profiles = _mapping(analyzer.get("profiles"), "analyzer.profiles") + profiles_html = "".join( + f"
{escape(str(key))}
{escape(str(value))}
" + for key, value in sorted(profiles.items()) + ) + threshold_state = ( + "Meets the 300,000-character calibration floor" + if representativeness.get("meets_calibration_threshold") is True + else "Short input — valid analysis, limited representativeness" + ) + dependency = _mapping(analyzer.get("dependencies"), "analyzer.dependencies") + dictionary_dependency = dependency.get("vocabulary_dictionary") + if dictionary_dependency is None: + dictionary_html = "

Vocabulary dictionary: not supplied; dictionary-dependent rows are not run.

" + else: + dep = _mapping(dictionary_dependency, "vocabulary_dictionary") + dictionary_html = ( + "

Vocabulary dictionary: " + f"{escape(str(dep.get('profile')))} · {escape(str(dep.get('lexeme_count')))} normalized lexemes · " + f"{escape(str(dep.get('normalized_lexemes_sha256')))}

" + ) + + revision = analyzer.get("scriptorium_revision") + revision_html = "not recorded" if revision is None else f"{escape(str(revision))}" + note = escape(str(representativeness.get("note", ""))) + saved_note = ( + '

Imported saved JSON: hashes, metric values and ' + 'algorithm versions are declarations in this file, not authenticated or replay-verified. ' + 'To check against your manuscript, rerun the analyzer on the ' + 'original text and compare the results. This is not FantLab parity.

' + if saved_artifact else "" + ) + return f''' + + + + +{escape(display_title)} + + + + +
+

Scriptorium · local-only deterministic analysis

+

{escape(display_title)}

+

Explore literary statistics in everyday language, with the exact machine metric IDs beneath each label. FantLab-namespaced rows are inferred compatibility candidates, not parity claims. Source text is not embedded in this report.

+

{escape(threshold_state)}

+{saved_note} +{overview} +
+

Raw SHA-256: {escape(str(source.get('raw_sha256')))}

+

Normalized SHA-256: {escape(str(source.get('normalized_sha256')))}

+

Bytes / normalized characters: {escape(str(source.get('byte_count')))} / {escape(str(source.get('normalized_character_count')))}

+

Scriptorium revision: {revision_html}

+{dictionary_html} +

Representativeness: {note}

+
Analysis profiles
{profiles_html}
+

Unavailable is not zero. A null metric may need a dictionary, a nonzero +word/sentence denominator, or a complete vocabulary window. No null is a parity pass.

+

Numeric display: Floating-point values retain their JSON round-trip decimal representation. Digits describe deterministic output, not measurement accuracy or FantLab parity.

+
+{sections} +
+
Local analysis only. Corpus admission, legal provenance and FantLab source identity are separate gates.
+ + +''' + + +def render_csv(artifact: dict[str, object]) -> str: + """Render stable, spreadsheet-safe derived rows without manuscript text. + + Metadata is repeated per metric so any filtered CSV subset remains attributable + to its exact source digest and algorithm profiles. Numeric values retain Python's + round-trip representation, consistent with JSON and HTML numeric values. + """ + + if artifact.get("schema_version") != LOCAL_ANALYSIS_SCHEMA: + raise ValueError("unsupported local analysis schema") + source = _mapping(artifact.get("source"), "source") + if source.get("source_text_embedded") is not False: + raise ValueError("local analysis must not embed source text") + analyzer = _mapping(artifact.get("analyzer"), "analyzer") + profiles = _mapping(analyzer.get("profiles"), "analyzer.profiles") + dependencies = _mapping(analyzer.get("dependencies"), "analyzer.dependencies") + metrics = _mapping(artifact.get("metrics"), "metrics") + if set(metrics) != set(METRIC_LABELS): + raise ValueError("local-analysis-csv-v1 requires all 29 current metrics") + dictionary = dependencies.get("vocabulary_dictionary") + if dictionary is not None: + dictionary = _mapping(dictionary, "analyzer.dependencies.vocabulary_dictionary") + + columns = ( + "metric_id", "value", "unit", "status", "definition_evidence", "raw_count", + "csv_schema_version", "metric_contract_id", "raw_sha256", "normalized_sha256", + "scriptorium_revision", "normalization_profile", "metrics_profile", "dialogue_profile", + "vocabulary_profile", "punctuation_profile", "dictionary_profile", + "dictionary_sha256", + ) + output = io.StringIO(newline="") + writer = csv.DictWriter(output, fieldnames=columns, lineterminator="\n") + writer.writeheader() + identity = { + "csv_schema_version": LOCAL_CSV_SCHEMA, + "scriptorium_revision": _csv_text(analyzer.get("scriptorium_revision")), + "metric_contract_id": _csv_required_text(artifact.get("metric_contract_id"), "metric_contract_id"), + "raw_sha256": _csv_required_text(source.get("raw_sha256"), "source.raw_sha256"), + "normalized_sha256": _csv_required_text(source.get("normalized_sha256"), "source.normalized_sha256"), + "normalization_profile": _csv_required_text(profiles.get("normalization"), "normalization profile"), + "metrics_profile": _csv_required_text(profiles.get("metrics"), "metrics profile"), + "dialogue_profile": _csv_required_text(profiles.get("dialogue"), "dialogue profile"), + "vocabulary_profile": _csv_required_text(profiles.get("vocabulary"), "vocabulary profile"), + "punctuation_profile": _csv_required_text(profiles.get("punctuation"), "punctuation profile"), + "dictionary_profile": _csv_text(dictionary.get("profile") if dictionary else None), + "dictionary_sha256": _csv_text(dictionary.get("normalized_lexemes_sha256") if dictionary else None), + } + for metric_id in sorted(metrics): + metric = _mapping(metrics[metric_id], metric_id) + status = metric.get("compatibility_status") + if status not in ("inferred", "extension"): + raise ValueError(f"metric {metric_id} has unsupported compatibility status") + writer.writerow({ + **identity, + "metric_id": _csv_text(metric_id), + "value": _csv_number(metric.get("value")), + "unit": _csv_required_text(metric.get("unit"), "metric unit"), + "status": status, + "definition_evidence": _csv_required_text(metric.get("definition_evidence"), "definition evidence"), + "raw_count": _csv_number(metric.get("raw_count")), + }) + return output.getvalue() + + +def _csv_number(value: object) -> str: + if value is None: + return "" # Unknown is a blank cell, never zero. + if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)): + raise ValueError("metric numeric value must be int, finite float or null") + if isinstance(value, (float, Decimal)) and not math.isfinite(value): + raise ValueError("metric numeric value must be finite") + return str(value) + + +def _csv_required_text(value: object, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{label} must be a non-empty string") + return _csv_text(value) + + +def _csv_text(value: object) -> str: + if value is None: + return "" + if not isinstance(value, str): + raise ValueError("metric identity field must be text or null") + return safe_csv_text(value) + +def _render_metric_group(name: str, rows: list[tuple[str, dict[str, object]]]) -> str: + body = [] + for metric_id, row in rows: + status = row.get("compatibility_status") + if status not in ("inferred", "extension"): + raise ValueError("local results must remain inferred or extension") + value = row.get("value") + raw_count = row.get("raw_count") + raw_count_html = "—" if raw_count is None else escape(str(raw_count)) + # Human labels are presentation-only; the canonical metric ID remains + # visibly inspectable and the machine-readable artifact is unchanged. + human_label = METRIC_LABELS.get(metric_id, metric_id) + body.append( + "" + f'' + f'{escape(human_label)}' + f'{escape(metric_id)}' + f"{escape(_format_value(value))}" + f"{escape(_human_unit(str(row.get('unit', ''))))}" + f"{escape(str(row.get('compatibility_status', 'unknown')))}" + f"{raw_count_html}" + "" + ) + label = escape(name) + return ( + f"

{label}

" + f"" + "" + "" + "".join(body) + "
{label}: local metrics and evidence
MetricValueUnitStatusRaw count
" + ) + + +def _rows(metrics: dict[str, object], prefixes: tuple[str, ...]) -> list[tuple[str, dict[str, object]]]: + result: list[tuple[str, dict[str, object]]] = [] + for metric_id in sorted(metrics): + if not metric_id.startswith(prefixes): + continue + row = _mapping(metrics[metric_id], metric_id) + result.append((metric_id, row)) + return result + + +def _format_value(value: object) -> str: + if value is None: + return "Unavailable" + if isinstance(value, bool): + raise ValueError("metric value must not be boolean") + if isinstance(value, int): + return f"{value:,}" + if isinstance(value, Decimal): + if not value.is_finite(): + raise ValueError("metric value must be finite") + return str(value) + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError("metric value must be finite") + # Preserve the float's JSON round-trip decimal in human-readable output. + # Six significant digits can hide meaningful diagnostic differences. + return repr(value) + raise ValueError("metric value must be numeric or null") + + +def _human_unit(unit: str) -> str: + return { + "characters/word": "characters / word", + "characters/sentence": "characters / sentence", + "occurrences/1000_words": "occurrences / 1,000 words", + "unique_dictionary_words/window": "unique dictionary words / window", + }.get(unit, unit) + + +def _mapping(value: object, label: str) -> dict[str, object]: + if not isinstance(value, dict): + raise ValueError(f"{label} must be an object") + return value + + +def _nonempty_text(value: str) -> str: + stripped = value.strip() + if not stripped: + raise ValueError("title must be non-empty") + return stripped + + +def _none_if_blank(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + + +def _read_bounded_regular_file(path: Path, limit: int, label: str) -> bytes: + """Bound local reads even if a file grows after its initial stat. + + On POSIX, O_NOFOLLOW also prevents a symlink-swap race at open time. + O_NONBLOCK prevents a FIFO from hanging the CLI before fstat rejects it. + """ + if path.is_symlink(): + raise LocalInputError(f"{label} must be a regular, non-symlink file") + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + try: + fd = os.open(path, flags) + except OSError as exc: + raise LocalInputError(f"{label} cannot be opened as a regular file") from exc + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode): + raise LocalInputError(f"{label} must be a regular file") + if info.st_size > limit: + raise LocalInputError(f"{label} exceeds the {limit}-byte input limit") + with os.fdopen(fd, "rb") as handle: + fd = -1 + content = handle.read(limit + 1) + except OSError as exc: + raise LocalInputError(f"{label} could not be read") from exc + finally: + if fd != -1: + os.close(fd) + if len(content) > limit: + raise LocalInputError(f"{label} exceeds the {limit}-byte input limit") + return content + + +def _write_new(path: Path, content: str) -> None: + # Publish complete derived output with no replacement, including symlink targets. + # Keep the temporary file on the same filesystem for atomic hard-link creation. + path.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", newline="\n", + prefix=".scriptorium-", suffix=".tmp", dir=path.parent, delete=False, + ) as handle: + temporary = Path(handle.name) + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.link(temporary, path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Analyze a local UTF-8 text without publishing source text.") + parser.add_argument("text", type=Path) + parser.add_argument("--format", choices=("json", "html", "csv"), default="json") + parser.add_argument("--title") + parser.add_argument("--scriptorium-revision") + parser.add_argument("--dictionary", type=Path) + parser.add_argument("--dictionary-profile") + parser.add_argument("--output", type=Path) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + if (args.dictionary is None) != (args.dictionary_profile is None): + parser.error("--dictionary and --dictionary-profile must be supplied together") + + try: + source_bytes = _read_bounded_regular_file(args.text, MAX_ANALYSIS_BYTES, "manuscript") + dictionary_words = None + if args.dictionary is not None: + dictionary_bytes = _read_bounded_regular_file(args.dictionary, MAX_DICTIONARY_BYTES, "dictionary") + try: + dictionary_words = dictionary_bytes.decode("utf-8").splitlines() + except UnicodeDecodeError as exc: + raise LocalInputError("dictionary must be UTF-8") from exc + artifact = build_local_analysis( + source_bytes, + dictionary_words=dictionary_words, + dictionary_profile=args.dictionary_profile, + scriptorium_revision=args.scriptorium_revision, + ) + if args.format == "json": + rendered = json.dumps(artifact, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False) + "\n" + elif args.format == "html": + rendered = render_html(artifact, title=args.title) + else: + rendered = render_csv(artifact) + if args.output is None: + print(rendered, end="") + else: + _write_new(args.output, rendered) + except FileExistsError: + parser.error("report output already exists; choose another destination") + except LocalInputError as exc: + parser.error(str(exc)) # This exception contains only controlled, path-free text. + except UnicodeDecodeError: + parser.error("manuscript must be UTF-8") + except (OSError, ValueError, TypeError): + parser.error("unable to analyze local inputs or create the report") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scriptorium/cli.py b/scriptorium/cli.py new file mode 100644 index 00000000..bc604c5a --- /dev/null +++ b/scriptorium/cli.py @@ -0,0 +1,65 @@ +"""Installed reader entry point; reuse canonical analysis and publication modules.""" +from __future__ import annotations + +import argparse +import importlib +from importlib.metadata import PackageNotFoundError, version +import json +from pathlib import Path +import sys + +COMMANDS = {"analyze": "scriptorium.analyze", "site": "scriptorium.site_renderer"} +MAX_DIAGNOSTIC_BYTES = 8 * 1024 * 1024 + + +def diagnostic_main(argv: list[str]) -> int: + from .analyze import LocalInputError, _read_bounded_regular_file, _write_new + from .frozen_diagnostic_report import _unique_keys, render_html + + parser = argparse.ArgumentParser(description="Read a curated frozen diagnostic without downloading a book.") + parser.add_argument("artifact", type=Path) + parser.add_argument("--title") + parser.add_argument("--output", type=Path) + args = parser.parse_args(argv) + try: + raw = _read_bounded_regular_file(args.artifact, MAX_DIAGNOSTIC_BYTES, "diagnostic") + value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_keys) + rendered = render_html(value, title=args.title) + if args.output is None: + print(rendered, end="") + else: + _write_new(args.output, rendered) + except FileExistsError: + parser.error("report output already exists; choose another destination") + except LocalInputError as exc: + parser.error(str(exc)) + except (OSError, UnicodeError, ValueError, TypeError, RecursionError): + parser.error("unable to read the curated diagnostic or create the report") + return 0 + + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if args == ["--version"]: + try: + installed = version("scriptorium-reader") + except PackageNotFoundError: + installed = "source checkout (distribution version not recorded)" + print(f"Scriptorium Reader {installed}; inferred/extension preview, not FantLab parity") + return 0 + if not args or args[0] in {"-h", "--help"}: + print("Scriptorium Reader — private text analysis and evidence-labelled literary reports") + print("Usage: scriptorium [options]") + print(" analyze UTF-8 manuscript -> JSON, HTML or CSV (29 metrics)") + print(" diagnostic Curated historical diagnostic -> offline HTML") + print(" site Build the canonical allow-listed site from a repository checkout") + print("Run scriptorium --help for command options.") + return 0 if args else 2 + command, rest = args[0], args[1:] + if command == "diagnostic": + return diagnostic_main(rest) + module = COMMANDS.get(command) + if module is None: + print("Unknown command. Run scriptorium --help.", file=sys.stderr) + return 2 + return importlib.import_module(module).main(rest) diff --git a/scriptorium/csv_safety.py b/scriptorium/csv_safety.py new file mode 100644 index 00000000..fb144be0 --- /dev/null +++ b/scriptorium/csv_safety.py @@ -0,0 +1,32 @@ +"""Minimal spreadsheet-formula neutralization for source-free CSV text fields. + +RFC 4180 quoting prevents column breakout but not formula execution. Prefix risky +*text* cells with an apostrophe; numeric cells are validated by their callers. +The apostrophe intentionally changes the spreadsheet-facing string. This is a +best-effort CSV defense, not a promise about Excel's save/reopen behavior. +""" +from __future__ import annotations + +import unicodedata + +# Full-width formula operators may be interpreted in some spreadsheet locales. +_FORMULA_PREFIX = frozenset("=+-@=+-@") +_CONTROL_PREFIX = frozenset("\t\r\n\x00") + + +def safe_csv_text(value: str) -> str: + """Escape formula-like text, including formulas hidden behind format marks. + + Keep the original label unchanged after the leading apostrophe, so provenance + can be reconstructed without silently stripping meaningful characters. + """ + if not value: + return value + if value[0] in _CONTROL_PREFIX: + return "'" + value + # Spreadsheet importers may ignore leading Unicode whitespace / formatting + # chars such as BOM (U+FEFF), zero-width space (U+200B) and word joiner. + first = next((char for char in value if not (char.isspace() or unicodedata.category(char) == "Cf")), "") + if first in _FORMULA_PREFIX: + return "'" + value + return value diff --git a/scriptorium/frozen_diagnostic_report.py b/scriptorium/frozen_diagnostic_report.py new file mode 100644 index 00000000..ee5d2bb2 --- /dev/null +++ b/scriptorium/frozen_diagnostic_report.py @@ -0,0 +1,467 @@ +"""Readable source-free report for frozen full-work FantLab diagnostics. + +This presentation layer consumes the already source-free +``scriptorium-frozen-diagnostic-v1`` artifact. It does not recompute literary metrics, +recover source text, rank incomparable deltas, or upgrade diagnostic evidence to parity. + +The current full-work Anna Karenina artifact is source-unmatched by design, so every +published metric remains ``unresolved`` or ``not_run`` here. +""" + +from __future__ import annotations + +import argparse +import html +import json +import math +import os +import re +from collections.abc import Mapping, Sequence +from pathlib import Path, PurePosixPath +from typing import Any, Final + +SCHEMA_VERSION: Final = "scriptorium-frozen-diagnostic-v1" +ALLOWED_RESULTS: Final = frozenset({"unresolved", "not_run"}) +HEX64_RE: Final = re.compile(r"^[0-9a-f]{64}$") +GROUPS: Final = ( + ("fantlab.general.", "General"), + ("fantlab.dialogue.", "Dialogue"), + ("fantlab.vocabulary.", "Vocabulary"), + ("fantlab.punctuation.", "Punctuation"), +) + + +class FrozenDiagnosticReportError(ValueError): + """Raised when a frozen diagnostic cannot be presented without overclaim.""" + + +def validate_artifact(artifact: Mapping[str, object]) -> Mapping[str, object]: + value = _mapping(artifact, "artifact") + expected_top = { + "artifact_version", + "benchmark_id", + "candidate_id", + "evidence", + "epistemic_status", + "metrics", + "omitted_metric_families", + "observations", + } + if set(value) != expected_top: + raise FrozenDiagnosticReportError("top-level keys drift from frozen-diagnostic-v1") + if value.get("artifact_version") != SCHEMA_VERSION: + raise FrozenDiagnosticReportError("unsupported frozen diagnostic artifact") + _text(value.get("benchmark_id"), "benchmark_id", 300) + _text(value.get("candidate_id"), "candidate_id", 300) + + evidence = _mapping(value.get("evidence"), "evidence") + required_evidence = { + "hosted_run_id", + "python_version", + "scriptorium_revision", + "reference_path", + "source_manifest_path", + "replay_integrity", + "chapter_count", + "raw_sha256", + "normalized_sha256", + "source_text_committed", + "source_text_uploaded", + } + if set(evidence) != required_evidence: + raise FrozenDiagnosticReportError("evidence keys drift from reviewed contract") + if evidence.get("replay_integrity") != "passed": + raise FrozenDiagnosticReportError("replay_integrity must be passed before presentation") + _positive_int(evidence.get("hosted_run_id"), "evidence.hosted_run_id") + _positive_int(evidence.get("chapter_count"), "evidence.chapter_count") + _text(evidence.get("python_version"), "evidence.python_version", 100) + _text(evidence.get("scriptorium_revision"), "evidence.scriptorium_revision", 100) + _repo_json_path(evidence.get("reference_path"), "evidence.reference_path") + _repo_json_path(evidence.get("source_manifest_path"), "evidence.source_manifest_path") + _sha256(evidence.get("raw_sha256"), "evidence.raw_sha256") + _sha256(evidence.get("normalized_sha256"), "evidence.normalized_sha256") + if evidence.get("source_text_committed") is not False: + raise FrozenDiagnosticReportError("source_text_committed must remain false") + if evidence.get("source_text_uploaded") is not False: + raise FrozenDiagnosticReportError("source_text_uploaded must remain false") + + status = _mapping(value.get("epistemic_status"), "epistemic_status") + required_status = { + "status", + "fantlab_source_edition_match", + "m2_parity_admissible", + "m2_source_matched_progress", + "reason", + } + if set(status) != required_status: + raise FrozenDiagnosticReportError("epistemic_status keys drift from reviewed contract") + if status.get("status") != "diagnostic_only": + raise FrozenDiagnosticReportError("artifact must remain diagnostic_only") + if status.get("fantlab_source_edition_match") != "unknown": + raise FrozenDiagnosticReportError("FantLab source edition must remain unknown") + if status.get("m2_parity_admissible") is not False: + raise FrozenDiagnosticReportError("M2 parity must remain inadmissible") + if status.get("m2_source_matched_progress") != "0/5": + raise FrozenDiagnosticReportError("unexpected M2 source-matched progress") + _text(status.get("reason"), "epistemic_status.reason", 2000) + + metrics = _mapping(value.get("metrics"), "metrics") + if not metrics: + raise FrozenDiagnosticReportError("diagnostic metrics must be non-empty") + for metric_id, raw in metrics.items(): + if not isinstance(metric_id, str) or not metric_id.startswith("fantlab."): + raise FrozenDiagnosticReportError(f"invalid metric id: {metric_id!r}") + row = _mapping(raw, f"metrics.{metric_id}") + if set(row) != {"expected", "actual", "raw_delta", "result"}: + raise FrozenDiagnosticReportError(f"{metric_id} keys drift from reviewed contract") + expected = _number(row.get("expected"), f"{metric_id}.expected") + actual = row.get("actual") + delta = row.get("raw_delta") + result = row.get("result") + if not isinstance(result, str) or result not in ALLOWED_RESULTS: + raise FrozenDiagnosticReportError( + f"{metric_id} result {result!r} would overclaim source-unmatched evidence" + ) + if actual is None: + if delta is not None: + raise FrozenDiagnosticReportError( + f"{metric_id} cannot have raw_delta without actual" + ) + else: + actual_num = _number(actual, f"{metric_id}.actual") + delta_num = _number(delta, f"{metric_id}.raw_delta") + calculated = actual_num - expected + if ( + delta_num != calculated + if all(isinstance(n, int) for n in (expected, actual_num, delta_num)) + else not math.isclose(delta_num, calculated, rel_tol=0.0, abs_tol=1e-9) + ): + raise FrozenDiagnosticReportError( + f"{metric_id}.raw_delta is inconsistent with actual - expected" + ) + if result == "not_run" and actual is not None: + raise FrozenDiagnosticReportError( + f"{metric_id} is not_run but contains an actual value" + ) + + omitted = _mapping(value.get("omitted_metric_families"), "omitted_metric_families") + for family, reason in omitted.items(): + _text(family, "omitted metric family", 100) + _text(reason, f"omitted_metric_families.{family}", 1000) + + observations = value.get("observations") + if not isinstance(observations, list): + raise FrozenDiagnosticReportError("observations must be an array") + for index, observation in enumerate(observations): + _text(observation, f"observations[{index}]", 2000) + + return value + + +def summarize(artifact: Mapping[str, object]) -> dict[str, int]: + value = validate_artifact(artifact) + metrics = _mapping(value["metrics"], "metrics") + actual = 0 + missing = 0 + unresolved = 0 + not_run = 0 + for raw in metrics.values(): + row = _mapping(raw, "metric") + if row["actual"] is None: + missing += 1 + else: + actual += 1 + if row["result"] == "unresolved": + unresolved += 1 + elif row["result"] == "not_run": + not_run += 1 + return { + "metric_count": len(metrics), + "actual_count": actual, + "missing_actual_count": missing, + "unresolved_count": unresolved, + "not_run_count": not_run, + } + + +def render_html(artifact: Mapping[str, object], *, title: str | None = None) -> str: + value = validate_artifact(artifact) + title_text = _text(title if title is not None else value["candidate_id"], "title", 300) + summary = summarize(value) + evidence = _mapping(value["evidence"], "evidence") + status = _mapping(value["epistemic_status"], "epistemic_status") + metrics = _mapping(value["metrics"], "metrics") + omitted = _mapping(value["omitted_metric_families"], "omitted_metric_families") + observations = value["observations"] + + grouped: dict[str, list[tuple[str, Mapping[str, object]]]] = { + label: [] for _, label in GROUPS + } + grouped["Other"] = [] + for metric_id in sorted(metrics): + group = "Other" + for prefix, label in GROUPS: + if metric_id.startswith(prefix): + group = label + break + grouped[group].append((metric_id, _mapping(metrics[metric_id], metric_id))) + + sections = [] + for group, rows in grouped.items(): + if not rows: + continue + rendered = [] + for metric_id, row in rows: + result = str(row["result"]) + rendered.append( + "" + f'{_esc(metric_id)}' + f'{_esc(_unit(metric_id))}' + f'{_esc(_fmt(row["expected"]))}' + f'{_esc(_fmt(row["actual"]))}' + f'{_esc(_signed(row["raw_delta"]))}' + f'{_esc(result)}' + "" + ) + sections.append( + f'

{_esc(group)}

' + f'
' + f'' + '' + '' + f'{"".join(rendered)}
{_esc(group)}: expected / actual / raw delta
MetricUnitFantLab expectedScriptorium actualRaw ΔEvidence result
' + ) + + omitted_rows = "".join( + f"
{_esc(family)}
{_esc(reason)}
" + for family, reason in sorted(omitted.items()) + ) + observation_items = "".join( + f"
  • {_esc(item)}
  • " for item in observations + ) + + css = """ +:root{color-scheme:light dark;font-family:ui-serif,Georgia,Cambria,"Times New Roman",serif;line-height:1.55} +*{box-sizing:border-box}body{margin:0;background:Canvas;color:CanvasText} +main,footer{max-width:96rem;margin:auto;padding:clamp(1rem,4vw,2.5rem)} +.eyebrow{font:800 .78rem/1.2 system-ui,sans-serif;letter-spacing:.1em;text-transform:uppercase} +h1{font-size:clamp(2.5rem,8vw,5.6rem);line-height:.96;max-width:18ch;margin:.35rem 0 1rem} +h2{margin-top:2.2rem}.lede,.notice,li{font:1rem/1.6 system-ui,sans-serif;max-width:78rem} +.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(11rem,100%),1fr));gap:.75rem;margin:1.4rem 0 2rem} +.card,.notice{border:1px solid currentColor;border-radius:.9rem;padding:1rem} +.card strong{display:block;font-size:1.7rem}.card span{font:.84rem/1.3 system-ui,sans-serif} +.table-wrap{overflow-x:auto;margin-bottom:2rem}table{width:100%;border-collapse:collapse;font:.84rem/1.4 system-ui,sans-serif} +th,td{text-align:left;padding:.62rem;border-bottom:1px solid currentColor;vertical-align:top} +th[scope=row]{min-width:21rem}.pill{display:inline-block;border:1px solid currentColor;border-radius:999px;padding:.15rem .42rem;font-size:.72rem;font-weight:700} +code{overflow-wrap:anywhere}dd{margin-left:0;overflow-wrap:anywhere}dt{font-weight:700;margin-top:.8rem} +*:focus-visible{outline:3px solid currentColor;outline-offset:3px} a{color:inherit;display:inline-block;min-height:44px;padding:.6rem 0} caption{text-align:left;margin:.5rem 0} .skip:not(:focus){position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden}footer{opacity:.72;font:.85rem/1.45 system-ui,sans-serif} +@media(max-width:44rem){main,footer{padding:1rem}th[scope=row]{min-width:15rem}th,td{padding:.48rem}} +@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}} +""" + body = ( + '
    ' + '

    Scriptorium · full-work diagnostic

    ' + f'

    {_esc(title_text)}

    ' + '

    A field-by-field, source-free comparison against FantLab’s ' + 'published values. Numeric resemblance is visible, but source identity remains ' + 'unknown, so this page does not promote any row to parity.

    ' + '
    ' + f'{_card(summary["metric_count"], "published metric rows")}' + f'{_card(summary["actual_count"], "numeric actuals")}' + f'{_card(summary["missing_actual_count"], "missing actuals")}' + f'{_card(summary["unresolved_count"], "unresolved")}' + f'{_card(summary["not_run_count"], "not run")}' + '
    ' + '
    Scientific boundary. ' + f'{_esc(status["reason"])} ' + 'M2 source-matched progress in this artifact: 0/5.
    ' + '

    Historical snapshot, not a new analyzer run. Display uses up to six ' + 'decimal places; small nonzero values use scientific notation. This formatting is ' + 'not FantLab display rounding or a parity tolerance. Δ uses the row unit; for ' + 'percentage rows it means percentage points. Missing is not zero.

    ' + '

    Reproducibility identity

    ' + f'
    Benchmark ID
    {_esc(value["benchmark_id"])}
    ' + f'
    Candidate ID
    {_esc(value["candidate_id"])}
    ' + f'
    Scriptorium revision
    {_esc(evidence["scriptorium_revision"])}
    ' + f'
    Python
    {_esc(evidence["python_version"])}
    ' + f'
    Hosted run
    {_esc(evidence["hosted_run_id"])}
    ' + f'
    Frozen chapters
    {_esc(evidence["chapter_count"])}
    ' + f'
    Raw SHA-256
    {_esc(evidence["raw_sha256"])}
    ' + f'
    Normalized SHA-256
    {_esc(evidence["normalized_sha256"])}
    ' + f'
    Source-edition match
    {_esc(status["fantlab_source_edition_match"])}
    ' + '
    ' + + '
    ' + "".join(sections) + "
    " + + '

    Omitted metric families (at capture)

    ' + + omitted_rows + + '

    Reviewed observations (historical)

      ' + + observation_items + + '
    Privacy. ' + 'This report contains hashes, aggregate metrics and reviewed observations only. ' + 'No novel prose or replayed source text is embedded.
    ' + ) + return ( + '' + '' + f'{_esc(title_text)} — Scriptorium' + f'{body}
    Generated from a source-free frozen diagnostic artifact.
    \n' + ) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Render a source-free scriptorium-frozen-diagnostic-v1 artifact as HTML." + ) + parser.add_argument("artifact", type=Path) + parser.add_argument("--title", help="Display title; defaults to the artifact candidate ID") + parser.add_argument("--output", type=Path) + args = parser.parse_args(argv) + try: + artifact = json.loads( + args.artifact.read_text(encoding="utf-8"), object_pairs_hook=_unique_keys + ) + rendered = render_html(_mapping(artifact, str(args.artifact)), title=args.title) + if args.output is None: + print(rendered, end="") + else: + _write_new(args.output, rendered) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + parser.error(str(exc)) + return 0 + + +def _unique_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise FrozenDiagnosticReportError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def _unit(metric_id: str) -> str: + if metric_id.endswith(".per_1000_words"): + return "events / 1,000 words" + if metric_id.endswith("_percent"): + return "% (delta: percentage points)" + if metric_id == "fantlab.general.mean_word_length_chars": + return "characters / word" + if metric_id in { + "fantlab.general.mean_sentence_length_chars", + "fantlab.dialogue.mean_narration_sentence_length_chars", + "fantlab.dialogue.mean_dialogue_sentence_length_chars", + }: + return "characters / sentence" + if metric_id.endswith("_chars") or metric_id.endswith(".characters"): + return "characters" + if ".uasz_" in metric_id: + return "mean distinct dictionary words / " + metric_id.rsplit("_", 1)[1] + "-word window" + if metric_id.startswith("fantlab.vocabulary."): + return "distinct words (definitions differ)" + if metric_id.endswith(".words"): + return "word tokens" + return "unspecified in artifact" + + +def _mapping(value: object, label: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise FrozenDiagnosticReportError(f"{label} must be an object") + return value + + +def _text(value: object, label: str, limit: int) -> str: + if not isinstance(value, str) or not value.strip(): + raise FrozenDiagnosticReportError(f"{label} must be non-empty text") + result = value.strip() + if len(result) > limit: + raise FrozenDiagnosticReportError(f"{label} is too long") + return result + + +def _positive_int(value: object, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise FrozenDiagnosticReportError(f"{label} must be a positive integer") + return value + + +def _number(value: object, label: str) -> int | float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise FrozenDiagnosticReportError(f"{label} must be numeric") + try: + number = float(value) + except OverflowError as exc: + raise FrozenDiagnosticReportError(f"{label} is outside the supported numeric range") from exc + if not math.isfinite(number): + raise FrozenDiagnosticReportError(f"{label} must be finite") + return value + + +def _sha256(value: object, label: str) -> str: + if not isinstance(value, str) or HEX64_RE.fullmatch(value) is None: + raise FrozenDiagnosticReportError(f"{label} must be a lowercase SHA-256") + return value + + +def _repo_json_path(value: object, label: str) -> str: + text = _text(value, label, 500) + if ( + text != value or "\\" in text or ":" in text + or PurePosixPath(text).is_absolute() + or any(part in {"", ".", ".."} for part in text.split("/")) + or not text.endswith(".json") + ): + raise FrozenDiagnosticReportError(f"{label} must be a safe repository JSON path") + return text + + +def _fmt(value: object) -> str: + if value is None: + return "— (missing)" + number = _number(value, "display value") + if isinstance(number, int) or number.is_integer(): + return f"{int(number):,}" + if number and abs(number) < 0.000001: + return f"{number:.6g}" + return f"{number:,.6f}".rstrip("0").rstrip(".") + + +def _signed(value: object) -> str: + if value is None: + return "— (missing)" + number = _number(value, "display delta") + if number == 0: + return "0" + return ("+" if number > 0 else "") + _fmt(number) + + +def _esc(value: object) -> str: + return html.escape(str(value), quote=True) + + +def _card(value: object, label: str) -> str: + return ( + '
    ' + f'{_esc(value)}{_esc(label)}
    ' + ) + + +def _write_new(path: Path, content: str) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666) + except FileExistsError as exc: + raise FrozenDiagnosticReportError(f"output already exists: {path}") from exc + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + except Exception: + try: + path.unlink() + except OSError: + pass + raise + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scriptorium/metric_labels.py b/scriptorium/metric_labels.py new file mode 100644 index 00000000..79865bd3 --- /dev/null +++ b/scriptorium/metric_labels.py @@ -0,0 +1,31 @@ +"""Reader labels for the versioned metric IDs; no metric calculations.""" +from typing import Final + +METRIC_LABELS: Final = { + "fantlab.general.characters": "Characters", + "fantlab.general.words": "Words", + "scriptorium.general.sentences": "Sentences (Scriptorium)", + "fantlab.general.mean_word_length_chars": "Mean word length", + "fantlab.general.mean_sentence_length_chars": "Mean sentence length", + "fantlab.dialogue.mean_narration_sentence_length_chars": "Mean narration sentence length", + "fantlab.dialogue.mean_dialogue_sentence_length_chars": "Mean dialogue sentence length", + "fantlab.dialogue.share_percent": "Dialogue character share", + "fantlab.dialogue.author_text_inside_dialogue_percent": "Author remarks within dialogue", + "fantlab.vocabulary.unique_words": "Distinct surface words", + "fantlab.vocabulary.active_dictionary": "Distinct dictionary words", + "fantlab.vocabulary.active_nondictionary": "Distinct out-of-dictionary words", + "fantlab.vocabulary.uasz_3000": "Dictionary variety · 3,000-word window", + "fantlab.vocabulary.uasz_10000": "Dictionary variety · 10,000-word window", + "fantlab.vocabulary.uasz_100000": "Dictionary variety · 100,000-word window", + **{f"fantlab.punctuation.{metric}.per_1000_words": f"{label} per 1,000 words" + for metric, label in { + "comma": "Commas", "period": "Periods", "dash": "Dashes", + "exclamation": "Exclamation marks", "question": "Question marks", + "ellipsis": "Ellipses", "exclamation_ellipsis": "Exclamation–ellipsis sequences", + "question_ellipsis": "Question–ellipsis sequences", + "triple_exclamation": "Triple-exclamation sequences", + "question_exclamation": "Question–exclamation sequences", + "quote": "Quotation marks", "parentheses": "Opening parentheses", + "colon": "Colons", "semicolon": "Semicolons", + }.items()}, +} diff --git a/scriptorium/site_frozen_diagnostic.py b/scriptorium/site_frozen_diagnostic.py new file mode 100644 index 00000000..2c01eabd --- /dev/null +++ b/scriptorium/site_frozen_diagnostic.py @@ -0,0 +1,73 @@ +"""Publish only the explicitly reviewed frozen full-work diagnostic snapshot. + +Digest approval is a publication boundary, not an assertion of FantLab source identity. +The historical artifact stays immutable; new snapshots require a reviewed registration. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from hashlib import sha256 +import json +from typing import Any + +from . import frozen_diagnostic_report as report + + +FROZEN_PROFILE = report.SCHEMA_VERSION +ARTIFACT_PATH = "benchmarks/fantlab/work270306-wikisource-diagnostic.json" +ARTIFACT_SHA256 = "2a25c3d2df91c2f26b283ec40055ca20248f865cffadc932026cf9c98b5d0e8b" +ENTRY_ID = "tolstoy-anna-karenina-full-work-diagnostic-v1" +SLUG = "anna-karenina-full-work-diagnostic" +_DECLARATION = { + "entry_id": ENTRY_ID, + "kind": "work_showcase", + "slug": SLUG, + "artifact_path": ARTIFACT_PATH, + "artifact_schema": FROZEN_PROFILE, + "publication_status": "diagnostic", + "benchmark_admissibility": "diagnostic_only", + "corpus_admissibility": "not_admissible", + "compatibility_claim": "inferred", + "source_text_included": False, +} + + +def prepare( + entry: Mapping[str, Any], artifact: Mapping[str, Any], raw: bytes | None +) -> dict[str, Any]: + """Validate the publication registration and reuse the standalone report renderer.""" + if set(entry) != {*_DECLARATION, "title"}: + raise ValueError("frozen diagnostic declaration keys drift") + for key, expected in _DECLARATION.items(): + actual = entry.get(key) + if type(actual) is not type(expected) or actual != expected: + raise ValueError(f"frozen diagnostic declaration mismatch: {key}") + if not isinstance(raw, bytes) or sha256(raw).hexdigest() != ARTIFACT_SHA256: + raise ValueError("frozen diagnostic bytes do not match the reviewed SHA-256") + if json.loads(raw) != artifact: + raise ValueError("frozen diagnostic object differs from its verified bytes") + value = report.validate_artifact(artifact) + if value["candidate_id"] != "tolstoy-anna-karenina-ru": + raise ValueError("frozen diagnostic candidate identity drift") + title = entry["title"] + rendered = report.render_html(value, title=title) + # The reused renderer owns all metric tables, values, units and historical labels. + # Add only navigation to the site's work list and a focusable skip-link destination. + if rendered.count("
    ") != 1 or rendered.count('
    ') != 1: + raise ValueError("frozen diagnostic navigation integration marker drift") + rendered = rendered.replace( + "
    ", + '
    ', + 1, + ).replace('
    ', '
    ', 1) + return { + **_DECLARATION, + "render_mode": "frozen_diagnostic", + "title": title, + "author": "Leo Tolstoy", + "work_title": "Anna Karenina", + "metric_count": report.summarize(value)["metric_count"], + "report_html": rendered, + } diff --git a/scriptorium/site_renderer.py b/scriptorium/site_renderer.py index 0f60f894..9d0555ee 100644 --- a/scriptorium/site_renderer.py +++ b/scriptorium/site_renderer.py @@ -19,8 +19,9 @@ from urllib.parse import urlsplit from .publication import validate_compatibility_claim +from . import site_frozen_diagnostic -GENERATOR_PROFILE = "scriptorium-static-site-v1" +GENERATOR_PROFILE = "scriptorium-static-site-v2" MANIFEST_PROFILE = "scriptorium-publication-manifest-v1" PROVENANCE_SHOWCASE_PROFILE = "scriptorium-work-provenance-showcase-v1" _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") @@ -50,6 +51,7 @@ "scriptorium-deterministic-metrics-v1", "scriptorium-deterministic-metrics-v2", PROVENANCE_SHOWCASE_PROFILE, + site_frozen_diagnostic.FROZEN_PROFILE, } ) _PROVENANCE_TOP_LEVEL_KEYS = frozenset( @@ -450,8 +452,15 @@ def _validate_provenance_showcase( } -def _validate_showcase(entry: Mapping[str, Any], artifact: Mapping[str, Any]) -> dict[str, Any]: +def _validate_showcase( + entry: Mapping[str, Any], artifact: Mapping[str, Any], artifact_raw: bytes | None = None +) -> dict[str, Any]: common = _validate_common_entry(entry) + if common[2] == site_frozen_diagnostic.FROZEN_PROFILE: + try: + return site_frozen_diagnostic.prepare(entry, artifact, artifact_raw) + except ValueError as exc: + raise PublicationBuildError(str(exc)) from exc if common[2] == PROVENANCE_SHOWCASE_PROFILE: return _validate_provenance_showcase(entry, artifact, common) return _validate_metric_showcase(entry, artifact, common) @@ -480,6 +489,7 @@ def _page(title: str, stylesheet_href: str, body: str) -> str: f"{_escape(title)}\n" f'\n' "\n\n" + '\n' f"{body}\n" f'
    Generated by {_escape(GENERATOR_PROFILE)} from allow-listed derived artifacts. No source prose is published.
    \n' "\n\n" @@ -489,7 +499,10 @@ def _page(title: str, stylesheet_href: str, body: str) -> str: def _render_index(entries: Sequence[Mapping[str, Any]]) -> str: cards = [] for item in entries: - mode_note = " · provenance-only" if item.get("render_mode") == "provenance" else "" + mode_note = { + "provenance": " · provenance-only", + "frozen_diagnostic": " · full-work metrics", + }.get(item.get("render_mode"), "") cards.append( '
    ' f'

    {_escape(item["publication_status"])}{_escape(mode_note)}

    ' @@ -500,11 +513,24 @@ def _render_index(entries: Sequence[Mapping[str, Any]]) -> str: f'Corpus: {_escape(item["corpus_admissibility"])}

    ' "
    " ) + featured = next((item for item in entries if item.get("render_mode") == "frozen_diagnostic"), None) + action = "" + if featured is not None: + action = ( + '

    ' + f'' + f'Explore the {_escape(featured["metric_count"])}-metric full-work report → ' + 'Browse all evidence pages

    ' + ) body = ( - '

    Scriptorium

    Evidence-labelled literary analysis

    ' - '

    Public pages are generated only from repository artifacts explicitly allow-listed by the publication manifest. ' - 'Compatibility labels describe evidence, not visual similarity to FantLab.

    ' - f'
    {"".join(cards)}
    ' + '

    Scriptorium · literary analysis

    ' + '

    Explore the language behind a story.

    ' + '

    Inspect vocabulary, sentence rhythm, dialogue and punctuation in real literary texts. ' + 'Each result shows its measurements, source identity and limits.

    ' + + action + + '

    Full-work metrics, illustrative excerpts and provenance-only pages are labelled separately. ' + 'FantLab source identity remains unknown for the frozen full-work report: diagnostic evidence is not parity.

    ' + + f'
    {"".join(cards)}
    ' ) return _page("Scriptorium", "assets/site.css", body) @@ -531,7 +557,7 @@ def _render_metric_work(item: Mapping[str, Any]) -> str: ) revision_url = _escape(item["revision_url"]) body = ( - '
    ' + '
    ' f'

    {_escape(item["publication_status"])}

    ' f'

    {_escape(item["title"])}

    ' f'

    {_escape(item["author"])} · {_escape(item["work_title"])}

    ' @@ -541,7 +567,9 @@ def _render_metric_work(item: Mapping[str, Any]) -> str: f'Corpus: {_escape(item["corpus_admissibility"])}. ' f'{_escape(item["representativeness_note"])}' '

    Derived metrics

    ' - '
    ' + '
    MetricValueUnitEvidence
    ' + '' + '' f'{"".join(rows)}
    Derived metrics and their evidence labels
    MetricValueUnitEvidence
    ' '

    Provenance

    ' f'
    Provider
    {_escape(item["provider"])}
    ' @@ -558,7 +586,7 @@ def _render_provenance_work(item: Mapping[str, Any]) -> str: revision_url = _escape(item["revision_url"]) fantlab_url = _escape(item["fantlab_url"]) body = ( - '
    ' + '
    ' f'

    {_escape(item["publication_status"])} · provenance-only

    ' f'

    {_escape(item["title"])}

    ' f'

    {_escape(item["author"])} · {_escape(item["work_title"])}

    ' @@ -600,6 +628,8 @@ def _render_provenance_work(item: Mapping[str, Any]) -> str: def _render_work(item: Mapping[str, Any]) -> str: + if item.get("render_mode") == "frozen_diagnostic": + return item["report_html"] if item.get("render_mode") == "provenance": return _render_provenance_work(item) return _render_metric_work(item) @@ -607,13 +637,22 @@ def _render_work(item: Mapping[str, Any]) -> str: _SITE_CSS = """\ :root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5; } -body { margin: 0; } -main, footer { max-width: 72rem; margin: auto; padding: 2rem; } +* { box-sizing: border-box; } +body { margin: 0; background: Canvas; color: CanvasText; } +main, footer { max-width: 72rem; margin: auto; padding: clamp(1rem, 4vw, 2rem); } a { color: inherit; } +.skip-link:not(:focus) { position: absolute; clip-path: inset(50%); width: 1px; height: 1px; overflow: hidden; } +.skip-link:focus { position: absolute; z-index: 10; margin: .5rem; padding: .75rem; background: Canvas; } +*:focus-visible { outline: 3px solid currentColor; outline-offset: 3px; } +nav a, .card a, .actions a { display: inline-block; min-height: 44px; padding-block: .6rem; } +h1, .card h2 { font-family: Georgia, 'Times New Roman', serif; } +.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; margin-block: 1.5rem; } +.primary-action { border: 2px solid currentColor; border-radius: .5rem; padding-inline: 1rem; font-weight: 700; } +.notice { margin-block: 1.5rem; } h1 { font-size: clamp(2rem, 5vw, 4rem); line-height: 1.05; } .eyebrow { font-size: .8rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } .lede { font-size: 1.15rem; max-width: 52rem; } -.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); gap: 1rem; } +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr)); gap: 1rem; } .card, .notice { border: 1px solid currentColor; border-radius: .75rem; padding: 1rem; } .table-wrap { overflow-x: auto; } table { border-collapse: collapse; width: 100%; } @@ -623,6 +662,7 @@ def _render_work(item: Mapping[str, Any]) -> str: .status { font-weight: 700; } code { overflow-wrap: anywhere; } footer { opacity: .7; font-size: .9rem; } +@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } } """ @@ -671,7 +711,7 @@ def build_site( artifact_path = _safe_artifact_path(root, entry.get("artifact_path")) artifact, artifact_raw = _read_json(artifact_path) - item = _validate_showcase(entry, artifact) + item = _validate_showcase(entry, artifact, artifact_raw) render_items.append(item) input_rows.append( { diff --git a/site/publication-manifest.json b/site/publication-manifest.json index db133b13..8de8156f 100644 --- a/site/publication-manifest.json +++ b/site/publication-manifest.json @@ -60,6 +60,19 @@ "corpus_admissibility": "admissible", "compatibility_claim": "extension", "source_text_included": false + }, + { + "entry_id": "tolstoy-anna-karenina-full-work-diagnostic-v1", + "kind": "work_showcase", + "slug": "anna-karenina-full-work-diagnostic", + "title": "Anna Karenina — 28-metric full-work diagnostic", + "artifact_path": "benchmarks/fantlab/work270306-wikisource-diagnostic.json", + "artifact_schema": "scriptorium-frozen-diagnostic-v1", + "publication_status": "diagnostic", + "benchmark_admissibility": "diagnostic_only", + "corpus_admissibility": "not_admissible", + "compatibility_claim": "inferred", + "source_text_included": false } ] } diff --git a/tests/test_analyze.py b/tests/test_analyze.py new file mode 100644 index 00000000..78ec21b6 --- /dev/null +++ b/tests/test_analyze.py @@ -0,0 +1,105 @@ +import contextlib +from hashlib import sha256 +import io +import json +from pathlib import Path +import tempfile +import unittest + +from scriptorium.analyze import build_local_analysis, main, render_html + + +SOURCE = "Кот, кот.\n— Да? — сказал он. — Нет." + + +class LocalAnalysisTests(unittest.TestCase): + def test_build_binds_source_without_embedding_text(self): + source = SOURCE.encode("utf-8") + artifact = build_local_analysis(source, scriptorium_revision="abc123") + serialized = json.dumps(artifact, ensure_ascii=False) + self.assertEqual(artifact["schema_version"], "scriptorium-local-analysis-v1") + self.assertEqual(artifact["source"]["raw_sha256"], sha256(source).hexdigest()) + self.assertFalse(artifact["source"]["source_text_embedded"]) + self.assertEqual(artifact["analyzer"]["scriptorium_revision"], "abc123") + self.assertNotIn(SOURCE, serialized) + self.assertEqual(len(artifact["metrics"]), 29) + + def test_short_input_is_valid_but_marked_less_representative(self): + artifact = build_local_analysis(b"Short text.") + status = artifact["representativeness"] + self.assertFalse(status["meets_calibration_threshold"]) + self.assertEqual(status["calibration_threshold_characters"], 300000) + self.assertIn("less representative", status["note"]) + + def test_dictionary_dependency_is_explicit_and_not_source_text(self): + artifact = build_local_analysis( + "Кот пёс дракон".encode("utf-8"), + dictionary_words=["кот", "пёс"], + dictionary_profile="demo-v1", + ) + dependency = artifact["analyzer"]["dependencies"]["vocabulary_dictionary"] + self.assertEqual(dependency["profile"], "demo-v1") + self.assertEqual(dependency["lexeme_count"], 2) + self.assertEqual(artifact["metrics"]["fantlab.vocabulary.active_dictionary"]["value"], 2) + self.assertEqual(artifact["metrics"]["fantlab.vocabulary.active_nondictionary"]["value"], 1) + self.assertNotIn("дракон", json.dumps(artifact, ensure_ascii=False)) + + def test_html_is_readable_accessible_and_source_free(self): + artifact = build_local_analysis(SOURCE.encode("utf-8")) + html = render_html(artifact, title="Demo ") + self.assertIn("Demo <report>", html) + self.assertIn('href="#main-content"', html) + self.assertIn('id="main-content" tabindex="-1"', html) + self.assertIn('tabindex="0" role="region"', html) + self.assertIn('scope="col"', html) + self.assertIn("*:focus-visible", html) + self.assertIn("prefers-reduced-motion: reduce", html) + self.assertIn("characters / word", html) + self.assertIn("characters / sentence", html) + self.assertNotIn(SOURCE, html) + + def test_deterministic_json_and_html(self): + a = build_local_analysis(SOURCE.encode("utf-8"), scriptorium_revision="abc") + b = build_local_analysis(SOURCE.encode("utf-8"), scriptorium_revision="abc") + self.assertEqual(a, b) + self.assertEqual(render_html(a), render_html(b)) + + def test_cli_writes_json_and_html_create_only(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.txt" + json_out = root / "analysis.json" + html_out = root / "analysis.html" + source.write_text(SOURCE, encoding="utf-8") + self.assertEqual(main([str(source), "--output", str(json_out)]), 0) + self.assertEqual( + main([str(source), "--format", "html", "--title", "Reader report", "--output", str(html_out)]), + 0, + ) + self.assertEqual(json.loads(json_out.read_text(encoding="utf-8"))["schema_version"], "scriptorium-local-analysis-v1") + self.assertIn("Reader report", html_out.read_text(encoding="utf-8")) + before = html_out.read_bytes() + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + main([str(source), "--format", "html", "--output", str(html_out)]) + self.assertEqual(html_out.read_bytes(), before) + + def test_cli_dictionary_pair_is_fail_closed(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "input.txt" + source.write_text(SOURCE, encoding="utf-8") + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + main([str(source), "--dictionary-profile", "demo-v1"]) + + def test_cli_rejects_invalid_utf8(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "input.txt" + source.write_bytes(b"\xff\xfe") + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + main([str(source)]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_analyze_csv.py b/tests/test_analyze_csv.py new file mode 100644 index 00000000..6e6cf111 --- /dev/null +++ b/tests/test_analyze_csv.py @@ -0,0 +1,149 @@ +"""CSV export regressions exercised against the exact PR #270 metric core.""" +from __future__ import annotations + +import contextlib +import copy +import csv +from hashlib import sha256 +import io +from pathlib import Path +import tempfile +import unittest + +from scriptorium.analyze import build_local_analysis, main, render_csv + + +TEXT = "Кот, кот!\n— Здравствуйте? — спросил он.\nДругая строка." + + +def table(artifact): + return list(csv.DictReader(io.StringIO(render_csv(artifact)))) + + +class AnalysisCsvTests(unittest.TestCase): + def test_all_29_real_engine_metrics_are_present_with_stable_ids(self): + artifact = build_local_analysis(TEXT.encode("utf-8")) + rows = table(artifact) + self.assertEqual(len(rows), 29) + self.assertEqual([r["metric_id"] for r in rows], sorted(artifact["metrics"])) + self.assertEqual(len(rows[0]), 18) + self.assertEqual({r["status"] for r in rows}, {"inferred", "extension"}) + + def test_values_and_raw_counts_exactly_match_the_real_engine(self): + artifact = build_local_analysis(TEXT.encode("utf-8")) + for row in table(artifact): + metric = artifact["metrics"][row["metric_id"]] + expected = "" if metric["value"] is None else str(metric["value"]) + self.assertEqual(row["value"], expected) + self.assertEqual(row["raw_count"], "" if metric.get("raw_count") is None else str(metric["raw_count"])) + + def test_unavailable_remains_blank_not_zero(self): + artifact = build_local_analysis(b"") + rows = table(artifact) + unavailable = [r for r in rows if artifact["metrics"][r["metric_id"]]["value"] is None] + self.assertTrue(unavailable) + self.assertTrue(all(r["value"] == "" for r in unavailable)) + + def test_reproducible_source_and_profile_identity_on_every_row(self): + data = TEXT.encode("utf-8") + artifact = build_local_analysis(data) + rows = table(artifact) + for row in rows: + self.assertEqual(row["raw_sha256"], sha256(data).hexdigest()) + self.assertEqual(row["normalized_sha256"], artifact["source"]["normalized_sha256"]) + self.assertEqual(row["metric_contract_id"], artifact["metric_contract_id"]) + self.assertEqual(row["csv_schema_version"], "scriptorium-local-analysis-csv-v1") + for key in ("normalization", "metrics", "dialogue", "vocabulary", "punctuation"): + self.assertEqual(row[f"{key}_profile"], artifact["analyzer"]["profiles"][key]) + self.assertEqual(row["dictionary_profile"], "") + self.assertEqual(render_csv(artifact), render_csv(build_local_analysis(data))) + + def test_no_source_text_or_dictionary_lexemes_are_exported(self): + artifact = build_local_analysis( + "Кот дракон-единорог".encode("utf-8"), + dictionary_words=["дракон-единорог"], dictionary_profile="demo-v1", + ) + result = render_csv(artifact) + self.assertNotIn("Кот", result) + self.assertNotIn("дракон-единорог", result) + self.assertEqual(table(artifact)[0]["dictionary_profile"], "demo-v1") + self.assertEqual(table(artifact)[0]["dictionary_sha256"], artifact["analyzer"]["dependencies"]["vocabulary_dictionary"]["normalized_lexemes_sha256"]) + + def test_rejects_spreadsheet_formulas_from_user_controlled_profiles(self): + for malicious in ('=HYPERLINK("https://invalid.example")', '+SUM(1,2)', '-cmd', '@SUM(1,2)', '\t=1+1'): + with self.subTest(malicious=malicious): + artifact = build_local_analysis(TEXT.encode("utf-8"), dictionary_words=["кот"], dictionary_profile=malicious) + value = table(artifact)[0]["dictionary_profile"] + self.assertTrue(value.startswith("'"), value) + self.assertEqual(value[1:], malicious.strip()) + + def test_arbitrary_precision_float_not_truncated_in_csv(self): + artifact = build_local_analysis(TEXT.encode("utf-8")) + artifact["metrics"]["fantlab.general.mean_word_length_chars"]["value"] = 5.142857142857143 + rows = {r["metric_id"]: r for r in table(artifact)} + self.assertEqual(rows["fantlab.general.mean_word_length_chars"]["value"], "5.142857142857143") + + def test_inconsistent_types_and_nonfinite_values_fail_closed(self): + original = build_local_analysis(TEXT.encode("utf-8")) + for invalid in (True, float("inf"), float("nan"), "=1+1"): + with self.subTest(value=str(invalid)): + artifact = copy.deepcopy(original) + artifact["metrics"]["fantlab.general.words"]["value"] = invalid + with self.assertRaises(ValueError): + render_csv(artifact) + artifact = copy.deepcopy(original) + artifact["metrics"]["fantlab.general.words"]["compatibility_status"] = "reproduced" + with self.assertRaises(ValueError): + render_csv(artifact) + artifact = copy.deepcopy(original) + artifact["source"]["source_text_embedded"] = True + with self.assertRaises(ValueError): + render_csv(artifact) + + def test_missing_provenance_is_not_silently_exported(self): + original = build_local_analysis(TEXT.encode("utf-8")) + for obj_path in (("source", "raw_sha256"), ("source", "normalized_sha256"), + ("analyzer", "profiles", "metrics")): + with self.subTest(path=obj_path): + artifact = copy.deepcopy(original) + parent = artifact + for key in obj_path[:-1]: + parent = parent[key] + parent[obj_path[-1]] = None + with self.assertRaises(ValueError): + render_csv(artifact) + + def test_cli_writes_csv_create_only_and_preserves_source_alias(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "manuscript.txt" + output = root / "analysis.csv" + source.write_text(TEXT, encoding="utf-8") + self.assertEqual(main([str(source), "--format", "csv", "--output", str(output)]), 0) + with output.open(encoding="utf-8", newline="") as stream: + self.assertEqual(len(list(csv.DictReader(stream))), 29) + before = output.read_bytes() + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + main([str(source), "--format", "csv", "--output", str(output)]) + self.assertEqual(output.read_bytes(), before) + manuscript_before = source.read_bytes() + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + main([str(source), "--format", "csv", "--output", str(source)]) + self.assertEqual(source.read_bytes(), manuscript_before) + alias = root / "source-alias.csv" + alias.symlink_to(source) + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + main([str(source), "--format", "csv", "--output", str(alias)]) + self.assertEqual(source.read_bytes(), manuscript_before) + + def test_cli_stdout_matches_serialized_artifact(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "input.txt" + source.write_text(TEXT, encoding="utf-8") + with contextlib.redirect_stdout(io.StringIO()) as stream: + self.assertEqual(main([str(source), "--format", "csv"]), 0) + self.assertEqual(stream.getvalue(), render_csv(build_local_analysis(source.read_bytes()))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_analyze_human_labels.py b/tests/test_analyze_human_labels.py new file mode 100644 index 00000000..50513a52 --- /dev/null +++ b/tests/test_analyze_human_labels.py @@ -0,0 +1,80 @@ +"""Offline report readability without changing scientific metric identity.""" +from __future__ import annotations + +import unittest + +from scriptorium.analyze import build_local_analysis, render_html +from scriptorium.metric_labels import METRIC_LABELS + + +class OfflineReadableMetricsTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.artifact = build_local_analysis("Лес шумит. Солнце светит!\n— Здравствуй, — сказала она.".encode("utf-8")) + + def test_all_current_metric_ids_have_clear_labels(self): + self.assertEqual(len(self.artifact["metrics"]), 29) + self.assertEqual(set(self.artifact["metrics"]), set(METRIC_LABELS)) + page = render_html(self.artifact) + for metric_id, label in METRIC_LABELS.items(): + with self.subTest(metric=metric_id): + self.assertIn(f'data-label="{label}"', page) + self.assertIn(f'{label}', page) + self.assertIn(f'aria-label="{label}, metric ID {metric_id}"', page) + self.assertIn(f'{metric_id}', page) + + def test_names_are_readable_without_css_or_javascript(self): + page = render_html(self.artifact) + self.assertIn('Mean sentence length', page) + self.assertIn('Dialogue character share', page) + self.assertIn('Distinct surface words', page) + self.assertIn('scope="row"', page) + self.assertNotIn('At a glance', page) + for label, metric_id in ( + ('Words', 'fantlab.general.words'), + ('Sentences · Scriptorium', 'scriptorium.general.sentences'), + ('Dialogue share', 'fantlab.dialogue.share_percent'), + ('Distinct surface words', 'fantlab.vocabulary.unique_words'), + ): + with self.subTest(metric=metric_id): + self.assertIn(f'
    {label}
    ', page) + self.assertIn(f'
    {_format_value(self.artifact["metrics"][metric_id]["value"])}', page) + self.assertIn('grid-template-columns: repeat(auto-fit', page) + + def test_missing_highlight_displays_unavailable_not_zero(self): + import copy + data = copy.deepcopy(self.artifact) + data['metrics']['fantlab.dialogue.share_percent']['value'] = None + page = render_html(data) + self.assertIn('
    Dialogue share
    Unavailable', page) + self.assertNotIn('
    Dialogue share
    0', page) + + def test_unknown_extension_label_is_escaped_and_identified(self): + data = dict(self.artifact) + metrics = dict(self.artifact["metrics"]) + metrics['scriptorium.other.'] = { + "value": 1, "unit": "items", "compatibility_status": "extension", + "definition_evidence": "scriptorium_extension", + } + data["metrics"] = metrics + page = render_html(data) + self.assertIn('scriptorium.other.<img>', page) + self.assertNotIn('', page) + self.assertGreaterEqual(page.count('scriptorium.other.<img>'), 3) + + def test_rendering_does_not_mutate_exported_metrics(self): + import json + before = json.dumps(self.artifact, ensure_ascii=False, sort_keys=True) + first = render_html(self.artifact) + self.assertEqual(first, render_html(self.artifact)) + self.assertEqual(before, json.dumps(self.artifact, ensure_ascii=False, sort_keys=True)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_analyze_precision.py b/tests/test_analyze_precision.py new file mode 100644 index 00000000..44e237cb --- /dev/null +++ b/tests/test_analyze_precision.py @@ -0,0 +1,107 @@ +"""Exact PR #270 renderer integration: scientific output fidelity, not parity. + +Run via ``python -m unittest discover -s tests -p test_analyze_precision.py -v``. +""" +from __future__ import annotations + +from decimal import Decimal +from html.parser import HTMLParser +import json +import unittest + +from scriptorium.analyze import build_local_analysis, render_html, _format_value + + +class ReportRowParser(HTMLParser): + """Read the actual rendered table cells without a browser dependency.""" + def __init__(self): + super().__init__(convert_charrefs=True) + self.rows = {} + self.in_row = False + self.active_cell = None + self.cells = [] + self.metric_id = "" + self.in_metric_id = False + + def handle_starttag(self, tag, attrs): + if tag == "tr": + self.in_row = True + self.cells = [] + self.metric_id = "" + elif tag == "code" and dict(attrs).get("class") == "metric-id": + self.in_metric_id = True + elif self.in_row and tag in ("th", "td"): + self.active_cell = [tag, ""] + + def handle_data(self, data): + if self.in_metric_id: + self.metric_id += data + if self.active_cell is not None: + self.active_cell[1] += data + + def handle_endtag(self, tag): + if tag == "code": + self.in_metric_id = False + if self.active_cell is not None and tag == self.active_cell[0]: + self.cells.append((self.active_cell[0], self.active_cell[1].strip())) + self.active_cell = None + if tag == "tr": + if len(self.cells) == 5 and self.cells[0][0] == "th" and self.cells[1][0] == "td": + self.rows[self.metric_id] = self.cells[1][1] + self.in_row = False + + +class LocalHtmlNumericFidelityTests(unittest.TestCase): + def setUp(self): + self.manuscript = "Кот сидел. Птица летела.\n— Сколько слов? — Десять!" + self.report = build_local_analysis(self.manuscript.encode("utf-8")) + + def test_actual_engine_all_29_values_match_json_numeric_lexemes(self): + html = render_html(self.report) + parser = ReportRowParser() + parser.feed(html) + native = json.loads(json.dumps(self.report, ensure_ascii=False, allow_nan=False)) + self.assertEqual(len(native["metrics"]), 29) + self.assertEqual(set(parser.rows), set(native["metrics"])) + values = native["metrics"] + real_float_count = 0 + for metric_id, row in values.items(): + value = row["value"] + displayed = parser.rows[metric_id] + if value is None: + self.assertEqual(displayed, "Unavailable", metric_id) + elif isinstance(value, int): + self.assertEqual(displayed.replace(",", ""), str(value), metric_id) + else: + real_float_count += 1 + self.assertEqual(displayed, repr(value), metric_id) + self.assertGreaterEqual(real_float_count, 1) + self.assertNotIn(self.manuscript, html) + self.assertIn("not measurement accuracy", html) + self.assertIn("not parity claims", html) + + def test_long_binary64_values_are_not_rounded(self): + metric = self.report["metrics"]["fantlab.general.mean_word_length_chars"] + for value in (0.032551102993042313, 1.2345678901234567, + 1.234567891234567e-8, 1e50, -0.0): + with self.subTest(value=value): + metric["value"] = value + html = render_html(self.report) + self.assertIn("" + repr(value) + "", html) + self.assertEqual(Decimal(_format_value(value)), Decimal(repr(value))) + + def test_nonfinite_boolean_int_and_null_are_preserved_or_rejected(self): + metric = self.report["metrics"]["fantlab.general.mean_word_length_chars"] + for value in (float("nan"), float("inf"), -float("inf"), True): + with self.subTest(value=str(value)): + metric["value"] = value + with self.assertRaises(ValueError): + render_html(self.report) + metric["value"] = None + self.assertIn("Unavailable", render_html(self.report)) + self.report["metrics"]["fantlab.general.characters"]["value"] = 9007199254740993 + self.assertIn("9,007,199,254,740,993", render_html(self.report)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_analyze_safety.py b/tests/test_analyze_safety.py new file mode 100644 index 00000000..816ae4c9 --- /dev/null +++ b/tests/test_analyze_safety.py @@ -0,0 +1,172 @@ +import contextlib +import copy +from hashlib import sha256 +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from scriptorium import analyze +from scriptorium.metrics import analyze_deterministic_metrics +from scriptorium.text import normalize_text + + +class LocalAnalysisSafetyTests(unittest.TestCase): + def fail_cli(self, argv): + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as error: + analyze.main(argv) + self.assertEqual(error.exception.code, 2) + + def test_actual_engine_values_profiles_and_dependencies_are_preserved(self): + raw = "Е\u0308лка\r\n— Кот? — сказал он.\rКот-пёс!".encode() + kwargs = dict(dictionary_words=["кот", "ёлка"], dictionary_profile="fixture-v1") + direct = analyze_deterministic_metrics(raw.decode(), **kwargs) + result = analyze.build_local_analysis(raw, **kwargs) + self.assertEqual(result["metrics"], direct["metrics"]) + self.assertEqual(result["analyzer"]["profiles"], direct["profiles"]) + self.assertEqual(result["analyzer"]["dependencies"], direct["dependencies"]) + self.assertEqual(result["source"]["normalized_sha256"], sha256(normalize_text(raw.decode()).encode()).hexdigest()) + self.assertNotEqual(result["source"]["raw_sha256"], result["source"]["normalized_sha256"]) + + def test_empty_input_preserves_undefined_mean_and_zero_count(self): + result = analyze.build_local_analysis(b"") + self.assertEqual(result["metrics"]["fantlab.general.words"]["value"], 0) + self.assertIsNone(result["metrics"]["fantlab.general.mean_word_length_chars"]["value"]) + rendered = analyze.render_html(result) + self.assertIn("Unavailable", rendered) + self.assertIn("nonzero", rendered) + + def test_supplied_dictionary_does_not_make_incomplete_window_zero(self): + result = analyze.build_local_analysis("Кот".encode(), dictionary_words=["кот"], dictionary_profile="fixture") + self.assertEqual(result["metrics"]["fantlab.vocabulary.active_dictionary"]["value"], 1) + self.assertIsNone(result["metrics"]["fantlab.vocabulary.uasz_3000"]["value"]) + self.assertIn("complete vocabulary window", analyze.render_html(result)) + + def test_threshold_is_not_corpus_admission(self): + for length, meets in ((299999, False), (300000, True)): + with self.subTest(length=length): + result = analyze.build_local_analysis(b" " * length) + self.assertEqual(result["representativeness"]["meets_calibration_threshold"], meets) + self.assertIn("separate gates", result["representativeness"]["note"]) + + def test_html_shows_all_rows_profiles_and_no_remote_assets(self): + result = analyze.build_local_analysis("Уникальная рукопись".encode()) + rendered = analyze.render_html(result) + self.assertEqual(rendered.count('scope="row"'), 29) + self.assertEqual(rendered.count(""), 4) + for profile in result["analyzer"]["profiles"].values(): + self.assertIn(profile, rendered) + self.assertNotIn("Other", analyze.render_html(result)) + + def test_render_rejects_nonfinite_or_promoted_result(self): + original = analyze.build_local_analysis(b"abc") + for value in (float("inf"), float("nan"), True): + bad = copy.deepcopy(original) + bad["metrics"]["fantlab.general.words"]["value"] = value + with self.assertRaises(ValueError): + analyze.render_html(bad) + original["metrics"]["fantlab.general.words"]["compatibility_status"] = "reproduced" + with self.assertRaises(ValueError): + analyze.render_html(original) + + def test_cli_real_dictionary_and_private_payload(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source, dictionary = root / "source.txt", root / "dict.txt" + source.write_text("Кот пёс дракон", encoding="utf-8") + dictionary.write_text("кот\nпёс\nSECRET_DICTIONARY_TOKEN\n", encoding="utf-8") + for fmt in ("json", "html"): + output = root / f"out.{fmt}" + self.assertEqual(analyze.main([str(source), "--dictionary", str(dictionary), "--dictionary-profile", "test-v1", "--format", fmt, "--output", str(output)]), 0) + rendered = output.read_text(encoding="utf-8") + self.assertNotIn("SECRET_DICTIONARY_TOKEN", rendered) + self.assertNotIn("дракон", rendered) + if fmt == "json": + result = json.loads(rendered) + self.assertEqual(result["metrics"]["fantlab.vocabulary.active_dictionary"]["value"], 2) + + def test_invalid_dictionary_utf8_and_blank_profile_create_no_output(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source, dictionary, output = root / "in.txt", root / "dict.txt", root / "out.json" + source.write_bytes(b"abc") + dictionary.write_bytes(b"\xff") + self.fail_cli([str(source), "--dictionary", str(dictionary), "--dictionary-profile", "v1", "--output", str(output)]) + dictionary.write_bytes(b"abc") + self.fail_cli([str(source), "--dictionary", str(dictionary), "--dictionary-profile", " ", "--output", str(output)]) + self.fail_cli([str(source), "--dictionary", str(dictionary), "--output", str(output)]) + self.assertFalse(output.exists()) + + def test_output_aliases_cannot_destroy_manuscript_or_dictionary(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source, dictionary = root / "in.txt", root / "dict.txt" + source.write_bytes(b"private manuscript") + dictionary.write_bytes(b"dictionary") + for target in (source, dictionary): + before = target.read_bytes() + self.fail_cli([str(source), "--output", str(target)]) + self.assertEqual(target.read_bytes(), before) + linked = root / "linked.txt" + os.link(source, linked) + self.fail_cli([str(source), "--output", str(linked)]) + self.assertEqual(source.read_bytes(), b"private manuscript") + + def test_dangling_symlink_is_preserved(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source, target, missing = root / "in.txt", root / "out.json", root / "missing" + source.write_bytes(b"abc") + try: + target.symlink_to(missing) + except (OSError, NotImplementedError): + self.skipTest("symlinks unavailable on this platform") + self.fail_cli([str(source), "--output", str(target)]) + self.assertTrue(target.is_symlink()) + self.assertFalse(missing.exists()) + + def test_failed_write_does_not_publish_partial_output(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + output = root / "report.json" + with patch.object(analyze.os, "fsync", side_effect=OSError("disk failure")): + with self.assertRaises(OSError): + analyze._write_new(output, "derived report") + self.assertFalse(output.exists()) + self.assertEqual(list(root.iterdir()), []) + + def test_atomic_publish_collision_preserves_existing_report(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + output = root / "report.json" + output.write_text("last valid report") + with self.assertRaises(FileExistsError): + analyze._write_new(output, "new report") + self.assertEqual(output.read_text(), "last valid report") + self.assertEqual(list(root.iterdir()), [output]) + + def test_output_has_private_permissions_on_posix(self): + if os.name != "posix": + self.skipTest("POSIX permission bits") + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "report.json" + analyze._write_new(path, "derived") + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_error_privacy.py b/tests/test_cli_error_privacy.py new file mode 100644 index 00000000..1514e06f --- /dev/null +++ b/tests/test_cli_error_privacy.py @@ -0,0 +1,107 @@ +"""Private-path leak regression for the installable Workbench single-text CLI.""" +from __future__ import annotations +import contextlib +import io +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from scriptorium import analyze + + +class LocalCliErrorPrivacyTests(unittest.TestCase): + def rejected(self, args, forbidden): + errors = io.StringIO() + with contextlib.redirect_stderr(errors), self.assertRaises(SystemExit) as caught: + analyze.main(args) + self.assertEqual(caught.exception.code, 2) + message = errors.getvalue() + for phrase in forbidden: + self.assertNotIn(phrase, message) + return message + + def test_existing_output_preserved_and_paths_private(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manuscript = root / 'PRIVATE-manuscript.txt' + report = root / 'PRIVATE-report.html' + manuscript.write_text('Кот идёт. Пёс спит.', encoding='utf-8') + report.write_bytes(b'OLD REPORT') + error = self.rejected([str(manuscript), '--format', 'html', '--output', str(report)], + [directory, manuscript.name, report.name]) + self.assertIn('already exists', error) + self.assertEqual(report.read_bytes(), b'OLD REPORT') + + def test_missing_manuscript_is_safe(self): + with tempfile.TemporaryDirectory() as directory: + manuscript = Path(directory) / 'PRIVATE-secret-work.txt' + error = self.rejected([str(manuscript)], [directory, manuscript.name]) + self.assertIn('regular file', error) + + def test_private_invalid_utf8_manuscript(self): + with tempfile.TemporaryDirectory() as directory: + manuscript = Path(directory) / 'PRIVATE-secret-work.txt' + manuscript.write_bytes(b'\xff') + error = self.rejected([str(manuscript)], [directory, manuscript.name]) + self.assertIn('manuscript must be UTF-8', error) + + def test_private_dictionary_invalid_utf8(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manuscript = root / 'PRIVATE-secret-work.txt' + dictionary = root / 'PRIVATE-words.txt' + manuscript.write_text('Кот идёт.', encoding='utf-8') + dictionary.write_bytes(b'\xff') + error = self.rejected([str(manuscript), '--dictionary', str(dictionary), + '--dictionary-profile', 'local-v1'], + [directory, manuscript.name, dictionary.name]) + self.assertIn('dictionary must be UTF-8', error) + + def test_dictionary_and_manuscript_regular_file_gate(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manuscript = root / 'PRIVATE-manuscript.txt' + dictionary = root / 'PRIVATE-words.txt' + manuscript.write_text('Кот идёт.', encoding='utf-8') + dictionary.mkdir() + error = self.rejected([str(manuscript), '--dictionary', str(dictionary), + '--dictionary-profile', 'local-v1'], + [directory, manuscript.name, dictionary.name]) + self.assertIn('regular file', error) + + def test_engine_exception_does_not_leak_original_text(self): + with tempfile.TemporaryDirectory() as directory: + manuscript = Path(directory) / 'PRIVATE-secret-work.txt' + manuscript.write_text('Кот идёт.', encoding='utf-8') + with patch.object(analyze, 'analyze_deterministic_metrics', + side_effect=ValueError('PRIVATE-ENGINE-AND-DICTIONARY')): + error = self.rejected([str(manuscript)], + [directory, manuscript.name, 'PRIVATE-ENGINE-AND-DICTIONARY']) + self.assertIn('unable to analyze', error) + + def test_unexpected_write_error_does_not_leak_output_path(self): + with tempfile.TemporaryDirectory() as directory: + manuscript = Path(directory) / 'PRIVATE-secret-work.txt' + report = Path(directory) / 'PRIVATE-report.json' + manuscript.write_text('Кот идёт.', encoding='utf-8') + with patch.object(analyze, '_write_new', + side_effect=OSError('PRIVATE-report.json: access denied')): + error = self.rejected([str(manuscript), '--output', str(report)], + [directory, manuscript.name, report.name, 'access denied']) + self.assertIn('unable to analyze', error) + self.assertFalse(report.exists()) + + def test_successful_json_and_html_still_work(self): + with tempfile.TemporaryDirectory() as directory: + manuscript = Path(directory) / 'work.txt' + manuscript.write_text('Кот идёт. Пёс спит.', encoding='utf-8') + for kind in ['json', 'html', 'csv']: + report = Path(directory) / f'{kind}.txt' + self.assertEqual(analyze.main([str(manuscript), '--format', kind, + '--output', str(report)]), 0) + self.assertTrue(report.stat().st_size > 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_frozen_diagnostic_report.py b/tests/test_frozen_diagnostic_report.py new file mode 100644 index 00000000..cf688a44 --- /dev/null +++ b/tests/test_frozen_diagnostic_report.py @@ -0,0 +1,271 @@ +import contextlib +import io +import hashlib +import copy +import json +from pathlib import Path +import tempfile +import unittest + +import scriptorium.frozen_diagnostic_report as report + + +def artifact(): + return { + "artifact_version": "scriptorium-frozen-diagnostic-v1", + "benchmark_id": "fantlab-work1-2022-09-19", + "candidate_id": "fixture-work", + "evidence": { + "hosted_run_id": 123, + "python_version": "3.13.15", + "scriptorium_revision": "abc123", + "reference_path": "benchmarks/fantlab/work1.json", + "source_manifest_path": "corpus/candidates/source-edition-traces/work.revisions.json", + "replay_integrity": "passed", + "chapter_count": 2, + "raw_sha256": "a" * 64, + "normalized_sha256": "b" * 64, + "source_text_committed": False, + "source_text_uploaded": False, + }, + "epistemic_status": { + "status": "diagnostic_only", + "fantlab_source_edition_match": "unknown", + "m2_parity_admissible": False, + "m2_source_matched_progress": "0/5", + "reason": "Source edition is not established.", + }, + "metrics": { + "fantlab.general.characters": { + "expected": 10, + "actual": 12, + "raw_delta": 2, + "result": "unresolved", + }, + "fantlab.vocabulary.active_dictionary": { + "expected": 5, + "actual": None, + "raw_delta": None, + "result": "not_run", + }, + "fantlab.punctuation.question.per_1000_words": { + "expected": 1.25, + "actual": 1.0, + "raw_delta": -0.25, + "result": "unresolved", + }, + }, + "omitted_metric_families": { + "pos": "Provider execution not available." + }, + "observations": [ + "A visible difference is diagnostic only.", + ], + } + + +class FrozenDiagnosticReportTests(unittest.TestCase): + def test_summary_keeps_missing_and_results_distinct(self): + self.assertEqual( + report.summarize(artifact()), + { + "metric_count": 3, + "actual_count": 2, + "missing_actual_count": 1, + "unresolved_count": 2, + "not_run_count": 1, + }, + ) + + def test_raw_delta_is_rechecked(self): + bad = artifact() + bad["metrics"]["fantlab.general.characters"]["raw_delta"] = 3 + with self.assertRaisesRegex(report.FrozenDiagnosticReportError, "raw_delta"): + report.validate_artifact(bad) + + def test_missing_actual_cannot_have_delta(self): + bad = artifact() + bad["metrics"]["fantlab.vocabulary.active_dictionary"]["raw_delta"] = 0 + with self.assertRaisesRegex(report.FrozenDiagnosticReportError, "without actual"): + report.validate_artifact(bad) + + def test_not_run_cannot_have_actual(self): + bad = artifact() + row = bad["metrics"]["fantlab.vocabulary.active_dictionary"] + row["actual"] = 5 + row["raw_delta"] = 0 + with self.assertRaisesRegex(report.FrozenDiagnosticReportError, "not_run"): + report.validate_artifact(bad) + + def test_pass_or_fail_is_rejected_for_source_unmatched_artifact(self): + for result in ("pass", "fail"): + bad = artifact() + bad["metrics"]["fantlab.general.characters"]["result"] = result + with self.subTest(result=result): + with self.assertRaisesRegex(report.FrozenDiagnosticReportError, "overclaim"): + report.validate_artifact(bad) + + def test_source_text_flags_fail_closed(self): + for key in ("source_text_committed", "source_text_uploaded"): + bad = artifact() + bad["evidence"][key] = True + with self.subTest(key=key): + with self.assertRaises(report.FrozenDiagnosticReportError): + report.validate_artifact(bad) + + def test_source_edition_and_m2_must_remain_open(self): + bad = artifact() + bad["epistemic_status"]["fantlab_source_edition_match"] = "exact" + with self.assertRaisesRegex(report.FrozenDiagnosticReportError, "source edition"): + report.validate_artifact(bad) + bad = artifact() + bad["epistemic_status"]["m2_parity_admissible"] = True + with self.assertRaisesRegex(report.FrozenDiagnosticReportError, "M2 parity"): + report.validate_artifact(bad) + + def test_html_groups_metric_families_and_preserves_unknown_as_dash(self): + rendered = report.render_html(artifact(), title="Example") + self.assertIn("

    General

    ", rendered) + self.assertIn("

    Vocabulary

    ", rendered) + self.assertIn("

    Punctuation

    ", rendered) + self.assertIn("—", rendered) + self.assertIn("not_run", rendered) + + def test_html_escapes_artifact_controlled_text(self): + value = artifact() + value["observations"] = [""] + rendered = report.render_html(value, title="") + self.assertIn("<script>", rendered) + self.assertIn("<img", rendered) + self.assertNotIn("", output.read_text(encoding="utf-8")) + with self.assertRaises(SystemExit): + report.main([str(source), "--output", str(output)]) + + + def test_canonical_repository_diagnostic_is_unchanged_and_readable(self): + source = Path(__file__).resolve().parents[1] / "benchmarks/fantlab/work270306-wikisource-diagnostic.json" + raw = source.read_bytes() + sha = hashlib.sha1(b"blob " + str(len(raw)).encode() + b"\0" + raw).hexdigest() + self.assertEqual(sha, "0d5e420abb45e2b9e178df75a479ab7cdd5a4150") + value = json.loads(raw) + self.assertEqual(report.summarize(value), { + "metric_count": 28, "actual_count": 23, "missing_actual_count": 5, + "unresolved_count": 26, "not_run_count": 2, + }) + rendered = report.render_html(value) + self.assertEqual(rendered.count('scope="row"'), 28) + self.assertIn("1,705,605", rendered) + self.assertIn("0/5", rendered) + self.assertIn(value["evidence"]["normalized_sha256"], rendered) + + def test_generic_title_comes_from_candidate_not_anna(self): + rendered = report.render_html(artifact()) + self.assertIn("fixture-work", rendered) + self.assertNotIn("Anna Karenina", rendered) + + def test_bad_status_type_has_controlled_error(self): + value = artifact() + value["metrics"]["fantlab.general.characters"]["result"] = [] + with self.assertRaises(report.FrozenDiagnosticReportError): + report.validate_artifact(value) + + def test_huge_numeric_value_has_controlled_error(self): + value = artifact() + value["metrics"]["fantlab.general.characters"]["expected"] = 10 ** 1000 + with self.assertRaises(report.FrozenDiagnosticReportError): + report.validate_artifact(value) + + def test_large_integer_delta_does_not_lose_precision(self): + value = artifact() + row = value["metrics"]["fantlab.general.characters"] + row.update(expected=2 ** 53, actual=2 ** 53 + 1, raw_delta=1) + report.validate_artifact(value) + self.assertIn("9,007,199,254,740,993", report.render_html(value)) + row["raw_delta"] = 0 + with self.assertRaises(report.FrozenDiagnosticReportError): + report.validate_artifact(value) + + def test_tiny_nonzero_delta_does_not_display_as_zero(self): + self.assertEqual(report._signed(1e-10), "+1e-10") + self.assertEqual(report._signed(-1e-10), "-1e-10") + + def test_noncanonical_reference_paths_fail_closed(self): + for path in ("../secret.json", "a/../b.json", "C:/secret.json", + "C:\\secret.json", "a//b.json", "a/./b.json", " a/b.json"): + value = artifact() + value["evidence"]["reference_path"] = path + with self.subTest(path=path): + with self.assertRaises(report.FrozenDiagnosticReportError): + report.validate_artifact(value) + + def test_cli_rejects_duplicate_keys_without_creating_output(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source, output = root / "input.json", root / "out.html" + source.write_text('{"artifact_version": "bad", "artifact_version": "other"}', encoding="utf-8") + with contextlib.redirect_stderr(io.StringIO()) as errors: + with self.assertRaises(SystemExit) as raised: + report.main([str(source), "--output", str(output)]) + self.assertEqual(raised.exception.code, 2) + self.assertIn("duplicate JSON key", errors.getvalue()) + self.assertFalse(output.exists()) + + def test_invalid_artifact_preserves_existing_output(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source, output = root / "input.json", root / "out.html" + source.write_text("{}", encoding="utf-8") + output.write_text("last valid report", encoding="utf-8") + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + report.main([str(source), "--output", str(output)]) + self.assertEqual(output.read_text(encoding="utf-8"), "last valid report") + + def test_table_keyboard_access_units_and_historical_boundary(self): + rendered = report.render_html(artifact()) + self.assertIn('href="#metrics"', rendered) + self.assertIn('id="metrics"', rendered) + self.assertIn('tabindex="0" role="region"', rendered) + self.assertIn('', rendered) + self.assertIn('scope="col"', rendered) + self.assertIn("events / 1,000 words", rendered) + self.assertIn("Historical snapshot, not a new analyzer run", rendered) + self.assertIn("Missing is not zero", rendered) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_frozen_diagnostic_units.py b/tests/test_frozen_diagnostic_units.py new file mode 100644 index 00000000..a1f3d09c --- /dev/null +++ b/tests/test_frozen_diagnostic_units.py @@ -0,0 +1,57 @@ +"""Unit-label regressions; historical numeric values must not change.""" +import copy +import hashlib +import json +from pathlib import Path +import unittest + +from scriptorium import frozen_diagnostic_report as report + +MEAN_UNITS = { + "fantlab.general.mean_word_length_chars": "characters / word", + "fantlab.general.mean_sentence_length_chars": "characters / sentence", + "fantlab.dialogue.mean_narration_sentence_length_chars": "characters / sentence", + "fantlab.dialogue.mean_dialogue_sentence_length_chars": "characters / sentence", +} + + +class FrozenDiagnosticUnitTests(unittest.TestCase): + def test_mean_length_denominators(self): + for metric_id, expected in MEAN_UNITS.items(): + with self.subTest(metric_id=metric_id): + self.assertEqual(report._unit(metric_id), expected) + + def test_other_units_are_not_reclassified(self): + controls = { + "fantlab.general.characters": "characters", + "fantlab.general.words": "word tokens", + "fantlab.dialogue.share_percent": "% (delta: percentage points)", + "fantlab.punctuation.dash.per_1000_words": "events / 1,000 words", + "fantlab.extension.sentence_length_chars": "characters", + "fantlab.unknown": "unspecified in artifact", + } + for metric_id, expected in controls.items(): + with self.subTest(metric_id=metric_id): + self.assertEqual(report._unit(metric_id), expected) + + def test_canonical_html_keeps_four_denominators_and_frozen_bytes(self): + path = Path(__file__).resolve().parents[1] / "benchmarks/fantlab/work270306-wikisource-diagnostic.json" + raw = path.read_bytes() + self.assertEqual( + hashlib.sha1(b"blob " + str(len(raw)).encode() + b"\0" + raw).hexdigest(), + "0d5e420abb45e2b9e178df75a479ab7cdd5a4150", + ) + artifact = json.loads(raw) + before = copy.deepcopy(artifact) + rendered = report.render_html(artifact) + self.assertEqual(rendered.count("characters / word"), 1) + self.assertEqual(rendered.count("characters / sentence"), 3) + self.assertEqual(rendered.count('scope="row"'), 28) + self.assertNotIn("unspecified in artifact", rendered) + self.assertEqual(artifact, before) + self.assertEqual(report.render_html(artifact), rendered) + self.assertFalse(artifact["epistemic_status"]["m2_parity_admissible"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_local_analysis_input_limits.py b/tests/test_local_analysis_input_limits.py new file mode 100644 index 00000000..e9afa6c2 --- /dev/null +++ b/tests/test_local_analysis_input_limits.py @@ -0,0 +1,120 @@ +"""Bound local manuscript/dictionary reads before parsing large untrusted inputs.""" +import contextlib +import io +import json +import os +from pathlib import Path +import stat +import tempfile +import unittest +from unittest.mock import patch + +from scriptorium import analyze + + +class InputBoundsTests(unittest.TestCase): + def _reject_cli(self, args, *, forbidden=None): + error = io.StringIO() + with contextlib.redirect_stderr(error), self.assertRaises(SystemExit) as caught: + analyze.main(args) + self.assertEqual(caught.exception.code, 2) + if forbidden: + self.assertNotIn(forbidden, error.getvalue()) + return error.getvalue() + + def test_small_manuscript_keeps_actual_json_and_is_deterministic(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + source = root / "PRIVATE manuscript.txt" + source.write_text("Кот сидел.\n— Да!", encoding="utf-8") + a, b = root / "a.json", root / "b.json" + self.assertEqual(analyze.main([str(source), "--output", str(a)]), 0) + self.assertEqual(analyze.main([str(source), "--output", str(b)]), 0) + self.assertEqual(a.read_bytes(), b.read_bytes()) + artifact = json.loads(a.read_text(encoding="utf-8")) + self.assertEqual(len(artifact["metrics"]), 29) + self.assertEqual(artifact["source"]["byte_count"], len(source.read_bytes())) + self.assertNotIn(source.name, a.read_text(encoding="utf-8")) + + def test_precise_byte_limit_is_inclusive(self): + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) / "sample" + source.write_bytes(b"abcd") + self.assertEqual(analyze._read_bounded_regular_file(source, 4, "manuscript"), b"abcd") + with self.assertRaisesRegex(ValueError, "exceeds"): + analyze._read_bounded_regular_file(source, 3, "manuscript") + + def test_sparse_oversized_manuscript_is_rejected_before_read(self): + with tempfile.TemporaryDirectory() as folder: + source, output = Path(folder) / "PRIVATE-oversize.txt", Path(folder) / "report.json" + with source.open("wb") as handle: + handle.truncate(analyze.MAX_ANALYSIS_BYTES + 1) + error = self._reject_cli([str(source), "--output", str(output)], forbidden=source.name) + self.assertIn("byte input limit", error) + self.assertFalse(output.exists()) + + def test_oversized_dictionary_is_rejected_without_output(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + source, dictionary, output = root / "valid.txt", root / "PRIVATE-lexemes.txt", root / "report.html" + source.write_text("Книга.", encoding="utf-8") + with dictionary.open("wb") as handle: + handle.truncate(analyze.MAX_DICTIONARY_BYTES + 1) + error = self._reject_cli([str(source), "--dictionary", str(dictionary), + "--dictionary-profile", "fixture", "--format", "html", + "--output", str(output)], forbidden=dictionary.name) + self.assertIn("byte input limit", error) + self.assertFalse(output.exists()) + + def test_read_guard_detects_file_growth_after_stat(self): + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) / "growing.txt" + source.write_bytes(b"growing") + real_fstat = os.fstat + def old_stat(fd): + current = real_fstat(fd) + return os.stat_result((current.st_mode, current.st_ino, current.st_dev, + current.st_nlink, current.st_uid, current.st_gid, + 3, current.st_atime, current.st_mtime, current.st_ctime)) + with patch.object(analyze.os, "fstat", side_effect=old_stat): + with self.assertRaisesRegex(ValueError, "exceeds"): + analyze._read_bounded_regular_file(source, 4, "manuscript") + + def test_symlink_source_is_rejected_without_following(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + source, link, output = root / "secret-source.txt", root / "PRIVATE-link.txt", root / "out.csv" + source.write_text("My secret content", encoding="utf-8") + try: + link.symlink_to(source) + except (OSError, NotImplementedError): + self.skipTest("symlink unavailable") + error = self._reject_cli([str(link), "--format", "csv", "--output", str(output)], forbidden=link.name) + self.assertIn("non-symlink", error) + self.assertFalse(output.exists()) + self.assertEqual(source.read_text(encoding="utf-8"), "My secret content") + + def test_nonregular_fifo_rejected_without_blocking(self): + if not hasattr(os, "mkfifo") or not hasattr(os, "O_NONBLOCK"): + self.skipTest("requires POSIX FIFO and nonblocking open") + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) / "input.fifo" + os.mkfifo(source) + with self.assertRaisesRegex(ValueError, "regular file"): + analyze._read_bounded_regular_file(source, 4, "manuscript") + + def test_invalid_dictionary_utf8_rejected_create_only(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + source, dictionary, output = root / "text", root / "private-lexemes", root / "out.json" + source.write_bytes(b"hello") + dictionary.write_bytes(b"\xff") + error = self._reject_cli([str(source), "--dictionary", str(dictionary), + "--dictionary-profile", "demo", "--output", str(output)], + forbidden=dictionary.name) + self.assertIn("dictionary must be UTF-8", error) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reader_cli.py b/tests/test_reader_cli.py new file mode 100644 index 00000000..6f2b21e1 --- /dev/null +++ b/tests/test_reader_cli.py @@ -0,0 +1,108 @@ +"""Integration of the installed entry point with real canonical reader modules.""" +import contextlib +import copy +import csv +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from scriptorium import analyze, cli + +ROOT = Path(__file__).resolve().parents[1] +DIAGNOSTIC = ROOT / "benchmarks/fantlab/work270306-wikisource-diagnostic.json" + + +class ReaderCliTests(unittest.TestCase): + def test_help_lists_real_commands(self): + text = io.StringIO() + with contextlib.redirect_stdout(text): + self.assertEqual(cli.main(["--help"]), 0) + for command in ["analyze", "diagnostic", "site"]: + self.assertIn(command, text.getvalue()) + + def test_unknown_command_does_not_echo_private_input(self): + text = io.StringIO() + with contextlib.redirect_stderr(text): + self.assertEqual(cli.main(["PRIVATE-PATH"]), 2) + self.assertNotIn("PRIVATE-PATH", text.getvalue()) + + def test_real_analyzer_dispatch_three_formats(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + source = root / "private.txt" + source.write_text("Кот идёт. Пёс спит!", encoding="utf-8") + for kind in ["json", "html", "csv"]: + out = root / f"report.{kind}" + self.assertEqual(cli.main(["analyze", str(source), "--format", kind, "--output", str(out)]), 0) + text = out.read_text(encoding="utf-8") + self.assertNotIn("Кот идёт", text) + if kind == "csv": + self.assertEqual(len(list(csv.DictReader(io.StringIO(text)))), 29) + + def test_frozen_diagnostic_matches_canonical_renderer(self): + from scriptorium.frozen_diagnostic_report import render_html + with tempfile.TemporaryDirectory() as d: + out = Path(d) / "report.html" + self.assertEqual(cli.main(["diagnostic", str(DIAGNOSTIC), "--output", str(out)]), 0) + self.assertEqual(out.read_text(encoding="utf-8"), render_html(json.loads(DIAGNOSTIC.read_text(encoding="utf-8")))) + + def test_diagnostic_errors_are_private_and_preserve_output(self): + with tempfile.TemporaryDirectory() as d: + out = Path(d) / "PRIVATE-REPORT.html" + out.write_bytes(b"preserved") + errors = io.StringIO() + with contextlib.redirect_stderr(errors), self.assertRaises(SystemExit) as exc: + cli.main(["diagnostic", str(DIAGNOSTIC), "--output", str(out)]) + self.assertEqual(exc.exception.code, 2) + self.assertNotIn(d, errors.getvalue()) + self.assertNotIn(out.name, errors.getvalue()) + self.assertEqual(out.read_bytes(), b"preserved") + + def test_diagnostic_rejects_duplicate_keys_before_output(self): + with tempfile.TemporaryDirectory() as d: + source, out = Path(d) / "bad.json", Path(d) / "out.html" + source.write_text('{"artifact_version":1,"artifact_version":2}', encoding="utf-8") + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + cli.main(["diagnostic", str(source), "--output", str(out)]) + self.assertFalse(out.exists()) + + @unittest.skipUnless(hasattr(os, "mkfifo"), "FIFO is POSIX-specific") + def test_diagnostic_fifo_rejected_without_hanging(self): + with tempfile.TemporaryDirectory() as d: + source = Path(d) / "PRIVATE-FIFO" + os.mkfifo(source) + result = subprocess.run([sys.executable, "-m", "scriptorium", "diagnostic", str(source)], + cwd=ROOT, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 2) + self.assertNotIn(d, result.stderr) + + def test_csv_rejects_missing_metrics_and_unit_evidence(self): + original = analyze.build_local_analysis(b"A short example.") + bad = copy.deepcopy(original) + bad["metrics"].pop("fantlab.general.words") + with self.assertRaises(ValueError): + analyze.render_csv(bad) + for key in ["unit", "definition_evidence"]: + bad = copy.deepcopy(original) + bad["metrics"]["fantlab.general.words"][key] = "" + with self.assertRaises(ValueError): + analyze.render_csv(bad) + + def test_site_dispatch_preserves_arguments(self): + from types import SimpleNamespace + received = [] + module = SimpleNamespace(main=lambda args: received.append(args) or 0) + with patch.object(cli.importlib, "import_module", return_value=module) as loader: + self.assertEqual(cli.main(["site", "--repo-root", ".", "--output", "build/site"]), 0) + loader.assert_called_once_with("scriptorium.site_renderer") + self.assertEqual(received, [["--repo-root", ".", "--output", "build/site"]]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_site_contract.py b/tests/test_site_contract.py index c4e0ce7e..054b77ee 100644 --- a/tests/test_site_contract.py +++ b/tests/test_site_contract.py @@ -4,6 +4,7 @@ from pathlib import Path from scriptorium.publication import derive_compatibility_claim, validate_compatibility_claim +from scriptorium import site_frozen_diagnostic ROOT = Path(__file__).resolve().parents[1] @@ -51,11 +52,20 @@ def test_seed_entries_are_unique_and_repo_relative(self): self.assertFalse(entry["source_text_included"]) def test_seed_manifest_matches_canonical_showcase_safety_labels(self): - self.assertEqual(len(self.manifest["entries"]), 4) + self.assertEqual(len(self.manifest["entries"]), 5) for entry in self.manifest["entries"]: artifact = json.loads( (ROOT / entry["artifact_path"]).read_text(encoding="utf-8") ) + if entry["artifact_schema"] == site_frozen_diagnostic.FROZEN_PROFILE: + raw = (ROOT / entry["artifact_path"]).read_bytes() + item = site_frozen_diagnostic.prepare(entry, artifact, raw) + self.assertEqual(item["metric_count"], 28) + self.assertEqual(entry["compatibility_claim"], "inferred") + self.assertEqual(entry["corpus_admissibility"], "not_admissible") + self.assertIs(artifact["epistemic_status"]["m2_parity_admissible"], False) + self.assertEqual(artifact["epistemic_status"]["fantlab_source_edition_match"], "unknown") + continue self.assertEqual(entry["entry_id"], artifact["showcase_id"]) self.assertEqual(entry["publication_status"], artifact["status"]) self.assertEqual( diff --git a/tests/test_site_frozen_diagnostic.py b/tests/test_site_frozen_diagnostic.py new file mode 100644 index 00000000..269d63a2 --- /dev/null +++ b/tests/test_site_frozen_diagnostic.py @@ -0,0 +1,229 @@ +"""Integration tests for the explicitly registered historical full-work route.""" +import copy +from hashlib import sha256 +from html.parser import HTMLParser +import json +from pathlib import Path +import shutil +import tempfile +import unittest +from unittest.mock import patch +from urllib.parse import urlsplit, unquote + +from scriptorium import site_frozen_diagnostic as frozen +from scriptorium import site_renderer as site + +ROOT = Path(__file__).resolve().parents[1] + + +class Links(HTMLParser): + def __init__(self, text): + super().__init__() + self.links = [] + self.ids = [] + self.feed(text) + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if "id" in attrs: + self.ids.append(attrs["id"]) + if tag in {"a", "link"} and "href" in attrs: + self.links.append(attrs["href"]) + + +class FrozenSiteIntegrationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) / "repo" + self.manifest = json.loads((ROOT / "site/publication-manifest.json").read_text()) + (self.root / "site").mkdir(parents=True) + for entry in self.manifest["entries"]: + destination = self.root / entry["artifact_path"] + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / entry["artifact_path"], destination) + self.entry = next(row for row in self.manifest["entries"] if row["entry_id"] == frozen.ENTRY_ID) + self.raw = (self.root / frozen.ARTIFACT_PATH).read_bytes() + self.artifact = json.loads(self.raw) + self.output = self.root / "build/site" + self.write_manifest() + + def write_manifest(self): + (self.root / "site/publication-manifest.json").write_text(json.dumps(self.manifest, ensure_ascii=False)) + + def snapshot(self): + return {path.relative_to(self.output).as_posix(): path.read_bytes() + for path in sorted(self.output.rglob("*")) if path.is_file()} + + def test_canonical_site_preserves_four_routes_and_adds_one_real_report(self): + record = site.build_site(self.root, self.output) + routes = {row["route"] for row in record["entries"]} + self.assertEqual(routes, { + "works/anna-karenina-part1-ch1-opening/", + "works/anna-karenina-part1-ch2-dialogue/", + "works/tolstoy-anna-karenina-morphology-diagnostic/", + "works/tolstoy-resurrection-provenance/", + f"works/{frozen.SLUG}/", + }) + self.assertEqual(record["generator_profile"], "scriptorium-static-site-v2") + row = next(row for row in record["entries"] if row["entry_id"] == frozen.ENTRY_ID) + self.assertEqual(row["artifact_sha256"], frozen.ARTIFACT_SHA256) + index = (self.output / "index.html").read_text() + self.assertIn("Explore the 28-metric full-work report", index) + self.assertIn("full-work metrics", index) + self.assertIn("provenance-only", index) + self.assertEqual(index.count('class="card"'), 5) + rendered = (self.output / "works" / frozen.SLUG / "index.html").read_text() + self.assertEqual(rendered.count('scope="row"'), 28) + self.assertEqual(rendered.count("characters / word"), 1) + self.assertEqual(rendered.count("characters / sentence"), 3) + self.assertIn("Historical snapshot", rendered) + self.assertIn("0/5", rendered) + self.assertIn("unknown", rendered) + self.assertIn('href="../../"', rendered) + self.assertIn('id="metrics" tabindex="-1"', rendered) + self.assertEqual(frozen.report.summarize(self.artifact)["missing_actual_count"], 5) + self.assertEqual((self.root / frozen.ARTIFACT_PATH).read_bytes(), self.raw) + + def test_double_build_is_byte_identical(self): + site.build_site(self.root, self.output) + before = self.snapshot() + site.build_site(self.root, self.output) + self.assertEqual(self.snapshot(), before) + + def test_all_internal_links_and_fragments_resolve(self): + site.build_site(self.root, self.output) + for path in self.output.rglob("*.html"): + parsed = Links(path.read_text()) + self.assertEqual(len(parsed.ids), len(set(parsed.ids)), path) + for href in parsed.links: + link = urlsplit(href) + if link.scheme or link.netloc: + continue + target = (path.parent / unquote(link.path)).resolve() if link.path else path + if target.is_dir(): + target = target / "index.html" + self.assertTrue(target.is_file(), (path, href)) + self.assertTrue(target.is_relative_to(self.output), (path, href)) + if link.fragment: + self.assertIn(link.fragment, Links(target.read_text()).ids) + + def test_every_page_has_targetable_main_and_no_source_selection_prose(self): + site.build_site(self.root, self.output) + for path in self.output.rglob("*.html"): + rendered = path.read_text() + self.assertIn('id="main-content" tabindex="-1"', rendered) + self.assertNotIn("", text) + self.assertIn("<script>", text) + + def test_report_adapter_marker_drift_preserves_output(self): + site.build_site(self.root, self.output) + before = self.snapshot() + with patch.object(frozen.report, "render_html", return_value="changed contract"): + with self.assertRaisesRegex(site.PublicationBuildError, "integration marker"): + site.build_site(self.root, self.output) + self.assertEqual(self.snapshot(), before) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reader_package_smoke.py b/tools/reader_package_smoke.py new file mode 100644 index 00000000..79469db1 --- /dev/null +++ b/tools/reader_package_smoke.py @@ -0,0 +1,45 @@ +"""Run with the installed wheel, outside the checkout; no manuscript is retained.""" +import csv +import hashlib +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile + + +def main(): + repo = Path(sys.argv[1]).resolve() + import scriptorium + package = Path(scriptorium.__file__).resolve() + assert not package.is_relative_to(repo), "smoke test imported checkout instead of installed wheel" + for name in ("analyze_deterministic_metrics", "analyze_pos_metrics", "normalize_text", "word_tokens"): + assert callable(getattr(scriptorium, name)), f"public API missing: {name}" + def run(*args): + return subprocess.run([sys.executable, "-m", "scriptorium", *map(str, args)], + check=True, capture_output=True, text=True, timeout=60) + assert "0.2.0a1" in run("--version").stdout + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "private.txt" + raw = "Кот идёт. Пёс спит!\n— Да? — спросил он.".encode("utf-8") + source.write_bytes(raw) + for kind in ("json", "html", "csv"): + run("analyze", source, "--format", kind, "--output", root / f"report.{kind}") + assert "Кот идёт" not in (root / f"report.{kind}").read_text(encoding="utf-8") + data = json.loads((root / "report.json").read_text(encoding="utf-8")) + assert len(data["metrics"]) == 29 + assert data["source"]["raw_sha256"] == hashlib.sha256(raw).hexdigest() + assert len(list(csv.DictReader(io.StringIO((root / "report.csv").read_text(encoding="utf-8"))))) == 29 + run("diagnostic", repo / "benchmarks/fantlab/work270306-wikisource-diagnostic.json", + "--output", root / "historical.html") + assert (root / "historical.html").read_text(encoding="utf-8").count('scope="row"') == 28 + run("site", "--repo-root", repo, "--output", root / "site") + assert (root / "site/works/anna-karenina-full-work-diagnostic/index.html").is_file() + assert "anna-karenina-full-work-diagnostic" in (root / "site/index.html").read_text(encoding="utf-8") + print("Installed Reader smoke PASS: public API, 29 metrics, JSON/HTML/CSV, historical report and canonical site") + + +if __name__ == "__main__": + main() From 71d71f7b18e2170c28be49856668914ebeef1eeb Mon Sep 17 00:00:00 2001 From: andy-zen-dev Date: Sat, 10 Oct 2026 14:22:20 +0300 Subject: [PATCH 2/3] fix: respect canonical site output boundary in installed smoke Refs #273. The full 736-test suite and Pages double build passed, but installed smoke used a temporary output outside the allowed repo/build tree. Put only derived site output under a unique disposable build child; keep installed imports and manuscript processing outside the checkout. Do not relax the renderer's output safety checks. --- tools/reader_package_smoke.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tools/reader_package_smoke.py b/tools/reader_package_smoke.py index 79469db1..63915db1 100644 --- a/tools/reader_package_smoke.py +++ b/tools/reader_package_smoke.py @@ -35,9 +35,15 @@ def run(*args): run("diagnostic", repo / "benchmarks/fantlab/work270306-wikisource-diagnostic.json", "--output", root / "historical.html") assert (root / "historical.html").read_text(encoding="utf-8").count('scope="row"') == 28 - run("site", "--repo-root", repo, "--output", root / "site") - assert (root / "site/works/anna-karenina-full-work-diagnostic/index.html").is_file() - assert "anna-karenina-full-work-diagnostic" in (root / "site/index.html").read_text(encoding="utf-8") + # The canonical renderer intentionally confines output to repo/build. + # Preserve that boundary without changing cwd or the installed import source. + build_root = repo / "build" + build_root.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(prefix="reader-smoke-", dir=build_root) as site_tmp: + site = Path(site_tmp) / "site" + run("site", "--repo-root", repo, "--output", site) + assert (site / "works/anna-karenina-full-work-diagnostic/index.html").is_file() + assert "anna-karenina-full-work-diagnostic" in (site / "index.html").read_text(encoding="utf-8") print("Installed Reader smoke PASS: public API, 29 metrics, JSON/HTML/CSV, historical report and canonical site") From 62e6c078cb70ec77775007a1668fa0c3ede4f5fa Mon Sep 17 00:00:00 2001 From: andy-zen-dev Date: Wed, 14 Oct 2026 07:52:13 +0300 Subject: [PATCH 3/3] fix(site): stage complete builds and atomically publish without clobber Refs #273. Preserve last valid site on write failures, fail closed when atomic replacement is unavailable, and reject competing first writes. --- scriptorium/site_publish.py | 69 ++++++++++++++++++++++++++++++ scriptorium/site_renderer.py | 43 ++++++++++--------- tests/test_site_publish.py | 82 ++++++++++++++++++++++++++++++++++++ tests/test_site_renderer.py | 17 ++++++++ 4 files changed, 192 insertions(+), 19 deletions(-) create mode 100644 scriptorium/site_publish.py create mode 100644 tests/test_site_publish.py diff --git a/scriptorium/site_publish.py b/scriptorium/site_publish.py new file mode 100644 index 00000000..f99fb10f --- /dev/null +++ b/scriptorium/site_publish.py @@ -0,0 +1,69 @@ +"""Atomic, no-clobber publication of an already-validated local static-site tree. + +Linux renameat2 is intentionally required. Unsupported platforms fail closed rather +than removing the last valid published tree or silently changing a first publish into +an overwrite. This module does not activate GitHub Pages. +""" +from __future__ import annotations + +from contextlib import contextmanager +import ctypes +import errno +from hashlib import sha256 +import os +from pathlib import Path +import sys +import tempfile +from typing import Iterator + + +_RENAME_NOREPLACE = 1 +_RENAME_EXCHANGE = 2 +_AT_FDCWD = -100 + + +def _atomic_rename(source: Path, destination: Path, *, replacing: bool) -> None: + if not sys.platform.startswith("linux"): + raise OSError(errno.ENOTSUP, "atomic directory publication requires Linux renameat2") + library = ctypes.CDLL(None, use_errno=True) + renameat2 = getattr(library, "renameat2", None) + if renameat2 is None: + raise OSError(errno.ENOTSUP, "atomic directory publication unavailable") + renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] + renameat2.restype = ctypes.c_int + mode = _RENAME_EXCHANGE if replacing else _RENAME_NOREPLACE + if renameat2(_AT_FDCWD, os.fsencode(source), _AT_FDCWD, os.fsencode(destination), mode): + code = ctypes.get_errno() + raise OSError(code, os.strerror(code)) + + +@contextmanager +def staged_site(repo_root: Path, destination: Path) -> Iterator[Path]: + """Yield a disposable staging directory and atomically publish on clean exit. + + The original directory survives rendering failures. An initially absent output + never overwrites an output created by a competing process during rendering. + """ + build_dir = repo_root / "build" + if build_dir.is_symlink(): + raise OSError(errno.ELOOP, "build directory must not be a symlink") + build_dir.mkdir(exist_ok=True) + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.parent.absolute() != destination.parent.resolve(strict=False): + raise OSError(errno.ELOOP, "site destination cannot traverse symlinks") + relative = destination.relative_to(repo_root) + lock = build_dir / (".site-publish-" + sha256(relative.as_posix().encode()).hexdigest()[:20] + ".lock") + try: + lock.mkdir() + except FileExistsError as exc: + raise OSError(errno.EBUSY, "site publication already in progress") from exc + try: + replacing = destination.exists() + with tempfile.TemporaryDirectory(prefix=".site-stage-", dir=build_dir) as temporary: + stage = Path(temporary) + yield stage + if destination.is_symlink() or destination.absolute() != destination.resolve(strict=False): + raise OSError(errno.ELOOP, "site destination cannot traverse symlinks") + _atomic_rename(stage, destination, replacing=replacing) + finally: + lock.rmdir() diff --git a/scriptorium/site_renderer.py b/scriptorium/site_renderer.py index 9d0555ee..74a79963 100644 --- a/scriptorium/site_renderer.py +++ b/scriptorium/site_renderer.py @@ -12,12 +12,12 @@ import html import json import re -import shutil from collections.abc import Mapping, Sequence from pathlib import Path, PurePosixPath from typing import Any from urllib.parse import urlsplit +from .site_publish import staged_site from .publication import validate_compatibility_claim from . import site_frozen_diagnostic @@ -670,16 +670,17 @@ def _prepare_output(repo_root: Path, output_dir: Path) -> Path: root = repo_root.resolve() if output_dir.is_symlink(): raise PublicationBuildError("output directory must not be a symlink") - output = output_dir.resolve() + if output_dir.absolute() != output_dir.resolve(strict=False): + raise PublicationBuildError("output directory must stay inside the repository build/ tree") + output = output_dir.resolve(strict=False) try: relative = output.relative_to(root) except ValueError as exc: raise PublicationBuildError("output directory must stay inside the repository build/ tree") from exc - if not relative.parts or relative.parts[0] != "build": + if not relative.parts or relative.parts[0] != "build" or len(relative.parts) < 2: raise PublicationBuildError("output directory must stay inside the repository build/ tree") - if output.exists(): - shutil.rmtree(output) - output.mkdir(parents=True) + if output.exists() and not output.is_dir(): + raise PublicationBuildError("output directory must be a directory") return output @@ -736,19 +737,23 @@ def build_site( output_path = Path(output_dir) if not output_path.is_absolute(): output_path = root / output_path - output = _prepare_output(root, output_path) - (output / "assets").mkdir() - (output / "assets" / "site.css").write_text(_SITE_CSS, encoding="utf-8", newline="\n") - (output / "index.html").write_text(index_html, encoding="utf-8", newline="\n") - for slug, page_html in work_pages.items(): - work_dir = output / "works" / slug - work_dir.mkdir(parents=True) - (work_dir / "index.html").write_text(page_html, encoding="utf-8", newline="\n") - (output / "build.json").write_text( - json.dumps(build_record, ensure_ascii=False, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - newline="\n", - ) + destination = _prepare_output(root, output_path) + try: + with staged_site(root, destination) as output: + (output / "assets").mkdir() + (output / "assets" / "site.css").write_text(_SITE_CSS, encoding="utf-8", newline="\n") + (output / "index.html").write_text(index_html, encoding="utf-8", newline="\n") + for slug, page_html in work_pages.items(): + work_dir = output / "works" / slug + work_dir.mkdir(parents=True) + (work_dir / "index.html").write_text(page_html, encoding="utf-8", newline="\n") + (output / "build.json").write_text( + json.dumps(build_record, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + newline="\n", + ) + except OSError as exc: + raise PublicationBuildError("atomic site publication failed") from exc return build_record diff --git a/tests/test_site_publish.py b/tests/test_site_publish.py new file mode 100644 index 00000000..35e296d5 --- /dev/null +++ b/tests/test_site_publish.py @@ -0,0 +1,82 @@ +"""Actual Linux filesystem regressions for canonical atomic site publication.""" +import errno +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from scriptorium import site_publish as p + + +@unittest.skipUnless(p.sys.platform.startswith("linux"), "requires Linux renameat2") +class AtomicSitePublicationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.dest = self.root / "build" / "site" + + def tearDown(self): + self.temp.cleanup() + + def test_first_publish_and_refresh_discard_stale_files(self): + with p.staged_site(self.root, self.dest) as stage: + (stage / "index.html").write_text("first") + (self.dest / "stale.html").write_text("stale") + with p.staged_site(self.root, self.dest) as stage: + (stage / "index.html").write_text("second") + self.assertEqual((self.dest / "index.html").read_text(), "second") + self.assertFalse((self.dest / "stale.html").exists()) + + def test_renderer_failure_does_not_remove_last_valid_site(self): + self.dest.mkdir(parents=True) + (self.dest / "index.html").write_text("old") + with self.assertRaisesRegex(RuntimeError, "injected"): + with p.staged_site(self.root, self.dest) as stage: + (stage / "index.html").write_text("new") + raise RuntimeError("injected failure") + self.assertEqual((self.dest / "index.html").read_text(), "old") + + def test_first_publish_does_not_replace_concurrent_destination(self): + rename = p._atomic_rename + def create_competing_output(source, destination, *, replacing): + self.assertFalse(replacing) + destination.mkdir() + (destination / "index.html").write_text("other writer") + return rename(source, destination, replacing=replacing) + with patch.object(p, "_atomic_rename", side_effect=create_competing_output): + with self.assertRaises(FileExistsError): + with p.staged_site(self.root, self.dest) as stage: + (stage / "index.html").write_text("ours") + self.assertEqual((self.dest / "index.html").read_text(), "other writer") + + def test_missing_atomic_primitive_fails_without_data_loss(self): + self.dest.mkdir(parents=True) + (self.dest / "index.html").write_text("old") + with patch.object(p, "_atomic_rename", side_effect=OSError(errno.ENOTSUP, "unavailable")): + with self.assertRaises(OSError): + with p.staged_site(self.root, self.dest) as stage: + (stage / "index.html").write_text("new") + self.assertEqual((self.dest / "index.html").read_text(), "old") + + def test_concurrent_same_output_is_not_silently_clobbered(self): + with p.staged_site(self.root, self.dest) as stage: + (stage / "index.html").write_text("outer") + with self.assertRaisesRegex(OSError, "already in progress"): + with p.staged_site(self.root, self.dest) as other: + (other / "index.html").write_text("inner") + self.assertEqual((self.dest / "index.html").read_text(), "outer") + + def test_destination_symlink_refused_and_external_data_retained(self): + external = self.root / "external" + external.mkdir() + (external / "keep").write_text("safe") + self.dest.parent.mkdir() + self.dest.symlink_to(external, target_is_directory=True) + with self.assertRaisesRegex(OSError, "symlinks"): + with p.staged_site(self.root, self.dest) as stage: + (stage / "index.html").write_text("new") + self.assertEqual((external / "keep").read_text(), "safe") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_site_renderer.py b/tests/test_site_renderer.py index a82dcadc..2cfdf04d 100644 --- a/tests/test_site_renderer.py +++ b/tests/test_site_renderer.py @@ -260,5 +260,22 @@ def test_rebuild_removes_stale_output(self): self.assertFalse(stale.exists()) + def test_mid_write_failure_preserves_previous_build(self): + from unittest.mock import patch + build_site(self.root, self.output) + before = self._snapshot() + original_write = Path.write_text + + def fail_receipt(path, *args, **kwargs): + if path.name == "build.json": + raise OSError("injected receipt write failure") + return original_write(path, *args, **kwargs) + + with patch.object(Path, "write_text", fail_receipt): + with self.assertRaisesRegex(PublicationBuildError, "atomic site publication failed"): + build_site(self.root, self.output) + self.assertEqual(before, self._snapshot()) + + if __name__ == "__main__": unittest.main()