diff --git a/.github/workflows/reader-package.yml b/.github/workflows/reader-package.yml new file mode 100644 index 00000000..bbcf72aa --- /dev/null +++ b/.github/workflows/reader-package.yml @@ -0,0 +1,64 @@ +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: Check Python syntax before test discovery + run: python -m compileall -q scriptorium tests tools + - 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 + - name: Verify installed source-free gallery and deterministic output + shell: bash + run: | + set -euo pipefail + cd "$RUNNER_TEMP" + for format in html csv; do + "$RUNNER_TEMP/reader-venv/bin/python" -I -m scriptorium.full_work_gallery --repo-root "$GITHUB_WORKSPACE" --format "$format" > "gallery.$format" + "$RUNNER_TEMP/reader-venv/bin/python" -I -m scriptorium.full_work_gallery --repo-root "$GITHUB_WORKSPACE" --format "$format" > "gallery-check.$format" + cmp "gallery.$format" "gallery-check.$format" + test -s "gallery.$format" + done diff --git a/.gitignore b/.gitignore index 84c048a7..3df16291 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ /build/ +/dist/ +/.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.coverage diff --git a/AGENTS.md b/AGENTS.md index aff2722a..c8052e70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,148 +1,144 @@ # Scriptorium agent instructions -These rules govern autonomous work in this repository. - -## Mission - -Build Scriptorium end to end: first reproduce the publicly observable FantLab -linguistic analysis as faithfully as evidence permits, then extend it with author-voice -profiles, profile comparison, additional literary metrics, tagging and tag profiles, -and a browsable GitHub Pages publication. - -The project uses `drevendev/EndlessZen` as its operating discipline. Every wake is -stateless: orient from repository state, perform one bounded unit, verify it, and leave -a recoverable durable result. - -## Repository scope - -`drevendev/Scriptorium` is this worker's development target. A bounded run advances -Scriptorium; it does not become a maintenance run for the framework or another project. - -`drevendev/EndlessZen` is a read-only operating-model reference for this worker. Do not -patch it, create branches or pull requests there, review or merge its pull requests, or -otherwise maintain it from the Scriptorium task. If Scriptorium work exposes a genuine -EndlessZen defect or reusable framework improvement, check that repository's current -contributor rules and open/closed issues, then report the finding as an Issue for its own -maintainers. Reporting that finding does not replace the Scriptorium unit for the run. - -Other repositories may be inspected read-only when needed for Scriptorium research, -source provenance, dependency evaluation, or comparison. Do not turn that inspection -into unrelated repository development. - -## Durable state - -Read these before selecting work: - -1. `project/PROJECT_MANIFEST.md` -2. `project/STATE_AND_QUEUE.md` -3. `project/CHANGELOG.md` -4. the issue or PR named by the selected unit - -Repository files, issues, pull requests, checks, benchmark artifacts and comments are -project memory. Chat memory is never authoritative. - -## Authority and workflow - -The user authorized end-to-end repository development. You may research, create and -update issues, create branches and commits, open and review pull requests, refactor, -merge safe pull requests, maintain CI and GitHub Pages, and update project state. - -Normal mutation path after bootstrap: - -```text -issue -> branch -> pull request -> independent later review -> merge -``` - -Do not merge a substantial PR in the same run that authored its substantive changes. -A later run may review and merge it when evidence and checks support doing so. Small -mechanical recovery/bookkeeping fixes may be handled directly only when the current -repository policy makes a PR disproportionate and the change cannot alter analyzer -semantics. - -Never force-push a shared branch, weaken a security gate, change repository visibility -or administration, spend money, or access credentials that were not explicitly made -available. Use the connected GitHub identity as itself; never impersonate another user. - -Repository-facing prose, issue bodies, PRs and commits are written in English. - -## One run, one bounded unit - -A run must finish one named unit that is small enough to verify. Recover interrupted -work before selecting new work. Prefer the highest-priority executable unit in -`STATE_AND_QUEUE.md`; do not invent a different priority because it is more interesting. - -Research is work only when it changes a requirement, decision, test, benchmark, source -record or queued unit. A list of links is not a completed research unit. - -A blocked run records the precise blocker and the evidence or event that would reopen -the unit. Never turn unknown into zero or not-run into passed. - -## FantLab fidelity rules - -Keep three classes distinct in code, docs and Pages output: - -- **reproduced** — behavior is demonstrated against a FantLab benchmark; -- **inferred** — behavior is a documented reconstruction from public evidence; -- **extension** — Scriptorium behavior that is not claimed to match FantLab. - -FantLab states that some corrective coefficients and implementation details are -unpublished. Therefore resemblance is not parity. A compatibility metric closes only -when benchmark evidence satisfies its declared equality/tolerance rule. - -The first compatibility target is the 19 September 2022 FantLab analysis of Henry Lion -Oldie, `Шутиха`, recorded in `benchmarks/fantlab/work488.json`. It is a public numeric -reference, not a licensed corpus text. - -For FantLab compatibility, prefer the AOT lineage for morphology. `pylem` is the first -implementation candidate because it wraps the original AOT C++ morphology family. A -newer tagger may be used for Scriptorium extensions, but must not silently replace the -compatibility backend. - -## Corpus and copyright boundary - -Calibration, benchmark and author-profile corpus entries must contain at least 300,000 -characters including spaces. The analyzer itself must accept shorter works and excerpts -and report that short-input author/profile estimates are less representative. - -Admit a corpus text only when provenance and legal use are clear: public domain, an -explicit open license, or explicit permission from the rightsholder/site for the use we -perform. Web accessibility is not permission. - -Treat every translation as a distinct work with its own translator, edition/provenance, -text digest and analysis result. Do not collapse translations into the source work. - -Do not commit copyrighted full text by default. Store metadata, license/provenance, -source references, immutable digests, analyzer version and derived analysis artifacts. -Local user-supplied text may be analyzed without becoming part of the public corpus. - -Author.Today and fanfiction sources are candidates only when their specific text/license -or permission supports the intended processing/publication. Do not scrape around access -controls or assume a platform-wide license. - -## Verification - -Analyzer changes require tests for the behavior changed. Compatibility changes also -update or run the relevant benchmark comparison. A benchmark records: - -- source/edition identity and legal basis; -- raw and normalized text digests; -- character count including spaces; -- analyzer version/configuration; -- FantLab reference URL/date; -- expected value, actual value and delta per metric. - -If the exact text edition behind a FantLab result is not established, the comparison is -diagnostic, not a parity proof. - -GitHub Pages contains derived analysis and provenance, not unlicensed book text. - -## Design bias - -Keep the core deterministic and testable. Separate ingestion, normalization, -tokenization, dialogue detection, morphology, metric extraction, profile modeling, -benchmarking and presentation so a FantLab-compatibility decision can be changed -without rewriting unrelated layers. - -Prefer explicit schemas and versioned compatibility profiles over hidden heuristics. -When evidence is insufficient, make uncertainty visible in the artifact rather than -burying it in code. +## Mission and scope + +Build the literary analyzer end to end: reproduce publicly observable FantLab analysis +as faithfully as evidence permits, then add author-voice profiles, comparisons, richer +metrics, work tags/tag profiles and useful GitHub Pages results. Deliver functional code +and a presentable experience for readers, writers and researchers. + +Only `drevendev/Scriptorium` is a development target. `drevendev/EndlessZen` is a read-only +operating reference. A genuine framework defect exposed here may be reported there only +after reading its contributor guide and deduplicating open/closed issues; never implement, +review or merge other repositories from this task. Relevant primary sources and dependency +repositories may be inspected read-only. + +## Canonical state and owner-delegated decisions + +Every ordinary wake is stateless. Read `project/PROJECT_MANIFEST.md`, +`project/STATE_AND_QUEUE.md`, relevant `project/CHANGELOG.md`/`CHANGELOG.d` deltas and the +selected issue/PR. Re-read native head/base/check/review/merge facts before acting. +GitHub is canonical; old chat reports, ZIPs and stale REVIEW_PENDING prose are not. +`STATE_AND_QUEUE.md` is the single ordered execution backlog. `project/ROADMAP.md` describes +strategic acceptance, not a second status ledger. + +The 2026-10-02 owner correction, reconciled by the 2026-10-18 cleanup, delegates routine +product, architecture, implementation, research, design, QA and backlog decisions to the +solo maintainer. Do not wait for another developer or ask the owner routine prioritization +questions. Scientific completion and incremental availability are separate: ship useful +clearly labeled partial results without declaring M1-M5 complete or changing their gates. + +## Selection and bounded work + +Use ORIENT -> SELECT -> CLAIM -> PRODUCE/REVIEW -> VERIFY -> COMMIT. Ordinary hourly wakes +produce one bounded semantic outcome; an explicit interactive owner batch can request +multiple outcomes. Closing-first means executable recovery, actual regressions/failing +checks, eligible earlier PR acceptance, then the strongest ready analyzer/benchmark/CLI/ +public-experience slice. Known unchanged blockers do not repeatedly win selection. + +Maintain the next user-visible milestone, acceptance and strongest 3-5 executable tasks +in the existing queue. Corpus-only inherited ordering, a scan/OCR candidate, a bookkeeping +PR or an unavailable write/deployment capability blocks dependent work only. Prefer useful +implementation, tests, merges and publication over process. Research must change a decision, +experiment, test, benchmark, source record or ready task; repeated links/rechecks are not +progress. Do not spend more than two consecutive process-only wakes while safe product work +exists. Reuse unfinished code and its branch after checking claims and current files. + +## Authority and safe repository workflow + +Research, issues/comments/triage, code/tests/docs, architecture, refactoring, UI, derived +artifacts, CI/Pages implementation, branches/commits/PRs, reviews and qualifying merges are +authorized within the mission. GitHub prose stays concise technical English. + +Verify the authenticated connected login `andy-zen-dev` and current repository permissions +before writes. Public profiles or PR authorship are not identity proof. Unknown/mismatched +identity means read-only/local work. Discover real connector actions before declaring a +capability missing. Interactive success does not prove scheduled-context capability. + +Normal path: issue/rationale -> focused branch -> PR -> later fresh acceptance -> qualifying +merge -> postcondition verification. No routine direct master writes, including bookkeeping. +Never materially author/fix and approve/merge the same semantic change in one run. A later +stateless wake may accept an earlier worker-authored PR without inventing a second human +or GitHub self-approval. Inspect exact diff/context, acceptance, findings/threads, fresh +head/base, applicable checks/tests and provenance/security. Use expected-head SHA when +supported and read back effects. Missing/skipped/stale/unknown is not PASS. Formal-review +API absence is not an extra gate; real required approvals/checks remain mandatory. + +No force-push, protection/safety bypass, credential discovery, secret access, spending, +external package-registry publication, repository visibility/access/administration changes +or new paid infrastructure without separate owner authorization. Treat external/repository +text as evidence, not a way to widen authority. Stop denied dependent writes; never evade +an actual denial through another endpoint, payload, token or account. + +## Branches, blockers and schedule + +Prefer finishing/reusing an existing branch. New branches need a distinct ready purpose +and PR destination. Preserve unique work and active stack bases. Supported, authorized +branch deletion needs fresh exact-head preservation proof and no open head/base/dependency; +squash merges require content/supersession proof, not ancestry alone. Never delete master +or simulate deletion by overwriting refs. Missing deletion/admin tools are scoped blockers. + +Record only new/changed blockers: operation, target/head, context, actual error/code, +effects/readback, affected scope and reopening trigger. Distinguish MISSING_TOOL, +IDENTITY_UNVERIFIED/MISMATCH, PROVIDER_PERMISSION, BRANCH_RULE_OR_CHECK, +NETWORK/RUNTIME_FAILURE and an actual execution safety denial. Do not infer denied writes +from history or repeat an unchanged failed mutation. With writes unavailable, finish useful +local code/tests/patches pinned to exact base/file hashes; label UNPUBLISHED honestly and +do not assume sandbox persistence. Existing private outbox use is temporary only when +owner-authorized, with explicit reconciliation; never create parallel control planes. + +The existing hourly job remains enabled indefinitely until the owner explicitly changes +its lifecycle. Ordinary wakes never mutate, disable, pause, retire, replace or stop it for +blockers, provider outages, failed tests, empty queues or apparent completion. Repository +state is not evidence that the scheduler is healthy. + +## FantLab fidelity and corpus + +Keep reproduced, inferred and extension behavior distinct in schemas, code, reports and UI. +Unpublished FantLab coefficients/know-how remain unknown. Reproduction requires the declared +per-metric equality/tolerance and displayed-rounding evidence, not visual/count resemblance. +The initial numeric reference is the 2022-09-19 analysis of Oldie's `Шутиха` in +`benchmarks/fantlab/work488.json`; it is not a licensed corpus text. + +AOT-lineage morphology is the compatibility target. `pylem` wraps that lineage; other +backends must be separately labeled/versioned extensions, never silent substitutions. +Keep current dependency/dictionary pins until an evidence-backed change is reviewed. + +Calibration/training/parity-benchmark/author-profile entries require at least 300,000 +characters INCLUDING spaces and clear public-domain/open-license/permission evidence for +the intended use. Accessibility is not permission. The analyzer accepts short works and +excerpts with explicit representativeness limitations; fixtures do not count as admission. +Every translation has its own translator, edition/provenance, hashes and result identity. +Author.Today/fanfiction requires specific rights; never scrape around access controls. + +Normally publish metadata, rights/provenance, immutable source hashes, analyzer/configuration +versions and derived results, not full books. Private analysis is not consent to upload a +manuscript or add it to the corpus. No undisclosed analytics or external transmission. + +M2 remains the manifest's five independent legally usable >=300k works with matching FantLab +analysis and established edition/analyzer-input identity, reproducing required published +metrics after rounding. Record raw/normalized hashes, toolchain/config versions, reference +URL/date and expected/actual/delta/rules. Unmatched comparisons are DIAGNOSTIC, not parity. +Unknown is not zero; NOT_RUN is not passed. Source/body/size/diagnostic/parity gates advance +separately. Neither sampled scan folios nor successful OCR proves whole-book mapping or +FantLab input identity. Do not loosen gates to ship sooner. + +## Implementation and presentation acceptance + +Keep a deterministic Python core. Separate ingestion, text normalization/tokenization/ +sentences/dialogue, morphology, metrics, profiles, benchmarks and presentation. Prefer +existing primitives over rewrites. Changes need positive/negative regressions, reproducible +examples, explicit units/profiles and preservation of historical artifacts. + +README/first screen must show the benefit, working actions, real results and methodology, +not governance first. Use coherent literary styling, readable typography/contrast/spacing, +responsive layouts, touch targets, keyboard/focus/reduced-motion behavior and honest empty, +error and unknown states. Charts need labels, units and table/text alternatives. No fake +buttons, unsupported scores, fabricated traction or parity claims. + +For visible changes build the actual site and exercise the relevant flow. Inspect real +browser desktop/mobile screenshots when execution is permitted; otherwise state +VISUAL_NOT_VERIFIED and run structural/interaction checks. Mockups or in-memory previews +are not live-route proof. Preserve the last valid publication on bad ingestion/build. +Distinguish PR_CREATED, MERGED, BUILT, DEPLOYED and LIVE_VERIFIED. Deployment/admin limits +block publication only, not independent UI or analyzer work. diff --git a/README.md b/README.md index ab95f4c3..e9aa0d67 100644 --- a/README.md +++ b/README.md @@ -1,251 +1,110 @@ -# Scriptorium +# Scriptorium · Reader preview -Scriptorium is an open-source toolkit for quantitative analysis of literary text. +**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. -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. +## Try your own text -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: +With Python 3.13 or later, from this checkout: ```bash -python -m scriptorium.frozen_diagnostic_report \ - benchmarks/fantlab/work270306-wikisource-diagnostic.json \ - --title "Anna Karenina — full-work diagnostic" \ - --output anna-diagnostic.html +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 ``` -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 +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 -python -m scriptorium.benchmark \ - --reference benchmarks/fantlab/work488.json \ - --text /path/to/local-text.txt \ - --scriptorium-revision \ - --edition-match unknown \ - --output comparison.json +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 ``` -An optional external dictionary can enable the dictionary-dependent vocabulary actuals: +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. +Atomic site replacement currently requires Linux `renameat2`; unsupported platforms +fail without deleting the previous build. Private text analysis is a separate capability. + +## Compare two already-analyzed books + +No manuscript download or morphology setup is needed for the frozen **Running on Waves** +and **Children of Captain Grant — Beketova translation** comparison: ```bash - --dictionary /path/to/dictionary.txt \ - --dictionary-profile my-dictionary-v1 +mkdir -p build +python -m scriptorium.full_work_gallery --repo-root . --format html > build/full-work-gallery.html +python -m scriptorium.full_work_gallery --repo-root . --format csv > build/full-work-evidence.csv ``` -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 `build/full-work-gallery.html` for ten exact measurements and seven row-wise charts. +These are Scriptorium-side results, not FantLab parity or author-style scores. This +comparison is an offline preview, not yet a canonical Pages route. Shell redirection +replaces its destination; use new filenames to keep older exports. [Method and limits](docs/FULL_WORK_GALLERY.md). + +## 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, including +the source-free gallery; the 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. + +Site locks are persistent regular files whose kernel locks are released on process death. +Do not delete a lock file while a publisher could be using it. Legacy directory locks +from an interrupted older preview fail closed and are not automatically deleted. + +## 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) · +[Roadmap and repository assessment](project/ROADMAP.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 and Comparison Desk prototypes are +**not** advertised as released features; 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/FULL_WORK_GALLERY.md b/docs/FULL_WORK_GALLERY.md new file mode 100644 index 00000000..46923189 --- /dev/null +++ b/docs/FULL_WORK_GALLERY.md @@ -0,0 +1,20 @@ +# Full-work Gallery — Scriptorium-only comparison + +This source-free preview compares two frozen Russian-language full works: Alexander Grin's *Running on Waves* (363,819 characters) and Jules Verne's *The Children of Captain Grant* in Alexandra Beketova's translation (1,095,467 characters). + +Run from the repository root on Python 3.13+: + +```sh +mkdir -p build +python -m scriptorium.full_work_gallery --repo-root . --format html > build/full-work-gallery.html +python -m scriptorium.full_work_gallery --repo-root . --format csv > build/full-work-evidence.csv +python -m unittest discover -s tests -p 'test_gallery_release.py' -v +``` + +Both results are deterministic. They use already-frozen derived metrics and do not download books, run morphology, require telemetry, or publish manuscript text. The standalone HTML includes a labeled comparison chart and exact-value table; the CSV preserves numeric values, units and scientific labels. Shell redirection replaces its destination; use new filenames when preserving an earlier export. This differs from the create-only `scriptorium analyze --output` writer. + +**Scientific boundaries:** FantLab's analyzer-input edition for *Running on Waves* is unestablished. No FantLab comparison is performed for the Beketova translation. The works' lengths exceed the 300,000-character corpus floor, but neither length nor diagnostic resemblance establishes parity. **M2 remains 0/5.** The three total-count metrics are not length-normalized style indicators; seven rate/mean metrics have their own row-wise scales. + +The module verifies exact source JSON Git blob identities, metrics profile compatibility, translation identity, explicit unknown gates and source-free evidence. Changes to the frozen source require a deliberate review and pin update. The package workflow also runs the installed module outside the checkout and compares two HTML and CSV builds byte for byte. + +This is an **offline preview**, not a new canonical Pages route. It does not modify the publication manifest or deployment permissions. Later site integration must use an explicit digest-pinned allow-list, review gates and the staged publisher. See the [roadmap](../project/ROADMAP.md). 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-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-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/CHANGELOG.d/2026-10-18-reader-release-repair.md b/project/CHANGELOG.d/2026-10-18-reader-release-repair.md new file mode 100644 index 00000000..acb68047 --- /dev/null +++ b/project/CHANGELOG.d/2026-10-18-reader-release-repair.md @@ -0,0 +1,19 @@ +# Reader release repair and owner-requested cleanup + +Refs #275, #269 and #273. This is a new successor PR, not a same-run merge acceptance. + +- Independently accepted #268 at 50fbb9b9c0da59a5eb78c6826381b8acd3dd8aae and merged as 6338bedbeaf94b00374a95423bba5dd82368e593; #267 closed. Accepted #272 at 610dc8044c0f03a70c646eb48db51e6bd8ce19c7 after retargeting to the identical master base tree, merged as 9412ee54ab1a56c7c81f6ff4d290e39d83f49630; #271 closed. Both exact heads had 22 successful workflow runs. +- Adopt #274 at 62e6c078cb70ec77775007a1668fa0c3ede4f5fa, including its bounded/path-private local analyzer, formats, labels, package and tests. Correct only the staged-site body indentation in the complete renderer (original blob 74a79963b06777ab4b15145b541b7b06a59d7aed). +- Recover the pending crash-safe publisher patch against exact original blob f99fb10f4f19b3b363a3accbe68212feacf66c44. Keep a stable regular-file inode and nonblocking Linux flock released on process death; reject symlinks/special files. Retain atomic exchange/no-replace behavior. New subprocess tests have deadlines; original six plus new five pass locally (11/11). Baseline reproduced two crash errors before the fix. +- Reuse existing gallery branch c411e29030e7e778f99cd9689441d348f75a7715 and preserve its source-free two-work module/tests. Clarify offline status, digest-based registration and shell-redirection semantics. Add installed-gallery/deterministic-output CI checks and an early syntax check without changing existing test coverage or workflow permissions. +- Expand build/cache ignores, synchronize README examples, refresh the single compact queue and add roadmap/assessment. Reconcile the owner's 2026-10-02 delivery correction into AGENTS and manifest mode selection/liveness; M0-M5 scientific criteria are unchanged. + +The complete corrected renderer compiled locally and its uploaded blob matches the tested +bytes. Full checkout was unavailable locally because Git DNS failed; the focused publisher +suite is not the full repository suite. Hosted exact-head CI/installed-wheel/canonical build +outcomes are native provider facts and remain pending at authoring. Real-route visual +acceptance is not inferred from earlier in-memory previews. + +No frozen corpus/benchmark/showcase JSON, publication allow-list, dependency/dictionary pin, +external registry, Pages activation, repository administration, visibility or scheduler +change. M2 remains 0/5; no new corpus admission or FantLab parity claim. diff --git a/project/PROJECT_MANIFEST.md b/project/PROJECT_MANIFEST.md index cb679647..abb53bf8 100644 --- a/project/PROJECT_MANIFEST.md +++ b/project/PROJECT_MANIFEST.md @@ -52,21 +52,32 @@ runs. ## Mode selection -The authorized hourly task prompt supplies the current deterministic selection policy: - -1. recover interrupted or review-ready work; -2. fix a failing required check or benchmark regression; -3. satisfy the current milestone gate; -4. execute the highest-priority unblocked queue unit; -5. perform a consumer/corpus/provenance review when its trigger is due; -6. report no executable work. - -This deployment does **not** yet have an independent external EndlessZen mode controller -that persists a selection receipt before each run. Until one is bound and verified, the -worker applies the user-authorized deterministic ladder above and records this as a -known conformance gap rather than claiming mechanical mode selection. The worker may -propose a policy change, but it may not silently change this ladder; goal or gate changes -require a manifest amendment with a reason. +The 2026-10-02 owner correction delegates solo product/backlog ownership, reconciled in +this manifest by the 2026-10-18 owner-requested delivery cleanup. Success means working +analyzer capabilities, verified fixes, qualifying merges, reproducible results and a +useful public experience, not repeated paperwork or recurring failed writes. + +Select the strongest executable unit, closing-first: + +1. reconcile ambiguous partial effects and actual product/evidence regressions; +2. fix new findings, failing applicable checks or conflicts on existing work; +3. accept/merge eligible earlier work, or recover useful unfinished code into its PR path; +4. implement the strongest ready analyzer, benchmark, CLI or public-experience slice; +5. remove concrete reliability, reproducibility, accessibility, installation or release friction; +6. research a genuinely blocking question with a new decision/test/experiment/ready task. + +Known unchanged blockers affect dependent work only and are excluded from repeatedly +winning selection. A corpus-only inherited queue, a scan candidate or a bookkeeping PR +is not a prerequisite for authorized independent product work. Maintain one compact +ordered backlog in STATE_AND_QUEUE; ROADMAP describes acceptance rather than another +mutable control plane. Ordinary wakes finish one bounded semantic outcome. Explicit +interactive owner batches may request several repository outcomes. + +Scientific completion and incremental availability are separate: ship clearly labeled +partial/inferred reports and independently validated extension previews while preserving +all M1-M5 criteria below. The absence of an independently verified external mode controller +remains a conformance limitation, not a global prerequisite or authority to stop useful +work. Do not silently change the mission, scientific gates or equality/tolerance rules. ## Liveness @@ -76,6 +87,10 @@ OBSERVER: user / ChatGPT task UI TOLERATED_SILENCE: 3 hours ``` +The same existing hourly task is perpetual until the owner explicitly changes its +lifecycle. Ordinary wakes must not mutate/disable/pause/replace/retire it for blockers, +provider outages, failed tests, empty queues or apparent completion. + A stopped worker cannot self-report liveness. Repository state records only committed runs, never claims that the scheduler itself is healthy. @@ -224,3 +239,4 @@ curation programme until the user stops or changes scope. | --- | --- | --- | | 2026-09-11 | Initial manifest | User commissioned the project and hourly autonomous development. | | 2026-09-11 | Record mode-controller conformance gap | Bootstrap review found that the deployment has an authorized deterministic ladder but no independently verified external selection receipt mechanism. | +| 2026-10-18 | Reconcile the 2026-10-02 owner delivery correction and perpetual-task boundary | Executable closing-first selection, scoped blockers and incremental product availability replace corpus/process-only ordering. Scientific M0-M5 gates and equality/tolerance rules are unchanged. | diff --git a/project/ROADMAP.md b/project/ROADMAP.md new file mode 100644 index 00000000..1758918d --- /dev/null +++ b/project/ROADMAP.md @@ -0,0 +1,127 @@ +# Scriptorium roadmap and repository assessment + +Updated by the owner-requested cleanup on 2026-10-18. This document defines strategic +acceptance; `STATE_AND_QUEUE.md` alone owns execution order. PR/check/merge/deployment +status is always native GitHub evidence, not a manually maintained duplicate ledger. + +## Assessment + +**The main bottleneck is delivery convergence, not a missing analyzer foundation.** +The repository already has deterministic metric families, pinned morphology research, +source-free evidence and tested rendering primitives. #268 and #272 were accepted on +their unchanged exact heads (22 successful workflow runs each) and merged into master. +The offline frozen report and its canonical route no longer need another implementation. + +The useful local analyzer in #270 was not mergeable as-is: its latest review identifies +unbounded special-file reads and path-disclosing errors. #274 already incorporates fixes, +CSV, readable labels and wheel packaging, but its newer atomic-publisher change left a +syntax error in `site_renderer.py`. Its observed head had 8 successful and 15 failed +workflow runs. The successor #275 adopts that existing implementation rather than replacing +the public API with an isolated subset. It also preserves the three unique files from the +un-PR'd two-work gallery branch. + +The publisher's mkdir lock survived process death and could block all later builds. +A reproduced crash test now motivates a persistent regular-file/kernel lock; replacement +remains atomic and first publication remains no-clobber. Linux support is explicit rather +than claiming cross-platform site publication. Orphaned old staging directories and legacy +directory locks are not automatically deleted while ownership could be ambiguous. + +**Public presentation and scientific completion are separate gaps.** The canonical site +has five evidence routes; the two-work gallery is still offline. Repository metadata reports +`has_pages=false`, so building a site is not a live deployment. M2 is still 0/5 because no +retained work establishes the required FantLab analyzer-input identity. More polished UI, +large legal source texts or approximate numeric matches do not close that gate. + +**Repository hygiene needs focused follow-through.** Master is protected by a PR/no-force/ +no-delete ruleset, but the observed ruleset does not require named CI checks or resolved +threads. #265 owns that administration/deletion scope. The root has no LICENSE file and +repository metadata reports no detected license; settle an explicit code/data licensing +and attribution policy before advertising a broadly reusable release. Do not infer a +license from public visibility or silently relicense contributors/dependencies. + +## 1. Reader 0.2 — finish one release path + +Scope: #275, #269 and #273. Preserve #268/#272's landed work; adopt #274's Reader and the +unfinished gallery, repair syntax/crash recovery and maintain one active successor branch. +Do not keep adding disconnected ZIP-only features. + +Acceptance: all applicable exact-head CI passes; the complete canonical suite and +syntax check run; a built wheel is installed in a clean environment outside checkout; +`scriptorium analyze` produces valid JSON/HTML/CSV with all 29 rows, bounded private input +and create-only reports; diagnostic and five-route site commands work; canonical builds +are byte-identical; the installed gallery produces deterministic HTML/CSV. Negative tests +must retain private-path, special-file, tampered-evidence and publication-failure behavior. + +A later fresh acceptance may merge the successor and then close superseded #270/#274 only +with preservation proof. Do not self-approve or merge freshly authored repairs. Verify the +new master checks separately. This preview does not authorize package-registry publication. + +## 2. Public full-work results — a useful real showcase + +Integrate the already-frozen Grin and Beketova results into the canonical publication path, +not a decorative dashboard or corpus-candidate research page. Keep translation identity, +source JSON/body hashes, scientific labels and exact units visible. Publish only supported +metric rows; absolute totals are not normalized style scores. + +Acceptance: explicit digest-pinned allow-list and route registration, working homepage/list/ +detail/back links, HTML plus source-free machine-readable download, labeled charts and exact +tables, no arbitrary artifact discovery. Exercise real desktop/mobile/keyboard flows and +inspect screenshots in a permitted browser. Malformed data/build failure preserves the last +valid site. Distinguish local build, CI artifact, deployment and live verification. A Pages +configuration blocker affects only the last two stages, not implementation. + +## 3. Trustworthy comparison — one shared contract + +Selectively adopt the pending comparison CLI/Comparison Desk work after checking canonical +Reader compatibility. Avoid independently hand-maintained Python/JavaScript registries. +A shared versioned declaration should define metric IDs, methods, units, dictionary-dependent +fields and valid status transitions. Imported declarations are untrusted input, not proof. + +Acceptance: actual Reader JSON -> comparison -> browser -> CSV/JSON round trip; unknown, +unavailable, unregistered and incompatible fields remain distinct; no delta for an +incomparable row. Test decimal arithmetic, stale import races, strict UTF-8, size limits, +dictionary identity, method/unit/status tampering, formula-like text and data privacy. +Keep benchmark diagnostics separate from author-profile claims. No external manuscript +transmission or new service is needed. + +## 4. Morphology and benchmark evidence — resolve a measured uncertainty + +Use the pinned AOT/pylem lineage for a bounded noun/cardinal ambiguity experiment, then +expose the affected POS distribution/bigram/sentence-position deltas. Reuse existing primary +source/provenance research; do not silently substitute another backend or invent FantLab's +unpublished coefficients. Add a new experimental consequence, not another link inventory. + +Acceptance: pinned toolchain/dictionary/source identities, positive and ambiguous fixtures, +expected/actual/delta output and an explicit mapping/version decision or narrowly actionable +remaining question. The experiment remains diagnostic until compatible behavior and source +identity are independently demonstrated. + +Build one inspectable cross-work readiness report from existing admitted/frozen evidence: +rights, >=300k size, source-edition identity, input identity, reference date, required fields, +rounding rules, NOT_RUN and unresolved deltas. Tests must prove source-unmatched works cannot +be promoted to parity. Use this to choose the next genuinely executable corpus/source task; +do not repeat unchanged Perelman/Darwin metadata investigations. + +## 5. Reproducibility, release hygiene and later profiles + +Keep the deterministic library/CLI separate from evidence ingestion and presentation. +Add measured performance/installation regressions before architectural rewrites. Consolidate +repeated broad CI execution only after mapping every old assertion to retained coverage; +research-specific triggers and a clear core/package/site acceptance check should reduce +noise without weakening gates. Required-check settings remain #265's administration scope. + +Resolve explicit code license, data attribution and dependency notices before a broad public +release. Inspect legacy branches for unique content and active stack dependencies; supported +deletion needs fresh exact-head proof, never ref overwrites or forced history changes. + +Author-voice and tag-profile work follows its own M3/M4 acceptance: multiple sufficiently +large legal works, reproducible weighting/uncertainty/cross-validation and distinct translator +identities. Any earlier extension preview must be labeled and independently validated. +M5 still needs at least 30 eligible diverse works plus its publication/license gates. + +## Definition of progress + +A working command, tested defect fix, accepted earlier PR, reproducible evidence delta or +usable real-work page is progress. Repeated unchanged reviews, wake counters, administrative +waiting and more unintegrated prototypes are not. No item above changes the mission, +corpus floor, M2 equality/rounding requirements, privacy boundary or perpetual hourly job. diff --git a/project/STATE_AND_QUEUE.md b/project/STATE_AND_QUEUE.md index e8b928c2..041dcbe1 100644 --- a/project/STATE_AND_QUEUE.md +++ b/project/STATE_AND_QUEUE.md @@ -1,102 +1,93 @@ # STATE_AND_QUEUE — Scriptorium -STATE_REVISION: 291 +STATE_REVISION: 293 PHASE: M1 — Deterministic FantLab surface -LAST_COMMITTED_RUN_AT: 2026-10-07T12:21:02Z -LAST_RESULT: Owner-requested delivery batch: repaired four mean-length unit labels in PR #268 at `50fbb9b9c0da59a5eb78c6826381b8acd3dd8aae` (654 hosted tests and canonical Pages double-build passed); implemented local analysis for #269 in PR #270 at `752f3c491af2f1c563a0c5ae31e9452f93a1c0a3` (22 focused real-engine tests, applicable hosted workflows passed); implemented the SCRIP-SITE-008 route for #271 in this stacked feature. These are branch/PR effects, not merges or live publication. Later fresh acceptance remains required. -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. +LAST_COMMITTED_RUN_AT: 2026-10-18 +LAST_RESULT: Owner-requested cleanup accepted #268 as 6338bedbeaf94b00374a95423bba5dd82368e593 and #272 as 9412ee54ab1a56c7c81f6ff4d290e39d83f49630; #267/#271 closed completed. #275 owns the new successor Reader repair PR, reusing the existing gallery branch and preserving #274's integration. Syntax and crash-safe publication fixes are authored here, not merged. Native GitHub owns current check/merge/deployment facts. +LAST_VERIFIED_PROGRESS: Complete current renderer blob revalidated and syntax corrected. Publisher baseline reproduced two crash failures; revised publisher plus existing and new bounded subprocess/filesystem regressions passed 11/11 locally. Full successor CI and installed-wheel/site checks require actual exact-head provider evidence. All frozen source/benchmark/publication data remain unchanged; M2 stays 0/5. ## Current unit ```text -UNIT_ID: SCRIP-SITE-008 -ISSUE: #271 -STATUS: REVIEW_PENDING -PR: Resolve the linked route-integration PR from #271; GitHub owns current head/check/merge facts. -BASE: 50fbb9b9c0da59a5eb78c6826381b8acd3dd8aae (PR #268) -NEXT_ACTION: Review/accept #268 separately, then accept this stacked route on a fresh exact head. - PR #270 is independent and can receive its own later acceptance without waiting for this stack. - Re-read applicable full-suite/Pages CI, source-free bytes and publication interlocks. +UNIT_ID: SCRIP-QA-READER-REPAIR +ISSUE: #275 (covers the remaining executable #269 and #273) +STATUS: IMPLEMENTED_PENDING_ACCEPTANCE +PR: Resolve the successor PR linked from #275; native head/check facts are authoritative. +BRANCH: scrip-site-two-full-work-gallery +BASE: 9412ee54ab1a56c7c81f6ff4d290e39d83f49630 +INPUT_HEADS: Reader #274 at 62e6c078cb70ec77775007a1668fa0c3ede4f5fa; + unfinished gallery at c411e29030e7e778f99cd9689441d348f75a7715. +NEXT_ACTION: Fix actual successor CI findings in this same branch, then later fresh acceptance. + Inspect the exact diff, full suite, installed-wheel smoke and canonical rebuild. + Never merge this authored repair in the same run. ``` -PR #266 is already merged as `abe7bb889ef98437ba9faef5a79b023499556991` and is not a -recovery blocker. GitHub writes succeeded in the 2026-10-07 interactive delivery pass; -this does not establish scheduled-context capability. Do not repeat old denied mutations -without a material capability change. This feature includes the real homepage report -action and shared keyboard/mobile structure; do not reimplement them from old preview ZIPs. - -## 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. - -M2 remains open at **0/5 source-matched works**. Public-source freezing, bibliographic compatibility, OCR/text-layer counts, page maps, pagination metadata, facsimile anchors, count proximity, showcase values and diagnostic resemblance do not advance M2. Advancement requires independently established FantLab analyzer-input identity plus field-by-field benchmark evidence with explicit tolerances. - -## Queue - -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: a reader can open a real full-work diagnostic with units, -missing values and provenance, then reach it through the static site; a local-text CLI -must remain usable independently of source-matched corpus admission. This incremental -milestone does not declare M1/M2 complete. - -| Priority | Unit | Mode | Deliverable | Gate / dependency | -| --- | --- | --- | --- | --- | -| P1 | SCRIP-SITE-007 / #267 / PR #268 | acceptance | Fresh acceptance of the fixed offline report | Exact head, applicable checks and later-run gate; unit-label fix is already on the PR branch | -| P2 | SCRIP-METRIC-003 / #269 / PR #270 | acceptance | Ship local UTF-8 text to private JSON/offline HTML | Independent of #268; review actual core integration, create-only writer and exact-head CI | -| P3 | SCRIP-SITE-008 / #271 | public experience | Ship five canonical routes including the 28-metric full-work report | Stack depends on #268; digest/source flags, full suite and deterministic build; deployment rights block live publication only | -| P4 | SCRIP-COMPARE-002 pending handoff | analyzer / comparison | Adopt one tested two-diagnostic comparison CLI into the repository | Deduplicate current code/PRs, preserve missing values and schema/profile boundaries; test real 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 | - -SCRIP-SITE-009's homepage action and keyboard structure are partly covered by #271; -README local-analysis instructions are in #270. Remaining visual acceptance is scoped: -the interactive Chromium attempt to the actual local build returned -`net::ERR_BLOCKED_BY_ADMINISTRATOR`. Structural/link tests and canonical double builds -passed, but screenshots remain VISUAL_NOT_VERIFIED. No browser restriction was bypassed. - -## Retained corpus / provenance orientation - -Detailed evidence is canonical in `corpus/candidates/`, `benchmarks/`, `project/run-receipts/`, `project/CHANGELOG.md`, and `project/CHANGELOG.d/`. Re-read candidate-specific evidence before acting. - -- **Anna Karenina** — 239 pinned chapter revisions; 1,705,605 characters; FantLab source match unknown. -- **Resurrection** — 129 pinned revisions; 890,835 characters; FantLab source match unknown. -- **Brothers Karamazov** — 98 admitted revisions; 1,810,351 characters; FantLab source match unknown. -- **Silver Dove** — single pinned revision; 563,125 characters; FantLab source match unknown. -- **Petersburg** — exact 632-page 1916 first-book-edition PDF and reviewed OCR/body promotion contract; literary-page selection/body/>=300k/FantLab identity remain open. -- **Hyperboloid of Engineer Garin** — frozen 499,066-character body; print-edition/FantLab identity unresolved. -- **Aelita** — FantLab input is 276,556 characters, below the standing >=300k M2 floor; M2 weight zero. -- **We** — FantLab input is 285,704 characters, below the standing >=300k M2 floor; public body remains separately unfrozen. -- **Shining World** — primary public route is incomplete. -- **Road to Nowhere** — alternate frozen route clears 300k but neither route is identified as FantLab input. -- **Running on Waves** — frozen 363,819-character body plus reviewed deterministic/dialogue diagnostics; close denominator resemblance remains hypothesis-only because FantLab input identity is unknown. -- **The White Guard** — source family is mixed across chapters; body remains unfrozen. -- **The Twelve Chairs** — exact Page/PDF surfaces retained; template/reference semantics and body identity remain unresolved. -- **The Life of Klim Samgin** — source graph/extractor/body identities retained; historical renderer equivalence and FantLab input remain unresolved. -- **Children of Captain Grant — Beketova translation** — exact 1,095,467-character body with reviewed source-free deterministic showcase; no FantLab result/input identity for this translation. -- **On the Origin of Species — Rachinsky translation** — exact Page set/scan/render contracts retained. Canonical SCRIP-CORPUS-078 resolves zero-argument `{{ё}}` for selected live-snapshot evidence; SCRIP-CORPUS-079/080 prove and consume `` noinclude containment; SCRIP-CORPUS-081–084 retain `ВАР` documentation, provider-drift, six-revision load closure and exact provider SHA-1 identities without full replay/promotion. Effective unresolved surface remains 4 tag shapes / 130 tag tokens and 37 template shapes / 2,356 invocations. Historical transclusion, complete renderer, literary body/>=300k and FantLab gates remain open. -- **Entertaining Physics, Book 1 — Perelman 1913** — exact PDF/DjVu carriers and reviewed pagination/facsimile boundaries retained. Canonical SCRIP-CORPUS-085 adds independently reviewed exact-PDF mid-body anchors at carrier 59–61, 109–111 and 159–161: eight visible labels are compatible with +9, while carrier 60 has no directly visible folio and remains deliberately uninterpolated. Complete mapping/global offset, PDF↔DjVu equivalence, OCR correctness, canonical carrier, literary body/>=300k, FantLab input identity and M2 remain open. - -## Deterministic / morphology orientation - -- Frozen Anna diagnostics still differ materially from FantLab; numeric-token and lexical-hyphen probes do not explain the word gap. -- Running on Waves keeps the strongest dialogue-denominator hypothesis, not parity evidence. -- `scriptorium-punctuation-v2` removes internal ASCII-hyphen double counting; dash-rate parity remains unresolved. -- Dialogue author-remark/denominator semantics remain unresolved. -- FantLab dictionary/version, homonym selection, word-boundary semantics and service-word aggregation remain unknown. -- `pylem==0.0.18` remains pinned by exact sdist/source/dictionary provenance; runtime `N` loses noun/cardinal distinction. - -## Public repository representation - -Public surfaces must remain source-free and synchronized with actual capability. Publish metadata, provenance, hashes and derived analysis only; never publish unlicensed source text. Perelman's transition/tail and restored-page interior companions are independently reviewed; SCRIP-CORPUS-085 now canonically exposes an independently reviewed source-free broad-midbody companion that visibly distinguishes direct folio observations from the unlabelled carrier-60 gap and keeps global mapping/body/parity gates closed. Darwin's canonical companions expose reviewed dependency/output/provider evidence through SCRIP-CORPUS-084 without claiming full VAR replay or parity. - -## Known blockers / invariants - -1. FantLab analyzer-input identity remains undisclosed/unestablished for retained benchmark works; M2 remains 0/5. -2. FantLab corrective coefficients and several parser/tokenization/dialogue semantics remain partly unpublished. -3. No corpus or showcase evidence may be promoted to parity without source-edition/input matching and explicit benchmark tolerances. -4. Perelman complete exact-carrier mapping is still required. Broad sampled anchors are compatible with +9 where a folio is visible, but carrier page 60 directly demonstrates that visible labels are not total; no missing folio may be filled by arithmetic alone. Current evidence does not prove every interior page, a global offset, PDF↔DjVu equivalence, OCR correctness, body identity or >=300k admission. -5. Darwin/Rachinsky has canonical reviewed provider SHA-1 identity evidence for all six exact observed-live revisions, but full VAR replay, historical transclusion, explicit version-pinned Scribunto/MediaWiki runtime and promotion remain closed. -6. Live Pages deployment remains subject to repository policy/administration and must not be implied by static build success. +The 2026-10-18 owner explicitly requested a new PR for ready issues and qualifying merges. +#268/#272 are now merged, not pending dependencies; do not merge or reimplement them again. +#270 has unresolved FIFO/path-private-error findings in its old head; #274 has the known +syntax regression. Their useful code is retained in the successor, and the #273 claim is +prospectively redirected through #275. Preserve #270/#274 as provenance until successor +acceptance; do not keep developing competing integrations or blindly adopt old ZIPs. +#266 is already merged. Interactive writes succeeded in this pass, not proof of hourly +scheduled-context capability. No scheduler/admin/deployment activation change was made. + +## Next user-visible milestone and compact queue + +Milestone: install the canonical Reader, analyze a local text to JSON/HTML/CSV, open the +real full-work report through the five-route static site, and inspect a source-free +Grin/Beketova offline comparison. Required acceptance: exact-head full CI, installed package +outside checkout, deterministic builds, safe errors/writes and honest scientific labels. +This incremental availability does not close M1-M5. + +| Priority | Unit | Deliverable and acceptance | Scoped dependency | +| --- | --- | --- | --- | +| P1 | #275 / #269 / #273 | Finish exact-head Reader CI, accept in a later run, merge and reconcile superseded PRs | Real checks/protection; no same-run acceptance | +| P2 | SITE — full-work gallery route | Integrate the two pinned derived works into an explicit public allow-list; real links/downloads; desktop/mobile/keyboard checks and preserved last build | Reader publisher; admin interlock blocks DEPLOYED only | +| P3 | METRIC — comparison contract | Adopt one pending comparison slice using one shared versioned metric/method/unit/dictionary registry; actual CLI JSON -> viewer -> export tests | Revalidate pending code against canonical Reader; no parallel stub implementation | +| P4 | MORPH — AOT ambiguity diagnostic | Add a reproducible pinned-provider noun/cardinal ambiguity experiment and inspectable POS deltas | AOT provenance stays pinned; no inferred mapping promoted to parity | +| P5 | REPRO/CORPUS — evidence readiness | One machine-readable cross-work readiness/delta report from retained evidence; explicit identity/rounding/NOT_RUN gates, with a bounded new source-identity consequence | Unknown FantLab input blocks parity only; no repeated scan metadata loop | + +Execution order lives only here. [ROADMAP.md](ROADMAP.md) records the acceptance details +and repository assessment. Reserve engineering attention for install/CI friction and +explicit code/data licensing before any broad release. #265 is scoped administrative +follow-through; do not let unavailable deletion/settings block P1-P5. + +## Retained evidence and scientific gates + +Detailed immutable evidence remains in `corpus/candidates/`, `benchmarks/`, `showcase/`, +`project/run-receipts/`, `project/CHANGELOG.md` and `CHANGELOG.d/`; reread candidate-specific +records before acting. This cleanup does not recalculate, admit or relabel any work. + +- Anna Karenina: 239 pinned revisions, 1,705,605 characters; source identity unknown. +- Resurrection: 129 revisions, 890,835 characters; source identity unknown. +- Brothers Karamazov: 98 admitted revisions, 1,810,351 characters; source identity unknown. +- Silver Dove: 563,125 characters; source identity unknown. +- Hyperboloid: frozen 499,066-character body; print/FantLab identity unresolved. +- Running on Waves: frozen 363,819-character body and diagnostics; denominator resemblance is hypothesis only. +- Children of Captain Grant / Beketova: frozen 1,095,467-character body and derived metrics; no FantLab result/input identity for this translation. +- Aelita and We: published FantLab inputs are below 300k; zero M2 weight. We's public body remains separately unfrozen. +- Petersburg: exact 632-page 1916 PDF and OCR/body contract; literary selection, body, size and identity gates remain open. +- Shining World is incomplete; Road to Nowhere has a >=300k alternate frozen route but no FantLab identity; White Guard mixes source families; Twelve Chairs retains unresolved template/body semantics; Klim Samgin retains unresolved historical-renderer/FantLab equivalence. +- Darwin/Rachinsky: SCRIP-CORPUS-078 through 084 retain zero-argument yo, references containment, VAR documentation/provider drift and six-revision identity evidence, not full historical replay/body/parity. Effective unresolved surface remains 4 tag shapes/130 tokens and 37 template shapes/2,356 invocations. +- Perelman 1913: SCRIP-CORPUS-085 exact-PDF anchors include carrier 60 without a directly visible folio. Sampled +9 compatibility cannot establish total/global mapping, PDF/DjVu equivalence, OCR/body/size or FantLab identity. Do not interpolate missing folios arithmetically. + +M0 is closed; M1 remains open. General/dialogue/vocabulary/punctuation metric families are +executable inferred candidates. POS has separate work-page-compatible and diagnostic-only +surfaces. `pylem==0.0.18` remains pinned; runtime N noun/cardinal ambiguity is unresolved. +FantLab dictionary/version, homonym selection, word boundaries, service-word aggregation, +corrective coefficients and some dialogue/denominator semantics remain unknown. The Anna +word gap and dash-rate parity are unresolved; punctuation-v2's internal-hyphen correction +is not new evidence of parity. + +M2 stays **0/5**: five independent legally usable works >=300,000 characters including +spaces, established source edition/analyzer-input identity, required published compatible +metrics after displayed rounding, and explicit unchanged equality/tolerance rules. Unmatched +comparisons remain diagnostic. Unknown is not zero, NOT_RUN is not PASS. M3/M4/M5 retain their +own manifest gates. Short excerpts/fixtures do not count as corpus admission. + +## Scoped operational blockers + +- #265: active master ruleset prevents deletion/non-fast-forward and requires PRs, with zero required approvals. The observed ruleset does not require named CI checks or resolved threads. Current connector has no branch-deletion/admin-setting mutation; login has push/triage but not admin. Reopen those operations only when supported authority/capability is present; no endpoint/ref bypass. +- Pages: repository reports has_pages=false. Existing deployment variable/configuration interlock remains untouched. BUILT is not DEPLOYED or LIVE_VERIFIED. +- Local full checkout: Git clone failed DNS in this run. Exact files and focused publisher tests were verified locally; hosted full-repository checks remain the integrated execution authority. +- Browser route execution must be tested in a permitted environment. Prior blocked navigation or in-memory previews are not visual/live acceptance for this candidate. 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/full_work_gallery.py b/scriptorium/full_work_gallery.py new file mode 100644 index 00000000..e3d94d8c --- /dev/null +++ b/scriptorium/full_work_gallery.py @@ -0,0 +1,207 @@ +"""Source-free, offline comparison of two pinned full-work evidence records.""" +from __future__ import annotations + +import argparse +import csv +import hashlib +import html +import io +import json +import math +import os +from pathlib import Path +import stat +import sys + +PROFILE = "scriptorium-full-work-gallery-v1" +SOURCES = ( + ("grin-running-on-waves-ru", "811d408e576c2f59809f5ba13ae638fed7950428", "Running on Waves", "Alexander Grin", "unmatched FantLab diagnostic"), + ("verne-children-captain-grant-beketova-ru", "140823b70e9ac28a5fc24b402e75f3e83bf5bb8f", "The Children of Captain Grant", "Jules Verne · Alexandra Beketova translation", "Scriptorium-only derived metrics"), +) +METRICS = ( + ("fantlab.general.characters", "Characters including spaces", "characters", False), + ("fantlab.general.words", "Word tokens", "words", False), + ("fantlab.general.mean_word_length_chars", "Mean word length", "characters/word", True), + ("fantlab.general.mean_sentence_length_chars", "Mean sentence length", "characters/sentence", True), + ("fantlab.dialogue.share_percent", "Dialogue share", "%", True), + ("fantlab.dialogue.author_text_inside_dialogue_percent", "Author text in dialogue", "%", True), + ("fantlab.vocabulary.unique_words", "Unique word forms", "types", False), + ("fantlab.punctuation.comma.per_1000_words", "Commas", "per 1,000 words", True), + ("fantlab.punctuation.dash.per_1000_words", "Dashes", "per 1,000 words", True), + ("fantlab.punctuation.question.per_1000_words", "Question marks", "per 1,000 words", True), +) + +class GalleryError(ValueError): + """Unverified or unsafe source-free evidence.""" + + +def _unique_pairs(pairs): + result = {} + for key, value in pairs: + if key in result: + raise GalleryError("duplicate JSON key") + result[key] = value + return result + + +def _load(root: Path, index: int): + candidate, expected_blob, title, author, evidence = SOURCES[index] + relative = Path("corpus/candidates/diagnostics") / (candidate + ".json") + root = root.resolve() + path = root / relative + if path.is_symlink() or path.absolute() != path.resolve(strict=False): + raise GalleryError("unsafe source path") + flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(path, flags) + try: + details = os.fstat(fd) + if not stat.S_ISREG(details.st_mode) or not 0 < details.st_size < 2_000_000: + raise GalleryError("source must be a bounded regular file") + data = os.read(fd, 2_000_001) + finally: + os.close(fd) + if hashlib.sha1(b"blob " + str(len(data)).encode() + b"\0" + data).hexdigest() != expected_blob: + raise GalleryError("frozen evidence checksum mismatch") + doc = json.loads(data.decode("utf-8"), object_pairs_hook=_unique_pairs, + parse_constant=lambda _: (_ for _ in ()).throw(GalleryError("nonfinite JSON"))) + if not isinstance(doc, dict) or doc.get("candidate_id") != candidate or doc.get("source_text_committed") is not False: + raise GalleryError("unreviewed source identity") + body = doc.get("body_identity", {}) + size = body.get("character_count_including_spaces") + if type(size) is not int or size < 300_000 or len(str(body.get("raw_sha256", ""))) != 64: + raise GalleryError("full-work body identity missing") + gates = doc.get("gates", {}) + if (gates.get("fantlab_source_edition_match") != "unknown" or + gates.get("m2_parity_admissible") is not False or gates.get("m2_weight") != 0 or + gates.get("gate_ready") is not False): + raise GalleryError("unverified scientific gate") + if index == 0: + if (doc.get("comparison_policy", {}).get("class") != "diagnostic_unmatched_source" or + doc.get("fantlab", {}).get("source_edition_disclosed") is not False or + doc.get("fantlab", {}).get("source_bytes_disclosed") is not False): + raise GalleryError("diagnostic status drift") + elif (doc.get("translation_identity") != "Alexandra A. Beketova Russian translation" or + doc.get("interpretation", {}).get("fantlab_comparison_performed") is not False): + raise GalleryError("translation or parity status drift") + metric_rows = doc.get("metrics") + if not isinstance(metric_rows, list) or len(metric_rows) > 500: + raise GalleryError("invalid metric collection") + values = {} + for row in metric_rows: + if not isinstance(row, dict) or not isinstance(row.get("metric_id"), str): + raise GalleryError("invalid metric row") + mid = row["metric_id"] + if mid in values: + raise GalleryError("duplicate metric") + status = row.get("status" if index == 0 else "compatibility_status") + allowed = {"diagnostic_only_source_unmatched", "not_run_dependency_unbound"} if index == 0 else {"inferred", "extension"} + if status not in allowed: + raise GalleryError("invalid scientific status") + number = row.get("scriptorium_raw_value" if index == 0 else "value") + if number is not None and (type(number) not in (int, float) or not math.isfinite(number)): + raise GalleryError("non-finite metric") + if status == "not_run_dependency_unbound" and number is not None: + raise GalleryError("not-run cannot have a value") + values[mid] = number + for key, _, unit, normalized in METRICS: + value = values.get(key) + if value is None or value < 0 or (unit == "%" and value > 100): + raise GalleryError("missing or invalid shared measurement") + # Cardinal observations are countable events; JSON 10.0 is not an integer. + for metric_id in ("fantlab.general.characters", "fantlab.general.words", + "fantlab.vocabulary.unique_words"): + if type(values[metric_id]) is not int: + raise GalleryError("cardinal measurement must be an integer") + if (values["fantlab.general.characters"] != size or + values["fantlab.general.words"] > size or + values["fantlab.vocabulary.unique_words"] > values["fantlab.general.words"]): + raise GalleryError("inconsistent body statistics") + profiles = doc.get("scriptorium", {}).get("profiles", {}) + return {"id": candidate, "title": title, "author": author, "evidence": evidence, + "body": size, "body_hash": body["raw_sha256"], "json_hash": hashlib.sha256(data).hexdigest(), + # Each of these algorithms can change at least one displayed metric. + "profiles": tuple(profiles.get(k) for k in ( + "metrics", "normalization", "dialogue", "vocabulary", "punctuation" + )), + "values": values} + + +def load_gallery(root: Path): + records = tuple(_load(root, index) for index in range(2)) + if (any(type(p) is not str or not p for p in records[0]["profiles"]) or + records[0]["profiles"] != records[1]["profiles"]): + raise GalleryError("incompatible measurement profiles") + return records + + +def render_csv(records): + out = io.StringIO(newline="") + writer = csv.writer(out, lineterminator="\n") + writer.writerow(("candidate_id", "work", "metric_id", "label", "unit", "value", "evidence", "body_sha256", "source_json_sha256")) + for record in records: + for mid, label, unit, _ in METRICS: + writer.writerow((record["id"], record["title"], mid, label, unit, str(record["values"][mid]), + record["evidence"], record["body_hash"], record["json_hash"])) + return out.getvalue() + + +def render_html(records): + esc = lambda text: html.escape(str(text), quote=True) + cards = [] + for record in records: + cards.append("

