@dzhechkov/p-replicator 1.10.4 → 1.13.1
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 +312 -76
- package/CHANGELOG.md +232 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +3 -3
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/04_api_reference.md +11 -5
- package/README/eng/05_architecture.md +1 -1
- package/README/eng/README.md +2 -1
- package/README/ru/01_quickstart.md +3 -3
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/04_api_reference.md +11 -5
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +2 -1
- package/README/ru/html/index.html +9 -9
- package/README.md +278 -39
- package/package.json +5 -4
- package/sbom.json +665 -75
- package/scripts/check-pipeline-gaps.sh +413 -0
- package/src/commands/doctor.js +94 -4
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +15 -0
- package/src/utils.js +35 -11
- package/templates/.claude/agents/harvest-coordinator.md +10 -1
- package/templates/.claude/agents/product-discoverer.md +38 -0
- package/templates/.claude/agents/replicate-coordinator.md +11 -1
- package/templates/.claude/commands/feature.md +81 -9
- package/templates/.claude/commands/go.md +9 -0
- package/templates/.claude/commands/harvest.md +39 -3
- package/templates/.claude/commands/myinsights.md +21 -26
- package/templates/.claude/commands/replicate.md +171 -37
- package/templates/.claude/commands/start.md +29 -0
- 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 +434 -24
- 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/session-insights.cjs +158 -25
- package/templates/.claude/hooks/statusline.cjs +2 -2
- package/templates/.claude/hooks/write-insight.cjs +253 -0
- package/templates/.claude/rules/cost-of-detection-ladder.md +96 -0
- package/templates/.claude/rules/docker-ports.md +41 -19
- package/templates/.claude/rules/embeddable-widget.md +73 -0
- package/templates/.claude/rules/feature-lifecycle.md +13 -3
- package/templates/.claude/rules/honest-configuration.md +54 -0
- package/templates/.claude/rules/incoming-webhooks.md +99 -0
- package/templates/.claude/rules/insights-capture.md +10 -5
- 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 +123 -52
- package/templates/.claude/rules/skill-interface-protocol.md +1 -0
- package/templates/.claude/rules/swarm-file-evidence.md +46 -0
- package/templates/.claude/settings.json +13 -1
- 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/knowledge-extractor/modules/01-agent-review.md +16 -5
- 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/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +234 -716
- package/tests/e2e/lifecycle.test.js +55 -9
- package/tests/e2e/packed-insights-writer.test.js +308 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/01_specification.md +29 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/02_pseudocode.md +57 -0
- package/tests/snapshot/baseline.json +72 -46
- package/tests/snapshot/templates.test.js +47 -0
- package/tests/unit/absence-is-not-emptiness.test.js +15 -1
- 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-pipeline-gaps.test.js +94 -0
- package/tests/unit/check-ports.test.js +773 -2
- 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 +43 -6
- package/tests/unit/detection-ladder-contract.test.js +302 -0
- package/tests/unit/detection-ladder-registry.test.js +52 -0
- package/tests/unit/doctor-insight-flow.test.js +315 -0
- package/tests/unit/external-dependency-check.test.js +19 -19
- 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 +574 -0
- package/tests/unit/hooks-project-anchored.test.js +67 -3
- package/tests/unit/insights-docs-tell-the-truth.test.js +52 -31
- package/tests/unit/insights-dz-delegation.test.js +197 -0
- package/tests/unit/insights-writer.test.js +285 -0
- 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/shipped-suite-context.test.js +3 -1
- package/tests/unit/traceability-machine-ids.test.js +413 -0
- package/tests/unit/traceability-negative-fixture.test.js +322 -0
- package/tests/unit/utils.test.js +40 -2
|
@@ -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
|
|