@dzhechkov/p-replicator 1.12.0 → 1.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +281 -65
- package/CHANGELOG.md +148 -1
- package/LICENSE +21 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +2 -2
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/05_architecture.md +1 -1
- package/README/eng/README.md +2 -1
- package/README/ru/01_quickstart.md +2 -2
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +2 -1
- package/README/ru/html/index.html +8 -8
- package/README.md +150 -9
- package/bin/cli.js +0 -0
- package/package.json +10 -11
- package/sbom.json +604 -64
- package/scripts/check-pipeline-gaps.sh +510 -21
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +5 -1
- package/src/utils.js +33 -3
- package/templates/.claude/agents/product-discoverer.md +38 -0
- package/templates/.claude/agents/replicate-coordinator.md +11 -1
- package/templates/.claude/commands/feature.md +72 -6
- package/templates/.claude/commands/go.md +6 -8
- package/templates/.claude/commands/harvest.md +5 -7
- package/templates/.claude/commands/replicate.md +169 -44
- package/templates/.claude/commands/start.md +28 -7
- package/templates/.claude/hooks/capture-source-path.cjs +795 -0
- package/templates/.claude/hooks/check-canon.cjs +493 -0
- package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
- package/templates/.claude/hooks/check-external-deps.cjs +288 -0
- package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
- package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
- package/templates/.claude/hooks/check-job-contract.cjs +501 -0
- package/templates/.claude/hooks/check-look-origin.cjs +240 -0
- package/templates/.claude/hooks/check-look-trace.cjs +385 -0
- package/templates/.claude/hooks/check-metric-source.cjs +296 -0
- package/templates/.claude/hooks/check-model-cost.cjs +470 -0
- package/templates/.claude/hooks/check-ports.cjs +27 -6
- package/templates/.claude/hooks/check-review-contract.cjs +205 -0
- package/templates/.claude/hooks/check-source-version.cjs +312 -0
- package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
- package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
- package/templates/.claude/hooks/statusline.cjs +2 -2
- package/templates/.claude/rules/embeddable-widget.md +73 -0
- package/templates/.claude/rules/feature-lifecycle.md +5 -6
- package/templates/.claude/rules/incoming-webhooks.md +99 -0
- package/templates/.claude/rules/long-running-job.md +73 -0
- package/templates/.claude/rules/model-call-cost.md +85 -0
- package/templates/.claude/rules/replicate-pipeline.md +122 -52
- package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
- package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
- package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
- package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
- package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
- package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
- package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
- package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
- package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
- package/templates/.claude/skills/requirements-validator/SKILL.md +18 -5
- package/templates/.claude/skills/requirements-validator/references/feature-report-contracts.md +67 -0
- package/templates/.claude/skills/requirements-validator/references/scoring-system.md +15 -6
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +173 -725
- package/tests/e2e/feature-contour.test.js +176 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/01_specification.md +15 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/02_pseudocode.md +19 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/03_architecture.md +3 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/04_refinement.md +3 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/05_completion.md +7 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/review-report.md +11 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/validation-report.md +10 -0
- package/tests/fixtures/feature-contour/tests/demo.test.js +16 -0
- package/tests/snapshot/baseline.json +64 -40
- package/tests/unit/capture-source-path.test.js +492 -0
- package/tests/unit/check-canon.test.js +403 -0
- package/tests/unit/check-embed-contract.test.js +422 -0
- package/tests/unit/check-external-deps.test.js +363 -0
- package/tests/unit/check-file-ownership.test.js +388 -0
- package/tests/unit/check-handoff-manifest.test.js +410 -0
- package/tests/unit/check-job-contract.test.js +514 -0
- package/tests/unit/check-look-origin.test.js +180 -0
- package/tests/unit/check-look-trace.test.js +420 -0
- package/tests/unit/check-metric-source.test.js +325 -0
- package/tests/unit/check-model-cost.test.js +425 -0
- package/tests/unit/check-ports.test.js +46 -2
- package/tests/unit/check-review-contract.test.js +181 -0
- package/tests/unit/check-source-version.test.js +344 -0
- package/tests/unit/check-swarm-receipts.test.js +231 -0
- package/tests/unit/check-webhook-contract.test.js +536 -0
- package/tests/unit/db-port-rule.test.js +8 -2
- package/tests/unit/detection-ladder-registry.test.js +2 -2
- package/tests/unit/generator-swarm-contract.test.js +287 -0
- package/tests/unit/guard-honest-input-meta.test.js +64 -0
- package/tests/unit/honest-failure-rules.test.js +106 -9
- package/tests/unit/look-phase-contract.test.js +231 -0
- package/tests/unit/negative-conclusion-gate.test.js +300 -0
- package/tests/unit/quote-provenance.test.js +122 -0
- package/tests/unit/sync-templates-guard.test.js +46 -3
- package/tests/unit/traceability-completion-gate.test.js +267 -0
- package/tests/unit/utils.test.js +40 -3
|
@@ -42,6 +42,17 @@ record of something recited from memory. That combination is legal, expressible,
|
|
|
42
42
|
| `ASSERTED` | Stated from model memory, source never opened | Nothing about the source | `0.0` |
|
|
43
43
|
| *(absent)* | Fact predates this axis | Evidence is **unknown** — neither asserted nor verified | no ceiling of its own |
|
|
44
44
|
|
|
45
|
+
For quoted text, the acquisition method is a separate closed vocabulary. The author-facing names
|
|
46
|
+
map exactly to the stored values; only the verifier can emit the verdict after comparing captured bytes.
|
|
47
|
+
|
|
48
|
+
| Stored method | Author-facing name | Verbatim ceiling |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `raw-fetch` | `сырая-загрузка` | eligible only after a matching captured excerpt |
|
|
51
|
+
| `tool-summary` | `пересказ-инструментом` | never verbatim, even when the words happen to match |
|
|
52
|
+
| `search-listing` | `из-поисковой-выдачи` | eligible only after reconciliation against a captured excerpt |
|
|
53
|
+
| `manual` | `вручную` | eligible only after reconciliation against a captured excerpt |
|
|
54
|
+
| *(absent/other)* | `method-unknown` | never verbatim |
|
|
55
|
+
|
|
45
56
|
**`FETCH_VERIFIED` cannot be self-declared.** It is minted only by `create_fetched_fact()`, which
|
|
46
57
|
requires a `FetchRecord` — the byte hash, HTTP status and date of a request that actually happened.
|
|
47
58
|
The manual constructors (`create_listing_fact`, `create_asserted_fact`) have no way to produce it.
|
|
@@ -54,7 +65,7 @@ Provenance, not truth.
|
|
|
54
65
|
### The report gate
|
|
55
66
|
|
|
56
67
|
```bash
|
|
57
|
-
python3 scripts/check_report_evidence.py --report report.md --facts facts.json [--profile patient.json]
|
|
68
|
+
python3 scripts/check_report_evidence.py --report report.md --facts facts.json [--profile patient.json] [--excerpts evidence_excerpts]
|
|
58
69
|
```
|
|
59
70
|
|
|
60
71
|
`ASSERTED` claims must not appear in a report at all. `LISTING_ONLY` claims may appear only with a
|
|
@@ -197,12 +208,10 @@ mkdir ~/health-research && cd ~/health-research # medical work lives here
|
|
|
197
208
|
Do not run medical investigations inside a shared code repository. If you already have,
|
|
198
209
|
the prompt log is `.dz/recall-usage.jsonl` and the lessons are in `.health-brain/`.
|
|
199
210
|
|
|
200
|
-
Choosing the shared store anyway is
|
|
201
|
-
|
|
202
|
-
the
|
|
203
|
-
|
|
204
|
-
"Nothing was blocked — this is your call." The data is yours; our job is that the choice
|
|
205
|
-
is knowing rather than accidental.
|
|
211
|
+
Choosing the shared store anyway is ADVISED against, never blocked: both `dz teach --domain
|
|
212
|
+
health-research` and `dz recall --all --json --include-domain health-research` print what follows
|
|
213
|
+
from the choice and end with "Nothing was blocked — this is your call" — the choice must be
|
|
214
|
+
knowing, not accidental.
|
|
206
215
|
Step 1 is what makes a lesson safe to keep: a rule about a method has nowhere to put a
|
|
207
216
|
person. But note what that is and is not — it is a discipline, performed by you and
|
|
208
217
|
asserted with `--confirm-method`, which nothing verifies. The property that holds
|
|
@@ -501,38 +510,44 @@ Invalid signatures are rejected with confidence `0.0`; they are never a recovera
|
|
|
501
510
|
4. Extract claims and source URLs.
|
|
502
511
|
5. **Fetch each source through the tool, not by hand:**
|
|
503
512
|
```python
|
|
504
|
-
from evidence_fetch import
|
|
505
|
-
|
|
513
|
+
from evidence_fetch import fetch_source_returning_body, FetchRecord
|
|
514
|
+
from quote_provenance import QuoteRecord, capture_excerpt, write_excerpt
|
|
515
|
+
record, body = fetch_source_returning_body(url) # real HTTP, captured bytes
|
|
506
516
|
if isinstance(record, FetchRecord):
|
|
507
|
-
|
|
517
|
+
captured = capture_excerpt(body, quoted_text, record)
|
|
518
|
+
write_excerpt("evidence_excerpts", captured)
|
|
519
|
+
quoted = QuoteRecord(quoted_text, "raw-fetch", record.final_url,
|
|
520
|
+
record.sha256_body, locator, captured["excerpt_id"])
|
|
521
|
+
fact = verifier.create_fetched_fact(claim, record, issuer, quote=quoted)
|
|
508
522
|
else: # offline, 404, oversize, refused scheme…
|
|
509
523
|
fact = verifier.create_listing_fact(claim, url, reason=record.reason)
|
|
510
524
|
```
|
|
511
525
|
A claim you never opened a source for is `verifier.create_asserted_fact(claim)` — record it
|
|
512
526
|
honestly and let the gate refuse it. Never hand-label a class you did not earn.
|
|
527
|
+
5a. **When quoting verbatim, capture at fetch time as shown above.** No captured source bytes means
|
|
528
|
+
no verbatim verdict; `tool-summary` can never earn one even when its words happen to match.
|
|
513
529
|
6. Add facts to the research ledger; issuer-signed facts only when a pinned issuer key actually
|
|
514
530
|
signed the message.
|
|
515
531
|
7. Verify facts and citation chains.
|
|
516
532
|
8. Cross-check claims through ordinary source evaluation.
|
|
517
533
|
9. **Run the report gate before delivering:**
|
|
518
534
|
`python3 scripts/check_report_evidence.py --report <report.md> --facts <facts.json>` — exit 0 required.
|
|
535
|
+
9a. **A NEGATIVE conclusion ("no competitor has this") MUST carry its own basis:** КОРПУС · ПОЛНОТА ·
|
|
536
|
+
СПОСОБ ПОИСКА · ГРАНИЦА КОРПУСА · СЛЕДСТВИЕ. "We did not find it" is a property of the SEARCH;
|
|
537
|
+
"it is not there" is a property of the WORLD, and only an exhaustive corpus licenses the second.
|
|
538
|
+
Form, closed values and the measured reason: `references/negative-results.md`.
|
|
519
539
|
10. Report confidence, evidence-class mix, unsigned claims, rejected signatures, and limitations
|
|
520
540
|
explicitly.
|
|
521
541
|
|
|
522
542
|
## Output Expectations
|
|
523
543
|
|
|
524
|
-
Reports
|
|
525
|
-
|
|
526
|
-
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
- A visible marker next to every `LISTING_ONLY` claim ("source not opened directly — verify").
|
|
532
|
-
- Chain integrity result when citation chains are used.
|
|
533
|
-
- Unsigned and rejected claims.
|
|
534
|
-
- Explicit caveat that cryptographic provenance is not truth verification, and that
|
|
535
|
-
`FETCH_VERIFIED` means the bytes arrived — not that the source is right.
|
|
544
|
+
Reports include: the objective + executed GOAP plan; findings with source URLs; per-claim status on
|
|
545
|
+
BOTH axes — trust (`ISSUER_SIGNED`/`SELF_ATTESTED`/`UNVERIFIED`) and evidence
|
|
546
|
+
(`FETCH_VERIFIED`/`LISTING_ONLY`/`ASSERTED`/unknown-legacy); the evidence-class mix as a count
|
|
547
|
+
(the `ASSERTED` share should be zero); a visible marker on every `LISTING_ONLY` claim ("source not
|
|
548
|
+
opened directly — verify"); chain integrity when chains are used; unsigned and rejected claims; the
|
|
549
|
+
explicit caveat that cryptographic provenance is not truth verification — `FETCH_VERIFIED` means
|
|
550
|
+
the bytes arrived, not that the source is right.
|
|
536
551
|
|
|
537
552
|
## Implementation
|
|
538
553
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Отрицательный вывод: «не нашли» и «доказали, что нет» — разные утверждения
|
|
2
|
+
|
|
3
|
+
Читается по требованию. В `SKILL.md` из этого файла живёт одна строка-указатель; всё остальное здесь,
|
|
4
|
+
потому что всегда-загружаемый корпус пакета исчерпан, а объём этой темы в него не помещается.
|
|
5
|
+
|
|
6
|
+
## Отказ, ради которого это написано
|
|
7
|
+
|
|
8
|
+
Отчёт исследования умеет делать очень сильное утверждение: **«такой функции нет ни у одного из
|
|
9
|
+
конкурентов; ниша свободна»**. На нём основывают продукт. И ровно оно было НЕВИДИМО для аппарата
|
|
10
|
+
доверия.
|
|
11
|
+
|
|
12
|
+
**ИЗМЕРЕНО 2026-09-01, ТВИН-ТЕСТОМ, а не грепом** (грепу здесь верить нельзя: он находит словарь, а
|
|
13
|
+
не слепоту). `scripts/check_report_evidence.py` шёл ОТ ЛЕДЖЕРА К ОТЧЁТУ — для каждого записанного
|
|
14
|
+
факта искал его употребление в тексте. Два прогона на ОДНОМ леджере, отчёты различаются РОВНО одной
|
|
15
|
+
строкой отрицательного вывода:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python3 check_report_evidence.py --report a.md --facts f.json --json > oa.json
|
|
19
|
+
python3 check_report_evidence.py --report b.md --facts f.json --json > ob.json # b.md = a.md + одна строка
|
|
20
|
+
diff oa.json ob.json # ДО починки: пусто, оба exit 0
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Вывод гейта был БАЙТ В БАЙТ одинаков. То есть ничем не подкреплённое отрицание не могло ни
|
|
24
|
+
увеличить, ни уменьшить число находок: оно было вне области зрения ПО ПОСТРОЕНИЮ, а не по недосмотру.
|
|
25
|
+
Причина простая и структурная: аппарат оценивал только утверждения, у которых ЕСТЬ источник, а у
|
|
26
|
+
отрицания источника нет — в этом и состоит отрицание.
|
|
27
|
+
|
|
28
|
+
**МЕХАНИЗМ:** «не встретилось» и «доказано, что нет» приходят в отчёте В ОДИНАКОВОЙ ФОРМЕ, и решения
|
|
29
|
+
по ним принимают одинаково. Первое — свойство ПОИСКА. Второе — свойство МИРА. Только второе
|
|
30
|
+
оправдывает продукт, построенный на пустой нише.
|
|
31
|
+
|
|
32
|
+
## Форма, которую отрицательный вывод обязан нести
|
|
33
|
+
|
|
34
|
+
Пять полей рядом с утверждением (в пределах 900 знаков — блок, а не приложение):
|
|
35
|
+
|
|
36
|
+
| Поле | Что отвечает | Закрытые значения |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `КОРПУС` | что именно прочитано, ПЕРЕЧНЕМ | свободный текст, но перечислимый |
|
|
39
|
+
| `ПОЛНОТА` | чем является этот перечень | `перечислимо и объявлено исчерпывающим` · `выборка` · `неизвестна` |
|
|
40
|
+
| `СПОСОБ ПОИСКА` | как искали и какими запросами | свободный текст |
|
|
41
|
+
| `ГРАНИЦА КОРПУСА` | по состоянию на когда | свободный текст |
|
|
42
|
+
| `СЛЕДСТВИЕ` | что из этого следует | `не встретилось` · `намеренно отсутствует` |
|
|
43
|
+
|
|
44
|
+
**Потолок, и он несущий.** `СЛЕДСТВИЕ: намеренно отсутствует` допустимо ТОЛЬКО при
|
|
45
|
+
`ПОЛНОТА: перечислимо и объявлено исчерпывающим`. Выборка и неизвестная полнота поддерживают
|
|
46
|
+
«не встретилось» и ничего сильнее: отсутствие в ЧАСТИ корпуса — отсутствие в ПОИСКЕ, а не в мире.
|
|
47
|
+
|
|
48
|
+
**Почему пяти полей, а не двух.** Первая редакция требовала только `КОРПУС` и `ПОЛНОТА`. Проверяющая
|
|
49
|
+
модель назвала это самым слабым местом заявки и была ПРАВА: **исчерпывающий ПЕРЕЧЕНЬ не доказывает
|
|
50
|
+
исчерпывающего ПОИСКА**. Двенадцать страниц, прочитанных от корки до корки, всё равно пропустят
|
|
51
|
+
функцию, если это были не те двенадцать — или если в запросе ни разу не прозвучало слово, которым
|
|
52
|
+
её называет сам поставщик. Поэтому в потолок входят также способ поиска, покрытие запросов, отказы
|
|
53
|
+
выборки и временная граница корпуса. Без них сильное «нет» выписывает себе разрешение само.
|
|
54
|
+
|
|
55
|
+
## Пример
|
|
56
|
+
|
|
57
|
+
```markdown
|
|
58
|
+
Вывод: массового экспорта нет ни у одного из трёх конкурентов.
|
|
59
|
+
|
|
60
|
+
КОРПУС: справочные центры A (14 стр.), B (9 стр.), C (11 стр.) — перечень в приложении A
|
|
61
|
+
ПОЛНОТА: выборка — 34 из ~120 страниц; отказ выборки: раздел для партнёров под входом
|
|
62
|
+
СПОСОБ ПОИСКА: полнотекстовый поиск по 6 запросам: «bulk export», «экспорт», «CSV», «API export», «выгрузка», «download all»
|
|
63
|
+
ГРАНИЦА КОРПУСА: по состоянию на 2026-08-30
|
|
64
|
+
СЛЕДСТВИЕ: не встретилось
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`СЛЕДСТВИЕ: намеренно отсутствует` в этом примере было бы отвергнуто гейтом: полнота — выборка.
|
|
68
|
+
|
|
69
|
+
## Чего проверка НЕ решает, и это граница, а не недоделка
|
|
70
|
+
|
|
71
|
+
**Подходит ли названный корпус ВОПРОСУ.** Второй случай из полевого каталога ровно об этом: вопрос
|
|
72
|
+
«есть ли у продукта X функция Y», функция описана ТОЛЬКО в справочном центре, а корпусом взята карта
|
|
73
|
+
сайта. Карта сайта — идеально перечислимый, идеально исчерпывающий и НЕ ТОТ корпус. Проверка
|
|
74
|
+
заставляет корпус НАЗВАТЬ, чтобы человек мог судить о его пригодности; судить — не её работа, и
|
|
75
|
+
делать вид, что её, значило бы то самое ложное закрытие класса, которое весь гейт и отвергает.
|
|
76
|
+
|
|
77
|
+
## Ворота
|
|
78
|
+
|
|
79
|
+
Отдельного вызова не нужно: проверка живёт внутри уже существующего гейта и ходит во ВТОРУЮ сторону —
|
|
80
|
+
от отрицательных выводов отчёта к их обоснованию.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
python3 .claude/skills/goap-research-ed25519/scripts/check_report_evidence.py \
|
|
84
|
+
--report report.md --facts facts.json --json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Виды находок: `NEGATIVE_CONCLUSION_WITHOUT_BASIS` (нет одного из пяти полей — перечисляются, каких) ·
|
|
88
|
+
`NEGATIVE_COMPLETENESS_UNRECOGNISED` · `NEGATIVE_IMPLICATION_UNRECOGNISED` ·
|
|
89
|
+
`NEGATIVE_CLAIM_EXCEEDS_ITS_CEILING` (сильное следствие на выборке).
|
|
90
|
+
|
|
91
|
+
**Область срабатывания сужена намеренно.** Ловятся УНИВЕРСАЛЬНЫЕ отрицания («ни у одного», «none of
|
|
92
|
+
the», «ниша свободна»), а не любое «нет» в тексте: жадный сторож — не более строгий сторож, а
|
|
93
|
+
сторож, который удалят. Новая формулировка отрицания будет пропущена, и здесь это сказано вслух,
|
|
94
|
+
ровно как соседний пояс по относительному риску говорит о себе то же самое.
|
|
@@ -11,7 +11,7 @@ evidence axis is lost if "don't include unread claims" stays a sentence in
|
|
|
11
11
|
SKILL.md that a tired model skips at 2am.
|
|
12
12
|
|
|
13
13
|
Usage:
|
|
14
|
-
python3 check_report_evidence.py --report report.md --facts facts.json [--json]
|
|
14
|
+
python3 check_report_evidence.py --report report.md --facts facts.json [--excerpts DIR] [--json]
|
|
15
15
|
|
|
16
16
|
Exit codes:
|
|
17
17
|
0 clean — no ASSERTED used, every used LISTING_ONLY carries a marker
|
|
@@ -30,11 +30,14 @@ from __future__ import annotations
|
|
|
30
30
|
|
|
31
31
|
import argparse
|
|
32
32
|
import json
|
|
33
|
+
import os
|
|
33
34
|
import re
|
|
34
35
|
import sys
|
|
35
36
|
from dataclasses import dataclass
|
|
36
37
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
|
37
38
|
|
|
39
|
+
import quote_provenance as quote_provenance
|
|
40
|
+
|
|
38
41
|
EVIDENCE_FETCH_VERIFIED = "FETCH_VERIFIED"
|
|
39
42
|
EVIDENCE_LISTING_ONLY = "LISTING_ONLY"
|
|
40
43
|
EVIDENCE_ASSERTED = "ASSERTED"
|
|
@@ -56,6 +59,17 @@ LISTING_MARKERS = (
|
|
|
56
59
|
# does not warn the reader of a sentence on page 2.
|
|
57
60
|
MARKER_WINDOW_CHARS = 400
|
|
58
61
|
|
|
62
|
+
# Report quotes are paired to a fact through the existing generous claim scanner,
|
|
63
|
+
# but the verdict itself is an exact normalized comparison in quote_provenance.
|
|
64
|
+
# Keeping those two polarities separate is load-bearing: generosity is safe for
|
|
65
|
+
# finding a claim and unsafe for granting "verbatim".
|
|
66
|
+
QUOTE_MIN_SIGNIFICANT_CHARS = 15
|
|
67
|
+
QUOTE_PATTERNS = (
|
|
68
|
+
re.compile(r'«([^»\n]+)»', re.UNICODE),
|
|
69
|
+
re.compile(r'"([^"\n]+)"', re.UNICODE),
|
|
70
|
+
re.compile(r'„([^“\n]+)“', re.UNICODE),
|
|
71
|
+
)
|
|
72
|
+
|
|
59
73
|
# ---------------------------------------------------------------------------
|
|
60
74
|
# SLICE C ADDITIONS (ADR-001 §5, ADR-002 §3) — ADDITIVE ONLY.
|
|
61
75
|
# No existing constant, rule, threshold or exit path above or below is modified:
|
|
@@ -370,6 +384,118 @@ def evaluate(report_text: str, facts: Sequence[Dict[str, Any]]) -> Tuple[List[Fi
|
|
|
370
384
|
return findings, counts
|
|
371
385
|
|
|
372
386
|
|
|
387
|
+
def _quoted_spans(report_text: str) -> List[Tuple[str, int, int]]:
|
|
388
|
+
spans: List[Tuple[str, int, int]] = []
|
|
389
|
+
occupied: List[Tuple[int, int]] = []
|
|
390
|
+
for pattern in QUOTE_PATTERNS:
|
|
391
|
+
for match in pattern.finditer(report_text):
|
|
392
|
+
start, end = match.span(1)
|
|
393
|
+
if any(left <= start < right or left < end <= right for left, right in occupied):
|
|
394
|
+
continue
|
|
395
|
+
span = match.group(1).strip()
|
|
396
|
+
significant = len(re.sub(r"[^\w\d]", "", span, flags=re.UNICODE))
|
|
397
|
+
if significant < QUOTE_MIN_SIGNIFICANT_CHARS:
|
|
398
|
+
continue
|
|
399
|
+
occupied.append(match.span(0))
|
|
400
|
+
spans.append((span, start, end))
|
|
401
|
+
return sorted(spans, key=lambda item: item[1])
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _quote_finding_kind(verdict: str) -> str:
|
|
405
|
+
if verdict == "not-in-excerpt":
|
|
406
|
+
return "QUOTE_NOT_IN_SOURCE"
|
|
407
|
+
if verdict == "method-ineligible":
|
|
408
|
+
return "QUOTE_METHOD_INELIGIBLE"
|
|
409
|
+
return "QUOTE_NO_EXCERPT"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def evaluate_quotes(
|
|
413
|
+
report_text: str,
|
|
414
|
+
facts: Sequence[Dict[str, Any]],
|
|
415
|
+
excerpt_dir: str,
|
|
416
|
+
) -> Tuple[List[Finding], Dict[str, int]]:
|
|
417
|
+
"""Grade report quotations only from captured excerpt bytes, never metadata.
|
|
418
|
+
|
|
419
|
+
``claim_positions`` is deliberately used only to associate report text with a
|
|
420
|
+
fact. Its word-overlap fallback NEVER reaches ``verify_verbatim``: the latter
|
|
421
|
+
performs the exact normalized substring comparison that alone can confirm.
|
|
422
|
+
"""
|
|
423
|
+
findings: List[Finding] = []
|
|
424
|
+
counts = {f"quote-{verdict}": 0 for verdict in quote_provenance.VERBATIM_VERDICTS}
|
|
425
|
+
counts.update({f"quote-acquisition-{method}": 0 for method in quote_provenance.ACQUISITION_METHODS})
|
|
426
|
+
counts["quote-acquisition-unknown"] = 0
|
|
427
|
+
counts["quote-total"] = 0
|
|
428
|
+
counts["quote-unchecked"] = 0
|
|
429
|
+
spans = _quoted_spans(report_text)
|
|
430
|
+
seen_pairs = set()
|
|
431
|
+
|
|
432
|
+
for fact_index, fact in enumerate(facts):
|
|
433
|
+
recorded_quote = fact.get("quote")
|
|
434
|
+
if not isinstance(recorded_quote, str) or not recorded_quote.strip():
|
|
435
|
+
continue
|
|
436
|
+
acquisition = quote_provenance.read_acquisition(fact.get("acquisition"))
|
|
437
|
+
claim = str(fact.get("claim", ""))
|
|
438
|
+
positions = claim_positions(report_text, claim) or claim_positions(report_text, recorded_quote)
|
|
439
|
+
normalized_recorded = quote_provenance.normalize_text(recorded_quote)
|
|
440
|
+
candidates = [
|
|
441
|
+
(span, start, end) for span, start, end in spans
|
|
442
|
+
if quote_provenance.normalize_text(span) == normalized_recorded
|
|
443
|
+
and any(abs(start - position) <= MARKER_WINDOW_CHARS for position in positions)
|
|
444
|
+
]
|
|
445
|
+
for span, start, _ in candidates:
|
|
446
|
+
pair = (fact_index, start)
|
|
447
|
+
if pair in seen_pairs:
|
|
448
|
+
continue
|
|
449
|
+
seen_pairs.add(pair)
|
|
450
|
+
counts["quote-total"] += 1
|
|
451
|
+
method_key = (
|
|
452
|
+
f"quote-acquisition-{acquisition}"
|
|
453
|
+
if acquisition in quote_provenance.ACQUISITION_METHODS
|
|
454
|
+
else "quote-acquisition-unknown"
|
|
455
|
+
)
|
|
456
|
+
counts[method_key] += 1
|
|
457
|
+
locator = fact.get("locator") or "(no locator)"
|
|
458
|
+
source_url = str(fact.get("source_url", ""))
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
schema_version = int(fact.get("schema_version"))
|
|
462
|
+
except (TypeError, ValueError):
|
|
463
|
+
schema_version = 0
|
|
464
|
+
if schema_version < 4:
|
|
465
|
+
verdict = quote_provenance.METHOD_UNKNOWN if acquisition == quote_provenance.METHOD_UNKNOWN else "no-excerpt"
|
|
466
|
+
counts[f"quote-{verdict}"] += 1
|
|
467
|
+
findings.append(Finding(
|
|
468
|
+
kind="QUOTE_NO_EXCERPT", claim=span, source_url=source_url,
|
|
469
|
+
detail=(f"quote {span!r} at {locator} is carried by schema v{schema_version or 'unknown'}; "
|
|
470
|
+
"quote provenance is not inside its signed message, so verbatim is unverified")))
|
|
471
|
+
continue
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
excerpt_rec = quote_provenance.load_excerpt(excerpt_dir, fact.get("excerpt_id"))
|
|
475
|
+
except quote_provenance.ExcerptStoreError as exc:
|
|
476
|
+
counts["quote-unchecked"] += 1
|
|
477
|
+
findings.append(Finding(
|
|
478
|
+
kind="QUOTES_UNCHECKED", claim=span, source_url=source_url,
|
|
479
|
+
detail=f"quote {span!r} at {locator} could not be checked: {exc}"))
|
|
480
|
+
continue
|
|
481
|
+
|
|
482
|
+
verdict = quote_provenance.verify_verbatim(span, excerpt_rec, fact)
|
|
483
|
+
counts[f"quote-{verdict}"] += 1
|
|
484
|
+
if verdict == "verbatim-confirmed":
|
|
485
|
+
continue
|
|
486
|
+
reason = {
|
|
487
|
+
"not-in-excerpt": "is absent from the single stored excerpt leaf; joining excerpts is forbidden",
|
|
488
|
+
"no-excerpt": "has no decodable captured excerpt",
|
|
489
|
+
"method-ineligible": f"uses acquisition method {acquisition!r}, whose ceiling forbids verbatim regardless of content",
|
|
490
|
+
"hash-mismatch": "has an excerpt/body/source hash mismatch; author-constructed material grants nothing",
|
|
491
|
+
quote_provenance.METHOD_UNKNOWN: "has an absent or unknown acquisition method; legacy method-unknown never grants verbatim",
|
|
492
|
+
}[verdict]
|
|
493
|
+
findings.append(Finding(
|
|
494
|
+
kind=_quote_finding_kind(verdict), claim=span, source_url=source_url,
|
|
495
|
+
detail=f"quote {span!r} at {locator} {reason} (verdict: {verdict})"))
|
|
496
|
+
return findings, counts
|
|
497
|
+
|
|
498
|
+
|
|
373
499
|
# ---------------------------------------------------------------------------
|
|
374
500
|
# SLICE C — population applicability (ADR-001 §5) and the relative-risk belt
|
|
375
501
|
# (ADR-002 §3). New functions; nothing above is rewritten.
|
|
@@ -561,6 +687,209 @@ def evaluate_population(report_text: str, facts: Sequence[Dict[str, Any]],
|
|
|
561
687
|
return findings, counts
|
|
562
688
|
|
|
563
689
|
|
|
690
|
+
# ---------------------------------------------------------------------------
|
|
691
|
+
# NEGATIVE CONCLUSIONS — the SECOND DIRECTION.
|
|
692
|
+
#
|
|
693
|
+
# Everything above this line walks FROM THE LEDGER TO THE REPORT: for each recorded
|
|
694
|
+
# fact, is it used, and is its use honest? That direction is structurally blind to a
|
|
695
|
+
# claim that HAS NO SOURCE — and the strongest claim a research report can make is
|
|
696
|
+
# exactly that shape: "no competitor has this; the niche is free."
|
|
697
|
+
#
|
|
698
|
+
# MEASURED on this package, 2026-09-01, by TWIN TEST rather than by grep (grep is not
|
|
699
|
+
# trustworthy here — it finds the vocabulary, not the blindness): two runs over ONE
|
|
700
|
+
# ledger, the reports differing by EXACTLY one line, that line being an unsupported
|
|
701
|
+
# negative conclusion. The gate's `--json` output was BYTE-IDENTICAL, both exit 0. So
|
|
702
|
+
# an unbacked negation can neither raise nor lower the finding count: it is outside
|
|
703
|
+
# the field of view by construction, not by oversight.
|
|
704
|
+
#
|
|
705
|
+
# THE MECHANISM: "we did not find it" and "we proved it is not there" arrive in the
|
|
706
|
+
# report in the SAME FORM, and decisions are made on both identically. The first is a
|
|
707
|
+
# property of the SEARCH. The second is a property of the WORLD. Only the second
|
|
708
|
+
# justifies building a product on an empty niche.
|
|
709
|
+
#
|
|
710
|
+
# THE CEILING, and why the obvious version of it is not enough. The first design said
|
|
711
|
+
# a negative conclusion needs an enumerable CORPUS declared exhaustive. A reviewing
|
|
712
|
+
# model called that the weakest part of the proposal and was RIGHT: an exhaustive LIST
|
|
713
|
+
# does not prove an exhaustive SEARCH. Twelve pages read end-to-end still miss the
|
|
714
|
+
# feature if the twelve were the wrong twelve, or if the query never used the vendor's
|
|
715
|
+
# word for it. So the ceiling also requires the SEARCH METHOD, the QUERY COVERAGE, the
|
|
716
|
+
# SAMPLING REFUSALS, and the corpus's TIME BOUNDARY. Without those, a strong "no"
|
|
717
|
+
# writes itself a licence.
|
|
718
|
+
#
|
|
719
|
+
# WHAT THIS SCAN CANNOT DECIDE, stated here and printed in the gate's output: whether
|
|
720
|
+
# the named corpus is the RIGHT corpus for the question. The record's own second case
|
|
721
|
+
# is exactly that — "does product X have feature Y", where the feature is documented
|
|
722
|
+
# only in the help centre and the corpus taken was the sitemap. A sitemap is a
|
|
723
|
+
# perfectly enumerable, perfectly exhaustive corpus, and the wrong one. This scan
|
|
724
|
+
# forces the corpus to be NAMED so a person can judge its fitness; judging it stays
|
|
725
|
+
# layer 3, and pretending otherwise would be the same false closure the whole gate
|
|
726
|
+
# exists to refuse.
|
|
727
|
+
# ---------------------------------------------------------------------------
|
|
728
|
+
|
|
729
|
+
# A NEGATIVE UNIVERSAL claim: absence asserted across a whole population of products,
|
|
730
|
+
# vendors or sources. Deliberately narrower than "any negation" — `there is no` alone
|
|
731
|
+
# fires on ordinary prose, and an eager gate is not a stricter gate, it is a deleted
|
|
732
|
+
# one. Every pattern here requires the UNIVERSAL, which is what makes the claim strong.
|
|
733
|
+
NEGATIVE_CLAIM_PATTERNS = (
|
|
734
|
+
r"ни\s+у\s+одного",
|
|
735
|
+
r"ни\s+один\s+из\s+[^.\n]{0,60}?\s+не\b",
|
|
736
|
+
r"ни\s+одного\s+из",
|
|
737
|
+
r"нет\s+ни\s+у\s+кого",
|
|
738
|
+
r"ниша\s+свободна",
|
|
739
|
+
r"никто\s+(?:из\s+\w+\s+)?не\s+(?:предлагает|поддерживает|делает|умеет)",
|
|
740
|
+
r"отсутствует\s+у\s+всех",
|
|
741
|
+
r"no\s+(?:competitor|vendor|provider|product|tool)s?\s+\w*\s*(?:has|have|offers?|supports?|provides?)",
|
|
742
|
+
r"none\s+of\s+the\s+\w+\s+(?:has|have|offer|offers|support|supports|provide|provides)",
|
|
743
|
+
r"nobody\s+(?:offers|supports|provides)",
|
|
744
|
+
r"the\s+niche\s+is\s+(?:free|open|unoccupied)",
|
|
745
|
+
r"we\s+found\s+no\s+\w+\s+(?:that|which)",
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
# The block that licenses such a claim. Both spellings, because a report may be written
|
|
749
|
+
# in either language; the fields must be VISIBLE to a reader, never a hidden attribute.
|
|
750
|
+
NEGATIVE_FIELDS = (
|
|
751
|
+
("corpus", ("КОРПУС", "CORPUS")),
|
|
752
|
+
("completeness", ("ПОЛНОТА", "COMPLETENESS")),
|
|
753
|
+
("method", ("СПОСОБ ПОИСКА", "SEARCH METHOD")),
|
|
754
|
+
("boundary", ("ГРАНИЦА КОРПУСА", "CORPUS AS OF")),
|
|
755
|
+
("implication", ("СЛЕДСТВИЕ", "IMPLICATION")),
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
# How far from the claim the licensing block may sit. Wider than MARKER_WINDOW_CHARS
|
|
759
|
+
# because this is a five-line block rather than a parenthetical, and narrower than the
|
|
760
|
+
# document because a basis in the appendix does not reach a sentence on page 2.
|
|
761
|
+
NEGATIVE_WINDOW_CHARS = 900
|
|
762
|
+
|
|
763
|
+
# CLOSED. Three, because collapsing them would erase the only distinction that matters:
|
|
764
|
+
# an enumerated corpus its own author calls complete is a different epistemic object
|
|
765
|
+
# from a sample, and both differ from not knowing which you have.
|
|
766
|
+
COMPLETENESS_VALUES = {
|
|
767
|
+
"перечислимо и объявлено исчерпывающим": "exhaustive",
|
|
768
|
+
"enumerable and declared exhaustive": "exhaustive",
|
|
769
|
+
"выборка": "sample",
|
|
770
|
+
"sample": "sample",
|
|
771
|
+
"неизвестна": "unknown",
|
|
772
|
+
"unknown": "unknown",
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
# CLOSED, and the whole point of the feature is that these two are not interchangeable.
|
|
776
|
+
IMPLICATION_VALUES = {
|
|
777
|
+
"не встретилось": "not-encountered",
|
|
778
|
+
"not encountered": "not-encountered",
|
|
779
|
+
"намеренно отсутствует": "deliberately-absent",
|
|
780
|
+
"deliberately absent": "deliberately-absent",
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _negative_field(window: str, spellings: Sequence[str]) -> Optional[str]:
|
|
785
|
+
"""The value of one licensing field inside the window, or None when it is absent.
|
|
786
|
+
|
|
787
|
+
An EMPTY value returns '' and is never collapsed into absent: "КОРПУС:" with
|
|
788
|
+
nothing after it is a different mistake from no КОРПУС line at all, and the two
|
|
789
|
+
need different repairs.
|
|
790
|
+
"""
|
|
791
|
+
for spelling in spellings:
|
|
792
|
+
match = re.search(
|
|
793
|
+
r"^[\s>*\-]*(?:\*\*)?" + re.escape(spelling) + r"(?:\*\*)?\s*[::]\s*(.*)$",
|
|
794
|
+
window, re.IGNORECASE | re.MULTILINE | re.UNICODE)
|
|
795
|
+
if match:
|
|
796
|
+
return match.group(1).strip().strip("*`")
|
|
797
|
+
return None
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _closed_value(raw: Optional[str], table: Mapping[str, str]) -> Optional[str]:
|
|
801
|
+
"""Map a field value onto a CLOSED vocabulary, matching on the leading phrase so a
|
|
802
|
+
author may add detail after it — `выборка — 12 из ~40 страниц` is a sample that
|
|
803
|
+
says how big, which is better than a bare word and must not be refused for it."""
|
|
804
|
+
if raw is None:
|
|
805
|
+
return None
|
|
806
|
+
lowered = raw.strip().lower()
|
|
807
|
+
for key, value in table.items():
|
|
808
|
+
if lowered == key or lowered.startswith(key + " ") or lowered.startswith(key + ",") \
|
|
809
|
+
or lowered.startswith(key + " —") or lowered.startswith(key + " -") \
|
|
810
|
+
or lowered.startswith(key + ":"):
|
|
811
|
+
return value
|
|
812
|
+
return "unrecognised"
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def scan_negative_conclusions(report_text: str) -> List[Finding]:
|
|
816
|
+
"""The second direction: from the report's NEGATIVE conclusions back to their basis.
|
|
817
|
+
|
|
818
|
+
A claim of universal absence must carry, within its window, the five fields that
|
|
819
|
+
distinguish a property of the SEARCH from a property of the WORLD — and the strong
|
|
820
|
+
implication (`намеренно отсутствует`) is admissible only on top of an exhaustive
|
|
821
|
+
corpus, because an exhaustive list is not an exhaustive search.
|
|
822
|
+
|
|
823
|
+
HONEST SCOPE: this matches CLAIM SHAPES and FIELD PRESENCE. It cannot tell whether
|
|
824
|
+
the named corpus fits the question — a sitemap is enumerable, exhaustive and the
|
|
825
|
+
wrong place to look for a feature. Naming the corpus is what makes that arguable;
|
|
826
|
+
judging it is not this gate's to do.
|
|
827
|
+
"""
|
|
828
|
+
findings: List[Finding] = []
|
|
829
|
+
seen_spans: List[Tuple[int, int]] = []
|
|
830
|
+
for pattern in NEGATIVE_CLAIM_PATTERNS:
|
|
831
|
+
for hit in re.finditer(pattern, report_text, re.IGNORECASE | re.UNICODE):
|
|
832
|
+
# One sentence, one finding: two patterns matching the same claim describe
|
|
833
|
+
# one defect, and reporting it twice would inflate the count the twin test
|
|
834
|
+
# measures.
|
|
835
|
+
if any(start <= hit.start() < end for start, end in seen_spans):
|
|
836
|
+
continue
|
|
837
|
+
line_start = report_text.rfind("\n", 0, hit.start()) + 1
|
|
838
|
+
line_end = report_text.find("\n", hit.end())
|
|
839
|
+
line_end = len(report_text) if line_end < 0 else line_end
|
|
840
|
+
seen_spans.append((line_start, line_end))
|
|
841
|
+
claim = report_text[line_start:line_end].strip()
|
|
842
|
+
|
|
843
|
+
window = report_text[max(0, hit.start() - NEGATIVE_WINDOW_CHARS):
|
|
844
|
+
min(len(report_text), hit.end() + NEGATIVE_WINDOW_CHARS)]
|
|
845
|
+
values = {name: _negative_field(window, spellings) for name, spellings in NEGATIVE_FIELDS}
|
|
846
|
+
missing = [spellings[0] for name, spellings in NEGATIVE_FIELDS
|
|
847
|
+
if values[name] is None or values[name] == ""]
|
|
848
|
+
if missing:
|
|
849
|
+
findings.append(Finding(
|
|
850
|
+
kind="NEGATIVE_CONCLUSION_WITHOUT_BASIS", claim=claim, source_url="",
|
|
851
|
+
detail=("a claim of universal ABSENCE with no basis beside it — missing: {m}. "
|
|
852
|
+
"'we did not find it' and 'we proved it is not there' are written the "
|
|
853
|
+
"same way and decided on identically; the first is a property of the "
|
|
854
|
+
"SEARCH, the second of the WORLD. Required within {w} chars: {req}"
|
|
855
|
+
.format(m=", ".join(missing), w=NEGATIVE_WINDOW_CHARS,
|
|
856
|
+
req=", ".join(s[0] for _, s in NEGATIVE_FIELDS)))))
|
|
857
|
+
continue
|
|
858
|
+
|
|
859
|
+
completeness = _closed_value(values["completeness"], COMPLETENESS_VALUES)
|
|
860
|
+
implication = _closed_value(values["implication"], IMPLICATION_VALUES)
|
|
861
|
+
if completeness == "unrecognised":
|
|
862
|
+
findings.append(Finding(
|
|
863
|
+
kind="NEGATIVE_COMPLETENESS_UNRECOGNISED", claim=claim, source_url="",
|
|
864
|
+
detail=("ПОЛНОТА is {v!r}, which is outside the closed set {allowed}. An open "
|
|
865
|
+
"field here collects a word that sounds thorough and commits to nothing"
|
|
866
|
+
.format(v=values["completeness"],
|
|
867
|
+
allowed=sorted(set(COMPLETENESS_VALUES.values()))))))
|
|
868
|
+
continue
|
|
869
|
+
if implication == "unrecognised":
|
|
870
|
+
findings.append(Finding(
|
|
871
|
+
kind="NEGATIVE_IMPLICATION_UNRECOGNISED", claim=claim, source_url="",
|
|
872
|
+
detail=("СЛЕДСТВИЕ is {v!r}, which is outside the closed set {allowed}. These "
|
|
873
|
+
"two are exactly what must not be interchangeable"
|
|
874
|
+
.format(v=values["implication"],
|
|
875
|
+
allowed=sorted(set(IMPLICATION_VALUES.values()))))))
|
|
876
|
+
continue
|
|
877
|
+
|
|
878
|
+
# THE CEILING, and the correction that made this record worth shipping: the
|
|
879
|
+
# strong reading needs an exhaustive corpus UNDER it. A sample can support
|
|
880
|
+
# "we did not encounter it" and can never support "it is deliberately absent".
|
|
881
|
+
if implication == "deliberately-absent" and completeness != "exhaustive":
|
|
882
|
+
findings.append(Finding(
|
|
883
|
+
kind="NEGATIVE_CLAIM_EXCEEDS_ITS_CEILING", claim=claim, source_url="",
|
|
884
|
+
detail=("СЛЕДСТВИЕ 'намеренно отсутствует' rests on ПОЛНОТА {c!r}. A sample — or "
|
|
885
|
+
"an unknown completeness — supports 'не встретилось' and nothing "
|
|
886
|
+
"stronger: absence in part of a corpus is absence from the SEARCH, not "
|
|
887
|
+
"from the world. Lower the implication, or make the corpus enumerable "
|
|
888
|
+
"and declare it exhaustive"
|
|
889
|
+
.format(c=values["completeness"]))))
|
|
890
|
+
return findings
|
|
891
|
+
|
|
892
|
+
|
|
564
893
|
def scan_relative_risk(report_text: str) -> List[Finding]:
|
|
565
894
|
"""BELT (D-19): refuse a relative figure with no absolute companion in the window.
|
|
566
895
|
|
|
@@ -619,6 +948,30 @@ def render_population_and_risk(counts: Dict[str, int], profile_supplied: bool) -
|
|
|
619
948
|
return "\n".join(lines)
|
|
620
949
|
|
|
621
950
|
|
|
951
|
+
def render_quotes(counts: Dict[str, int]) -> str:
|
|
952
|
+
"""One compact, auditable line for the independent quote-verdict axis."""
|
|
953
|
+
return (
|
|
954
|
+
" quotes: {total} checked — confirmed {confirmed} / absent {absent} / no-excerpt {missing} / "
|
|
955
|
+
"method-ineligible {ineligible} / hash-mismatch {mismatch} / method-unknown {unknown} / "
|
|
956
|
+
"unchecked {unchecked}; methods raw-fetch {raw} / tool-summary {tool} / search-listing {search} / "
|
|
957
|
+
"manual {manual} / unknown {method_unknown}"
|
|
958
|
+
).format(
|
|
959
|
+
total=counts.get("quote-total", 0),
|
|
960
|
+
confirmed=counts.get("quote-verbatim-confirmed", 0),
|
|
961
|
+
absent=counts.get("quote-not-in-excerpt", 0),
|
|
962
|
+
missing=counts.get("quote-no-excerpt", 0),
|
|
963
|
+
ineligible=counts.get("quote-method-ineligible", 0),
|
|
964
|
+
mismatch=counts.get("quote-hash-mismatch", 0),
|
|
965
|
+
unknown=counts.get("quote-method-unknown", 0),
|
|
966
|
+
unchecked=counts.get("quote-unchecked", 0),
|
|
967
|
+
raw=counts.get("quote-acquisition-raw-fetch", 0),
|
|
968
|
+
tool=counts.get("quote-acquisition-tool-summary", 0),
|
|
969
|
+
search=counts.get("quote-acquisition-search-listing", 0),
|
|
970
|
+
manual=counts.get("quote-acquisition-manual", 0),
|
|
971
|
+
method_unknown=counts.get("quote-acquisition-unknown", 0),
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
|
|
622
975
|
def render(findings: Sequence[Finding], counts: Dict[str, int]) -> str:
|
|
623
976
|
lines: List[str] = []
|
|
624
977
|
total = sum(counts.values())
|
|
@@ -649,7 +1002,9 @@ def render(findings: Sequence[Finding], counts: Dict[str, int]) -> str:
|
|
|
649
1002
|
" scope: proves the report does not lean on unread sources AND that the ledger's evidence "
|
|
650
1003
|
"classes carry intact signatures. It does NOT prove the cited sources support the claims, "
|
|
651
1004
|
"and it can only judge claims it recognises as used — paraphrase detection is deliberately "
|
|
652
|
-
"over-eager, because a missed unread claim is silent while a false alarm is arguable."
|
|
1005
|
+
"over-eager, because a missed unread claim is silent while a false alarm is arguable. "
|
|
1006
|
+
"Negative conclusions are judged in the OTHER direction, from the claim back to its basis; "
|
|
1007
|
+
"that scan proves the basis is STATED, never that the named corpus fits the question."
|
|
653
1008
|
)
|
|
654
1009
|
return "\n".join(lines)
|
|
655
1010
|
|
|
@@ -660,6 +1015,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
660
1015
|
parser.add_argument("--facts", required=True, help="path to facts.json")
|
|
661
1016
|
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
662
1017
|
parser.add_argument("--pins", help="JSON file of {issuer: pubkey_b64} so ISSUER_SIGNED facts can be checked")
|
|
1018
|
+
parser.add_argument(
|
|
1019
|
+
"--excerpts",
|
|
1020
|
+
help="captured excerpt directory (default: evidence_excerpts beside the facts ledger)",
|
|
1021
|
+
)
|
|
663
1022
|
# SLICE C: OPTIONAL on purpose. Making it mandatory would break every documented
|
|
664
1023
|
# invocation for a check that cannot always apply; letting its absence pass
|
|
665
1024
|
# silently would be "inconclusive reads as pass". Its absence is PRINTED instead.
|
|
@@ -695,13 +1054,17 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
695
1054
|
# SLICE C: three independent judgements merged into the SAME findings list — the
|
|
696
1055
|
# merge must not lose one, and the existing exit path below is unchanged.
|
|
697
1056
|
population_findings, population_counts = evaluate_population(report_text, facts, profile)
|
|
698
|
-
findings = findings + population_findings + scan_relative_risk(report_text)
|
|
1057
|
+
findings = (findings + population_findings + scan_relative_risk(report_text)
|
|
1058
|
+
+ scan_negative_conclusions(report_text))
|
|
1059
|
+
excerpt_dir = args.excerpts or os.path.join(os.path.dirname(os.path.abspath(args.facts)), "evidence_excerpts")
|
|
1060
|
+
quote_findings, quote_counts = evaluate_quotes(report_text, facts, excerpt_dir)
|
|
1061
|
+
findings += quote_findings
|
|
699
1062
|
if args.json:
|
|
700
1063
|
print(
|
|
701
1064
|
json.dumps(
|
|
702
1065
|
{
|
|
703
1066
|
"ok": not findings,
|
|
704
|
-
"counts": dict(counts, **population_counts),
|
|
1067
|
+
"counts": dict(counts, **population_counts, **quote_counts),
|
|
705
1068
|
"populationChecked": profile is not None,
|
|
706
1069
|
"findings": [f.__dict__ for f in findings],
|
|
707
1070
|
"exitCode": 1 if findings else 0,
|
|
@@ -712,6 +1075,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
712
1075
|
else:
|
|
713
1076
|
print(render(findings, counts))
|
|
714
1077
|
print(render_population_and_risk(population_counts, profile is not None))
|
|
1078
|
+
print(render_quotes(quote_counts))
|
|
715
1079
|
return 1 if findings else 0
|
|
716
1080
|
|
|
717
1081
|
|