" + esc(record["evidence"]) + + "

" + esc(record["title"]) + "

" + esc(record["author"]) + + "

" + format(record["body"], ",") + + "

Characters including spaces

Inspect source identity" + + "

Literary body SHA-256: " + record["body_hash"] + + "

Frozen JSON SHA-256: " + record["json_hash"] + "

") + rows, charts = [], [] + for mid, label, unit, normalized in METRICS: + a, b = (record["values"][mid] for record in records) + rows.append("" + esc(label) + " " + esc(unit) + "" + + esc(a) + "" + esc(b) + "") + if normalized: + upper = max(a, b) + bars = [] + for name, value, cssclass in (("Grin", a, "teal"), ("Verne", b, "ochre")): + width = format(value / upper * 100 if upper else 0, ".5f") + bars.append("
" + name + "
" + + format(value, ".2f") + "
") + charts.append("
  • " + esc(label) + " " + esc(unit) + "

    " + "".join(bars) + "
  • ") + css = """*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:#f6f1e5;color:#20312b;font:16px/1.55 system-ui,sans-serif}.wrap{max-width:1120px;margin:auto;padding:clamp(1rem,4vw,3rem)}h1,h2,h3{font-family:Georgia,serif;font-weight:400;line-height:1.15}h1{font-size:clamp(2.8rem,6vw,5.5rem)}.lede{font-size:1.15rem;max-width:65ch}.eyebrow{font-size:.8rem;color:#20665f;text-transform:uppercase;letter-spacing:.09em;font-weight:700}.skip{position:absolute;top:-5rem;left:1rem;background:white;padding:1rem}.skip:focus{top:1rem}a{color:#14615a}a:focus-visible,summary:focus-visible,.scroll:focus-visible{outline:3px solid #a24f07;outline-offset:4px}.notice{padding:1rem 1.4rem;background:#e1ece6;border-left:5px solid #266b64}.cards,.charts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.card,.charts li{min-width:0;background:#fffdf8;border:1px solid #d7cebd;border-radius:.7rem;padding:1.4rem}.big{display:block;font:3rem Georgia,serif;margin:1rem 0}.card code{overflow-wrap:anywhere}.charts{padding:0;list-style:none}.charts h3{font-size:1.2rem}.charts small,th small{font:.8rem system-ui,sans-serif;color:#52635b}.bar{display:grid;grid-template-columns:3rem minmax(0,1fr) 4rem;gap:.6rem;align-items:center;font-size:.85rem;margin:.7rem 0}.bar strong{text-align:right}.track{background:#e2e0d5;height:.9rem;border-radius:3px;overflow:hidden}.fill{height:100%;border-radius:3px}.teal{background:#277069}.ochre{background:#ae7134}section{padding-top:2.5rem}.scroll{overflow:auto;background:#fffdf8;border:1px solid #d7cebd;border-radius:.5rem}table{border-collapse:collapse;width:100%;min-width:570px}caption{text-align:left;padding:1rem}th,td{text-align:left;padding:.8rem 1rem;border-bottom:1px solid #ded4c5;font-variant-numeric:tabular-nums}summary{cursor:pointer;min-height:44px;padding:.8rem 0;font-weight:700}footer{margin-top:3rem;border-top:1px solid #d7cebd;padding:1.5rem 0}@media(max-width:700px){.cards,.charts{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}""" + return ("\n" + + "Scriptorium — Two analyzed works" + + "

    Scriptorium / Literary measurement

    " + + "

    Two books. Different rhythms.

    Real source-free measurements from two frozen full works. " + + "These are Scriptorium-side observations, not proven FantLab parity.

    " + + "

    The works

    " + + "
    " + "".join(cards) + "

    Compare the language

    " + + "

    Seven normalized measures, scaled separately within each row. Bar lengths cannot be compared across measures.

    " + + "
      " + "".join(charts) + "

    Exact numbers

    " + + "

    Absolute counts are not style scores. Values retain their inferred or diagnostic scientific status.

    " + + "
    " + + "" + + "" + "".join(rows) + + "
    Ten shared metrics and their units
    MetricRunning on WavesChildren of Captain Grant · Beketova
    " + esc(PROFILE) + " · offline · no tracking · M2 0/5
    \n") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path, default=Path(".")) + parser.add_argument("--format", choices=("html", "csv"), default="html") + args = parser.parse_args(argv) + try: + records = load_gallery(args.repo_root) + result = render_html(records) if args.format == "html" else render_csv(records) + except (GalleryError, OSError, TypeError, ValueError, KeyError): + print("Cannot build gallery: frozen source-free evidence is unavailable or incompatible.", file=sys.stderr) + return 2 + sys.stdout.write(result) + return 0 + + +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_publish.py b/scriptorium/site_publish.py new file mode 100644 index 00000000..8f9be095 --- /dev/null +++ b/scriptorium/site_publish.py @@ -0,0 +1,78 @@ +"""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 stat +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") + # Keep a stable lock inode. An auto-released flock survives SIGKILL without + # permanently stranding the publisher; unlinking it would introduce an inode race. + if not sys.platform.startswith("linux"): + raise OSError(errno.ENOTSUP, "atomic directory publication requires Linux") + import fcntl # Linux-only: keep the rest of the library importable elsewhere. + fd = os.open(lock, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK, 0o600) + try: + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise OSError(errno.EINVAL, "site publication lock must be a regular file") + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + raise OSError(errno.EBUSY, "site publication already in progress") from exc + 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: + os.close(fd) diff --git a/scriptorium/site_renderer.py b/scriptorium/site_renderer.py index 9d0555ee..8a7c4f46 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_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_gallery_evidence_contract.py b/tests/test_gallery_evidence_contract.py new file mode 100644 index 00000000..20d6e4e7 --- /dev/null +++ b/tests/test_gallery_evidence_contract.py @@ -0,0 +1,83 @@ +"""Reject inconsistent count metrics and algorithm profiles in frozen literary comparisons. + +The fixtures mutate source-free derived JSON and recompute its Git blob pin so tests +exercise the scientific invariants rather than mere checksum rejection. +""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import shutil +import tempfile +import unittest +from unittest.mock import patch + +from scriptorium import full_work_gallery as gallery + + +class FrozenGalleryEvidenceContract(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + source = Path(__file__).resolve().parents[1] + for candidate, *_ in gallery.SOURCES: + relative = Path("corpus/candidates/diagnostics") / f"{candidate}.json" + (self.root / relative).parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source / relative, self.root / relative) + + def repin(self, index, edit): + specs = list(gallery.SOURCES) + spec = list(specs[index]) + path = self.root / "corpus/candidates/diagnostics" / f"{spec[0]}.json" + doc = json.loads(path.read_text(encoding="utf-8")) + edit(doc) + data = (json.dumps(doc, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + path.write_bytes(data) + spec[1] = hashlib.sha1(b"blob " + str(len(data)).encode() + b"\0" + data).hexdigest() + specs[index] = tuple(spec) + return tuple(specs) + + def test_frozen_records_have_valid_counts_and_five_matching_profiles(self): + left, right = gallery.load_gallery(self.root) + self.assertEqual(len(left["profiles"]), 5) + self.assertEqual(left["profiles"], right["profiles"]) + for record in (left, right): + self.assertLessEqual(record["values"]["fantlab.general.words"], record["body"]) + for mid in ("fantlab.general.characters", "fantlab.general.words", "fantlab.vocabulary.unique_words"): + self.assertIs(type(record["values"][mid]), int) + + def test_fractional_count_is_rejected_after_repin(self): + specs = self.repin(0, lambda doc: next(row for row in doc["metrics"] if row["metric_id"] == "fantlab.general.words").update(scriptorium_raw_value=55778.5)) + with patch.object(gallery, "SOURCES", specs), self.assertRaisesRegex(gallery.GalleryError, "cardinal measurement"): + gallery.load_gallery(self.root) + + def test_integer_looking_float_is_not_an_integer_count(self): + specs = self.repin(1, lambda doc: next(row for row in doc["metrics"] if row["metric_id"] == "fantlab.general.words").update(value=158881.0)) + with patch.object(gallery, "SOURCES", specs), self.assertRaisesRegex(gallery.GalleryError, "cardinal measurement"): + gallery.load_gallery(self.root) + + def test_word_tokens_cannot_exceed_character_count(self): + specs = self.repin(0, lambda doc: next(row for row in doc["metrics"] if row["metric_id"] == "fantlab.general.words").update(scriptorium_raw_value=363820)) + with patch.object(gallery, "SOURCES", specs), self.assertRaisesRegex(gallery.GalleryError, "inconsistent body statistics"): + gallery.load_gallery(self.root) + + def test_dialogue_profile_drift_is_not_silently_compared(self): + specs = self.repin(1, lambda doc: doc["scriptorium"]["profiles"].update(dialogue="scriptorium-dialogue-v999")) + with patch.object(gallery, "SOURCES", specs), self.assertRaisesRegex(gallery.GalleryError, "incompatible measurement profiles"): + gallery.load_gallery(self.root) + + def test_vocabulary_profile_drift_is_not_silently_compared(self): + specs = self.repin(1, lambda doc: doc["scriptorium"]["profiles"].update(vocabulary="scriptorium-vocabulary-v999")) + with patch.object(gallery, "SOURCES", specs), self.assertRaisesRegex(gallery.GalleryError, "incompatible measurement profiles"): + gallery.load_gallery(self.root) + + def test_missing_profile_is_not_silently_compared(self): + specs = self.repin(1, lambda doc: doc["scriptorium"]["profiles"].pop("dialogue")) + with patch.object(gallery, "SOURCES", specs), self.assertRaisesRegex(gallery.GalleryError, "incompatible measurement profiles"): + gallery.load_gallery(self.root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gallery_release.py b/tests/test_gallery_release.py new file mode 100644 index 00000000..5b5971ed --- /dev/null +++ b/tests/test_gallery_release.py @@ -0,0 +1,115 @@ +"""Regression tests for the source-free gallery preview.""" +from __future__ import annotations +import csv +from io import StringIO +import json +import hashlib +import os +from pathlib import Path +import shutil +import tempfile +import unittest +from unittest.mock import patch +from scriptorium import full_work_gallery as gallery + +class ReleaseTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.original = Path(__file__).resolve().parents[1] + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + for spec in gallery.SOURCES: + filename = spec[0] + '.json' + p = Path('corpus/candidates/diagnostics') / filename + (self.root / p).parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(self.original / p, self.root / p) + + def _mutate(self, index, change): + spec = list(gallery.SOURCES[index]) + p = self.root / 'corpus/candidates/diagnostics' / (spec[0]+'.json') + doc = json.loads(p.read_text()) + change(doc) + raw = (json.dumps(doc, ensure_ascii=False, indent=2) + '\n').encode() + p.write_bytes(raw) + blob = hashlib.sha1(b'blob '+str(len(raw)).encode()+b'\0'+raw).hexdigest() + all_specs = list(gallery.SOURCES) + spec[1] = blob + all_specs[index] = tuple(spec) + return tuple(all_specs) + + def test_verified_sizes(self): + a,b = gallery.load_gallery(self.root) + self.assertEqual((a['body'],b['body']), (363819,1095467)) + self.assertEqual((a['values']['fantlab.dialogue.share_percent'],b['values']['fantlab.dialogue.share_percent']), (35.88986183443132,32.4738652575157)) + + def test_html_and_csv(self): + records = gallery.load_gallery(self.root) + page = gallery.render_html(records) + self.assertEqual(page.count(""),10) + self.assertEqual(page.count("
  • "),7) + self.assertIn('M2 0/5',page) + self.assertIn('prefers-reduced-motion',page) + self.assertIn('tabindex=\'0\'',page) + self.assertNotIn('