moca-reader 0.6.0__py3-none-any.whl
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.
- moca_reader/__init__.py +84 -0
- moca_reader/_js.py +291 -0
- moca_reader/attestations.py +271 -0
- moca_reader/audience.py +49 -0
- moca_reader/backends/__init__.py +1 -0
- moca_reader/backends/bm25.py +118 -0
- moca_reader/backends/dense.py +146 -0
- moca_reader/backends/hybrid.py +73 -0
- moca_reader/backends/lexical.py +112 -0
- moca_reader/backends/memory_store.py +122 -0
- moca_reader/citation.py +213 -0
- moca_reader/codes.py +110 -0
- moca_reader/conformance.py +249 -0
- moca_reader/content.py +312 -0
- moca_reader/diagnostics.py +45 -0
- moca_reader/digest.py +92 -0
- moca_reader/dsse.py +97 -0
- moca_reader/ed25519.py +83 -0
- moca_reader/embedders.py +224 -0
- moca_reader/evidence_text.py +187 -0
- moca_reader/frontmatter.py +151 -0
- moca_reader/library.py +176 -0
- moca_reader/manifest.py +186 -0
- moca_reader/metadata.py +58 -0
- moca_reader/profiles.py +5 -0
- moca_reader/py.typed +0 -0
- moca_reader/reader.py +468 -0
- moca_reader/relations.py +46 -0
- moca_reader/resolver.py +33 -0
- moca_reader/reviews.py +18 -0
- moca_reader/schemas/ag-ui-citations.schema.json +36 -0
- moca_reader/schemas/citation-record.schema.json +326 -0
- moca_reader/schemas/manifest.schema.json +171 -0
- moca_reader/schemas/node.schema.json +372 -0
- moca_reader/schemas/review-predicate.schema.json +42 -0
- moca_reader/schemas/search-hit.schema.json +62 -0
- moca_reader/schemas/sidecar-index.schema.json +155 -0
- moca_reader/schemas/structure.schema.json +93 -0
- moca_reader/schemas/trust-root.schema.json +130 -0
- moca_reader/schemas.py +44 -0
- moca_reader/search.py +346 -0
- moca_reader/sidecar.py +228 -0
- moca_reader/source.py +285 -0
- moca_reader/state.py +119 -0
- moca_reader/strategies/__init__.py +1 -0
- moca_reader/strategies/diversify.py +28 -0
- moca_reader/strategies/ontology_guided.py +63 -0
- moca_reader/structure.py +385 -0
- moca_reader/structure_ops.py +160 -0
- moca_reader/unicode.py +33 -0
- moca_reader-0.6.0.dist-info/METADATA +209 -0
- moca_reader-0.6.0.dist-info/RECORD +55 -0
- moca_reader-0.6.0.dist-info/WHEEL +4 -0
- moca_reader-0.6.0.dist-info/licenses/LICENSE +201 -0
- moca_reader-0.6.0.dist-info/licenses/NOTICE +15 -0
moca_reader/citation.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Citation records and the default retrieval policy (Reader contract §7 and §8).
|
|
2
|
+
|
|
3
|
+
A citation record is a dict valid against citation-record.schema.json: the same JSON in every Reader.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from ._js import effective_window, get, js_string, timestamp, truthy
|
|
13
|
+
from .audience import audience_of
|
|
14
|
+
from .content import ContentNode, Representation
|
|
15
|
+
from .digest import utf8
|
|
16
|
+
from .reviews import review_status_of
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .reader import PackageResult
|
|
20
|
+
|
|
21
|
+
CitationRecord = dict[str, Any]
|
|
22
|
+
RelationIndex = tuple[dict[str, list[str]], dict[str, list[str]]] # superseded, contested
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def passes_default_policy(record: CitationRecord) -> bool:
|
|
26
|
+
"""In force, not superseded, not deprecated (Reader contract §8)."""
|
|
27
|
+
trust = record["trust"]
|
|
28
|
+
return bool(trust.get("inForce")) and not trust.get("superseded") and trust.get("status") != "deprecated"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pick_representation(node: ContentNode, locale: str | None) -> Representation:
|
|
32
|
+
"""The representation package spec §5 selects for a locale: exact, then a parent, then a child, then the default."""
|
|
33
|
+
if not locale:
|
|
34
|
+
return node.representations[0]
|
|
35
|
+
want = locale.lower()
|
|
36
|
+
reps = node.representations
|
|
37
|
+
# Package spec §5.4: exact; then the longest tag the request starts with (RFC 4647 Lookup); then the lowest
|
|
38
|
+
# tag, in code-point order, that starts with the request.
|
|
39
|
+
tagged = [r for r in reps if r.locale]
|
|
40
|
+
parents = sorted(
|
|
41
|
+
(r for r in tagged if want.startswith(f"{(r.locale or '').lower()}-")), key=lambda r: -len(r.locale or "")
|
|
42
|
+
)
|
|
43
|
+
children = sorted(
|
|
44
|
+
(r for r in tagged if (r.locale or "").lower().startswith(f"{want}-")), key=lambda r: r.locale or ""
|
|
45
|
+
)
|
|
46
|
+
return (
|
|
47
|
+
next((r for r in tagged if (r.locale or "").lower() == want), None)
|
|
48
|
+
or (parents[0] if parents else None)
|
|
49
|
+
or (children[0] if children else None)
|
|
50
|
+
or reps[0]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _declared(value: Any) -> dict[str, str]:
|
|
55
|
+
out = {"by": js_string(value.get("by"), "by" in value) if isinstance(value, dict) else "undefined"}
|
|
56
|
+
at = get(value, "at")
|
|
57
|
+
if truthy(at):
|
|
58
|
+
out["at"] = js_string(at)
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _date_or_none(value: Any) -> Any:
|
|
63
|
+
"""A frontmatter or manifest date that parses, or None."""
|
|
64
|
+
if value is None or value == "":
|
|
65
|
+
return None
|
|
66
|
+
return None if math.isnan(timestamp(value)) else value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _has_actor(entry: Any) -> bool:
|
|
70
|
+
"""An OKF ``generated`` or ``verified`` entry that names its actor."""
|
|
71
|
+
return isinstance(entry, dict) and entry.get("by") is not None and js_string(entry["by"]) != ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def citation_for(
|
|
75
|
+
result: PackageResult,
|
|
76
|
+
node: ContentNode,
|
|
77
|
+
rep: Representation,
|
|
78
|
+
now: datetime | None = None,
|
|
79
|
+
index: RelationIndex | None = None,
|
|
80
|
+
text: str | None = None,
|
|
81
|
+
) -> CitationRecord:
|
|
82
|
+
"""Builds one citation record."""
|
|
83
|
+
manifest = result.manifest
|
|
84
|
+
fm = rep.frontmatter
|
|
85
|
+
moca = fm["moca"] if isinstance(fm.get("moca"), (dict, list)) else {}
|
|
86
|
+
moca = moca if isinstance(moca, dict) else {}
|
|
87
|
+
key = f"{js_string(manifest.get('id'))}@{js_string(manifest.get('version'))}"
|
|
88
|
+
# Package spec §4.4: the node's window is the intersection of the package's and its own. A bound that does not
|
|
89
|
+
# parse, or a window whose end is not after its start, is ignored: it never takes a node out of force.
|
|
90
|
+
valid_from, valid_until, from_at, until_at = effective_window(
|
|
91
|
+
manifest.get("validFrom"), manifest.get("validUntil"), moca.get("valid_from"), moca.get("valid_until")
|
|
92
|
+
)
|
|
93
|
+
t = (now or datetime.now(UTC)).timestamp() * 1000
|
|
94
|
+
superseded_by = list(index[0].get(key, [])) if index else []
|
|
95
|
+
contested_by = [
|
|
96
|
+
*(moca["contested_by"] if isinstance(moca.get("contested_by"), list) else []),
|
|
97
|
+
*(index[1].get(key, []) if index else []),
|
|
98
|
+
]
|
|
99
|
+
declared_sources = fm.get("sources")
|
|
100
|
+
sources: dict[Any, Any] = {}
|
|
101
|
+
for s in declared_sources if isinstance(declared_sources, list) else []:
|
|
102
|
+
sid = get(s, "id")
|
|
103
|
+
if truthy(sid) and isinstance(sid, (str, int, float, bool)):
|
|
104
|
+
sources[sid] = s
|
|
105
|
+
|
|
106
|
+
package: dict[str, Any] = {
|
|
107
|
+
"id": manifest.get("id"),
|
|
108
|
+
"version": manifest.get("version"),
|
|
109
|
+
"digest": result.digest,
|
|
110
|
+
"signed": len(result.signers) > 0,
|
|
111
|
+
}
|
|
112
|
+
if result.signers:
|
|
113
|
+
package["signers"] = list(result.signers)
|
|
114
|
+
|
|
115
|
+
node_part: dict[str, Any] = {
|
|
116
|
+
"id": f"{js_string(manifest.get('id'))}#{node.path}",
|
|
117
|
+
"ref": f"{key}#{node.path}",
|
|
118
|
+
"path": node.path,
|
|
119
|
+
}
|
|
120
|
+
if rep.locale:
|
|
121
|
+
node_part["locale"] = rep.locale
|
|
122
|
+
elif truthy(manifest.get("language")):
|
|
123
|
+
node_part["locale"] = manifest["language"]
|
|
124
|
+
node_part["type"] = fm.get("type")
|
|
125
|
+
if isinstance(fm.get("title"), str):
|
|
126
|
+
node_part["title"] = fm["title"]
|
|
127
|
+
if isinstance(fm.get("description"), str):
|
|
128
|
+
node_part["description"] = fm["description"]
|
|
129
|
+
if isinstance(fm.get("tags"), list):
|
|
130
|
+
node_part["tags"] = [x for x in fm["tags"] if isinstance(x, str)]
|
|
131
|
+
if rep.sha256:
|
|
132
|
+
node_part["digest"] = f"sha256:{rep.sha256}"
|
|
133
|
+
|
|
134
|
+
verified = fm.get("verified")
|
|
135
|
+
verified_list = verified if isinstance(verified, list) else [verified] if truthy(verified) else []
|
|
136
|
+
generated = fm.get("generated")
|
|
137
|
+
trust: dict[str, Any] = {
|
|
138
|
+
"status": fm["status"] if fm.get("status") in ("draft", "stable", "deprecated") else "stable",
|
|
139
|
+
# An entry with no actor (`by`) is dropped rather than reported as "undefined".
|
|
140
|
+
"generated": _declared(generated) if _has_actor(generated) else None,
|
|
141
|
+
"declaredVerified": [_declared(v) for v in verified_list if _has_actor(v)],
|
|
142
|
+
"attestedReviews": [
|
|
143
|
+
{**_review_json(r), "packageVerified": bool(result.signers)}
|
|
144
|
+
for r in result.reviews_by_file.get(rep.file, [])
|
|
145
|
+
],
|
|
146
|
+
"reviewStatus": review_status_of(result.reviews_by_file.get(rep.file), bool(result.signers)),
|
|
147
|
+
}
|
|
148
|
+
stale_after = _date_or_none(fm.get("stale_after"))
|
|
149
|
+
if truthy(stale_after):
|
|
150
|
+
trust["staleAfter"] = js_string(stale_after)
|
|
151
|
+
if truthy(valid_from):
|
|
152
|
+
trust["validFrom"] = js_string(valid_from)
|
|
153
|
+
if truthy(valid_until):
|
|
154
|
+
trust["validUntil"] = js_string(valid_until)
|
|
155
|
+
trust["stale"] = t >= timestamp(stale_after) if truthy(stale_after) else False
|
|
156
|
+
trust["inForce"] = (from_at is None or t >= from_at.timestamp() * 1000) and (
|
|
157
|
+
until_at is None or t < until_at.timestamp() * 1000
|
|
158
|
+
)
|
|
159
|
+
trust["superseded"] = len(superseded_by) > 0
|
|
160
|
+
if superseded_by:
|
|
161
|
+
trust["supersededBy"] = superseded_by
|
|
162
|
+
trust["contested"] = len(contested_by) > 0
|
|
163
|
+
if contested_by:
|
|
164
|
+
trust["contestedBy"] = contested_by
|
|
165
|
+
|
|
166
|
+
evidence: list[dict[str, Any]] = []
|
|
167
|
+
for i, e in enumerate(moca["evidence"] if isinstance(moca.get("evidence"), list) else []):
|
|
168
|
+
if not truthy(e) or not isinstance(e, (dict, list)):
|
|
169
|
+
continue
|
|
170
|
+
source_key = get(e, "source")
|
|
171
|
+
hashable = isinstance(source_key, (str, int, float, bool))
|
|
172
|
+
if hashable and source_key in sources:
|
|
173
|
+
source = sources[source_key]
|
|
174
|
+
else:
|
|
175
|
+
source = {"id": source_key} if isinstance(e, dict) and "source" in e else {}
|
|
176
|
+
entry: dict[str, Any] = {"source": source}
|
|
177
|
+
if truthy(get(e, "selector")):
|
|
178
|
+
entry["selector"] = get(e, "selector")
|
|
179
|
+
if truthy(get(e, "note")):
|
|
180
|
+
entry["note"] = get(e, "note")
|
|
181
|
+
matched = rep.evidence_checks[i].matched if i < len(rep.evidence_checks) else None
|
|
182
|
+
if matched is not None:
|
|
183
|
+
entry["matched"] = matched
|
|
184
|
+
evidence.append(entry)
|
|
185
|
+
|
|
186
|
+
record: CitationRecord = {
|
|
187
|
+
"package": package,
|
|
188
|
+
"node": node_part,
|
|
189
|
+
"span": {"start": rep.body_offset, "end": rep.body_offset + len(utf8(rep.body))},
|
|
190
|
+
"trust": trust,
|
|
191
|
+
"evidence": evidence,
|
|
192
|
+
}
|
|
193
|
+
if rep.concepts:
|
|
194
|
+
record["concepts"] = list(rep.concepts)
|
|
195
|
+
label, invalid = audience_of(node, rep)
|
|
196
|
+
if label is not None:
|
|
197
|
+
record["audience"] = label
|
|
198
|
+
if invalid:
|
|
199
|
+
record["audienceInvalid"] = True
|
|
200
|
+
if result.profiles:
|
|
201
|
+
record["profiles"] = list(result.profiles)
|
|
202
|
+
if text is not None:
|
|
203
|
+
record["text"] = text
|
|
204
|
+
return record
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _review_json(review: Any) -> dict[str, Any]:
|
|
208
|
+
out: dict[str, Any] = {"reviewer": review.reviewer, "reviewedAt": review.reviewed_at, "outcome": review.outcome}
|
|
209
|
+
if review.signer is not None:
|
|
210
|
+
out["signer"] = review.signer
|
|
211
|
+
if review.scope is not None:
|
|
212
|
+
out["scope"] = review.scope
|
|
213
|
+
return out
|
moca_reader/codes.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Diagnostic codes. This table mirrors Reader contract §11, which is normative: codes are the contract,
|
|
2
|
+
message prose is not. A change here without the matching spec change is a bug."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
ERROR = "error"
|
|
7
|
+
WARNING = "warning"
|
|
8
|
+
INFO = "info"
|
|
9
|
+
|
|
10
|
+
CODES: dict[str, tuple[str, str]] = {
|
|
11
|
+
"T001_TARGET_NOT_FOUND": (ERROR, "The target does not exist."),
|
|
12
|
+
"T002_MANIFEST_NOT_FOUND": (ERROR, "The target exists but has no moca.json at its root."),
|
|
13
|
+
"T003_ARCHIVE_REJECTED": (ERROR, "An archive or host source broke a safety rule or limit."),
|
|
14
|
+
"P001_UNSUPPORTED_ENTRY": (ERROR, "The package contains a symbolic link or other non-regular file."),
|
|
15
|
+
"P002_UNREADABLE_ENTRY": (ERROR, "A package entry could not be read."),
|
|
16
|
+
"P003_PATH_COLLISION": (ERROR, "Two package paths are equal after Unicode NFC normalisation or case folding."),
|
|
17
|
+
"M001_MANIFEST_INVALID_JSON": (ERROR, "moca.json is not valid UTF-8 JSON."),
|
|
18
|
+
"M002_MANIFEST_SCHEMA": (ERROR, "moca.json does not validate against the manifest schema."),
|
|
19
|
+
"M003_NO_CONTENT": (ERROR, "The package has neither a content node nor a member."),
|
|
20
|
+
"M004_UNKNOWN_KEY": (
|
|
21
|
+
INFO,
|
|
22
|
+
"A top-level manifest key is neither defined by the spec nor a vendor x-<vendor>-<key> key.",
|
|
23
|
+
),
|
|
24
|
+
"M005_DUPLICATE_MEMBER": (ERROR, "The same member id appears more than once."),
|
|
25
|
+
"M006_VALIDITY_WINDOW_INVALID": (WARNING, "validUntil is not later than validFrom."),
|
|
26
|
+
"M007_UNREGISTERED_URN": (INFO, "The package id uses a URN namespace that is not registered with IANA."),
|
|
27
|
+
"M008_DIGEST_V1_PACKAGE": (
|
|
28
|
+
INFO,
|
|
29
|
+
"The package was written for a mocaVersion before 0.4; digests, pins and attestations made then will not match moca-digest-v2.",
|
|
30
|
+
),
|
|
31
|
+
"M009_MOCA_VERSION_NEWER": (
|
|
32
|
+
WARNING,
|
|
33
|
+
"The package was written for a newer mocaVersion than this Reader implements; it is read on a best-effort basis.",
|
|
34
|
+
),
|
|
35
|
+
"C001_FRONTMATTER_MISSING": (ERROR, "A concept document has no YAML frontmatter (OKF MUST)."),
|
|
36
|
+
"C002_FRONTMATTER_INVALID": (ERROR, "Frontmatter is not parseable YAML or is not a mapping."),
|
|
37
|
+
"C003_TYPE_MISSING": (ERROR, "Frontmatter has no non-empty type (OKF MUST)."),
|
|
38
|
+
"C004_RESERVED_FILE_INVALID": (ERROR, "index.md or log.md breaks the OKF reserved-file rules."),
|
|
39
|
+
"C005_FIELD_INVALID": (WARNING, "A frontmatter field does not match the node schema."),
|
|
40
|
+
"C006_EVIDENCE_SOURCE_UNKNOWN": (WARNING, "moca.evidence names a source id that sources[] does not declare."),
|
|
41
|
+
"C007_LINK_UNRESOLVED": (WARNING, "An internal Markdown link does not resolve."),
|
|
42
|
+
"C008_UNSAFE_PATH": (ERROR, "A package-relative path is absolute or escapes the package root."),
|
|
43
|
+
"C009_LOCALE_ORPHAN": (WARNING, "A locale variant has no default-language file."),
|
|
44
|
+
"C010_VALIDITY_WINDOW_INVALID": (WARNING, "moca.valid_until is not later than moca.valid_from."),
|
|
45
|
+
"C011_EVIDENCE_SELECTOR_UNMATCHED": (
|
|
46
|
+
WARNING,
|
|
47
|
+
"An evidence selector does not match the text source file it cites inside the package.",
|
|
48
|
+
),
|
|
49
|
+
"A001_ATTESTATION_MALFORMED": (ERROR, "An attestation file is not a parseable envelope and statement."),
|
|
50
|
+
"A002_ATTESTATION_INVALID": (ERROR, "An attestation failed verification or does not match the package."),
|
|
51
|
+
"A003_ATTESTATION_UNVERIFIABLE": (
|
|
52
|
+
WARNING,
|
|
53
|
+
"An attestation could not be checked because no trust root was supplied.",
|
|
54
|
+
),
|
|
55
|
+
"A004_ATTESTATION_INDETERMINATE": (WARNING, "Online verification was requested but could not complete."),
|
|
56
|
+
"A006_REVIEW_OUTDATED": (
|
|
57
|
+
WARNING,
|
|
58
|
+
"A review attestation covers a file whose bytes have since changed or been removed.",
|
|
59
|
+
),
|
|
60
|
+
"A007_REVIEW_DATE_FUTURE": (WARNING, "A review is dated later than the host clock, so it does not count."),
|
|
61
|
+
"A008_PACKAGE_DIGEST_MISMATCH": (
|
|
62
|
+
ERROR,
|
|
63
|
+
"A package attestation names this id and version with a different digest: the content changed after it was signed.",
|
|
64
|
+
),
|
|
65
|
+
"A009_SIGNATURE_REQUIRED": (ERROR, "The host requires a valid package attestation and the package has none."),
|
|
66
|
+
"R001_MEMBER_UNRESOLVED": (WARNING, "A member could not be resolved by the host resolver."),
|
|
67
|
+
"R002_MEMBER_DIGEST_MISMATCH": (ERROR, "A resolved member does not match its pinned digest."),
|
|
68
|
+
"R003_MEMBER_CYCLE": (ERROR, "Members form a cycle."),
|
|
69
|
+
"L001_IDENTITY_CONFLICT": (
|
|
70
|
+
ERROR,
|
|
71
|
+
"A library was given a second package with the same id and version but a different digest; the second is refused.",
|
|
72
|
+
),
|
|
73
|
+
"R004_RELATION_UNAUTHORISED": (
|
|
74
|
+
INFO,
|
|
75
|
+
"A supersedes or conflictsWith relation is not authorised by a signer of the package, so it has no effect.",
|
|
76
|
+
),
|
|
77
|
+
"F001_PROFILE_UNRECOGNISED": (INFO, "A declared profile is not recognised by this reader."),
|
|
78
|
+
"O001_ONTOLOGY_UNPARSEABLE": (
|
|
79
|
+
WARNING,
|
|
80
|
+
"A structure or overlay file is over the size or triple limit, or is not parseable Turtle.",
|
|
81
|
+
),
|
|
82
|
+
"O002_CONCEPT_UNDECLARED": (
|
|
83
|
+
WARNING,
|
|
84
|
+
"A concept is used in a relation or node binding but not declared as a skos:Concept.",
|
|
85
|
+
),
|
|
86
|
+
"O003_REMOTE_REFERENCE": (
|
|
87
|
+
WARNING,
|
|
88
|
+
"An ontology file or concept binding relies on something a Reader would have to fetch or resolve.",
|
|
89
|
+
),
|
|
90
|
+
"O005_STRUCTURE_CYCLE": (WARNING, "requires, broader or hasPart forms a cycle."),
|
|
91
|
+
"S001_SIDECAR_INVALID": (ERROR, "index.json does not validate against the sidecar schema."),
|
|
92
|
+
"S002_SIDECAR_TARGET_MISMATCH": (ERROR, "The sidecar is bound to a different package id or version."),
|
|
93
|
+
"S003_SIDECAR_STALE": (WARNING, "The sidecar digest does not match the package; the sidecar is ignored."),
|
|
94
|
+
"S004_SIDECAR_ITEM_INVALID": (ERROR, "A payload item breaks the addressing rules."),
|
|
95
|
+
"S005_SIDECAR_FORMAT_UNKNOWN": (INFO, "The payload format is not recognised; the sidecar is ignored."),
|
|
96
|
+
"S006_MODEL_MISMATCH": (WARNING, "The host's embedder does not match the index's model; dense search is refused."),
|
|
97
|
+
"S007_NO_VECTORS": (
|
|
98
|
+
WARNING,
|
|
99
|
+
"A loaded package has no vectors a dense backend can search; that package is not searched.",
|
|
100
|
+
),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Codes that are no longer emitted. Codes are never reused.
|
|
104
|
+
RETIRED_CODES = ("A005_SKILLS_WITHHELD", "K001_SKILL_INVALID", "O004_STRUCTURE_VIEW_MISMATCH")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def default_severity(code: str) -> str:
|
|
108
|
+
if code not in CODES:
|
|
109
|
+
raise KeyError(f"unknown diagnostic code {code}")
|
|
110
|
+
return CODES[code][0]
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""moca-spec's runner protocol (conformance/README.md): one JSON object ``{root, case}`` on stdin, one JSON result
|
|
2
|
+
on stdout. What this Reader concludes for one case of conformance/cases.json. It ships with the package, so the
|
|
3
|
+
corpus runs against the Reader you installed. From a moca-spec checkout:
|
|
4
|
+
|
|
5
|
+
node scripts/run-conformance.mjs --reader "python -m moca_reader.conformance"
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from . import (
|
|
18
|
+
DenseBackend,
|
|
19
|
+
HybridBackend,
|
|
20
|
+
LexicalBackend,
|
|
21
|
+
Library,
|
|
22
|
+
MemoryStoreBackend,
|
|
23
|
+
Overlay,
|
|
24
|
+
Search,
|
|
25
|
+
SearchHooks,
|
|
26
|
+
StoreScope,
|
|
27
|
+
bind_sidecar,
|
|
28
|
+
directory_resolver,
|
|
29
|
+
ontology_guided,
|
|
30
|
+
read_package,
|
|
31
|
+
)
|
|
32
|
+
from .state import package_state
|
|
33
|
+
|
|
34
|
+
UNLOADED = "sha256:" + "0" * 64
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ConformanceEmbedder:
|
|
38
|
+
"""The identity a case names, and [1, 0, 0, ...] for every text, whatever the role."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, spec: dict[str, Any]) -> None:
|
|
41
|
+
self.name = spec["name"]
|
|
42
|
+
self.version = spec.get("version")
|
|
43
|
+
self.dimensions = spec["dimensions"]
|
|
44
|
+
self.query_prefix = spec.get("queryPrefix")
|
|
45
|
+
self.document_prefix = spec.get("documentPrefix")
|
|
46
|
+
|
|
47
|
+
def embed(self, texts: list[str], role: str) -> list[list[float]]:
|
|
48
|
+
return [[1.0 if i == 0 else 0.0 for i in range(self.dimensions)] for _ in texts]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class EveryHitBackend:
|
|
52
|
+
"""Ignores every filter, declares no features, and returns every representation plus one unknown digest."""
|
|
53
|
+
|
|
54
|
+
features: tuple[str, ...] = ()
|
|
55
|
+
|
|
56
|
+
def __init__(self, library: Library) -> None:
|
|
57
|
+
self.library = library
|
|
58
|
+
|
|
59
|
+
def search(self, query: str, limit: int, filters: dict[str, Any]) -> list[dict[str, Any]]:
|
|
60
|
+
hits: list[dict[str, Any]] = []
|
|
61
|
+
for p in self.library.packages:
|
|
62
|
+
for node in p.result.nodes:
|
|
63
|
+
for rep in node.representations:
|
|
64
|
+
hit = {
|
|
65
|
+
"digest": p.result.digest,
|
|
66
|
+
"node": f"{p.result.manifest['id']}#{node.path}",
|
|
67
|
+
"path": node.path,
|
|
68
|
+
}
|
|
69
|
+
if rep.locale:
|
|
70
|
+
hit["locale"] = rep.locale
|
|
71
|
+
hits.append({**hit, "score": 1})
|
|
72
|
+
hits.append({"digest": UNLOADED, "node": "https://example.com/not-loaded#x.md", "path": "x.md", "score": 2})
|
|
73
|
+
return hits
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def hostile_hooks() -> SearchHooks:
|
|
77
|
+
"""An egress hook that tries to return everything, plus a record from a package that is not loaded."""
|
|
78
|
+
|
|
79
|
+
def egress(candidates: list[dict[str, Any]], ctx: Any) -> list[dict[str, Any]]:
|
|
80
|
+
everything = ctx.library.citations(include_text=True)
|
|
81
|
+
fake = [{**everything[0], "package": {**everything[0]["package"], "digest": UNLOADED}}] if everything else []
|
|
82
|
+
return [*candidates, *everything, *fake]
|
|
83
|
+
|
|
84
|
+
return SearchHooks(egress=egress)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def hostile_ingress_hooks() -> SearchHooks:
|
|
88
|
+
"""An ingress hook that asks for more than the caller did: the gate must follow the caller."""
|
|
89
|
+
return SearchHooks(ingress=lambda query, ctx: {"includeAll": True, "limit": 1000, "locale": "fr"})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def concept_item(x: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
93
|
+
if x is None:
|
|
94
|
+
return None
|
|
95
|
+
out = {"iri": x["iri"]}
|
|
96
|
+
if "label" in x:
|
|
97
|
+
out["label"] = x["label"]
|
|
98
|
+
out["layer"] = x["layer"]
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def actual_for(root: str, c: dict[str, Any]) -> dict[str, Any]:
|
|
103
|
+
o = c.get("options") or {}
|
|
104
|
+
read_options: dict[str, Any] = {
|
|
105
|
+
"trust_root": os.path.join(root, o["trustRoot"]) if o.get("trustRoot") else None,
|
|
106
|
+
"resolve_member": directory_resolver([os.path.join(root, m) for m in o["members"]])
|
|
107
|
+
if o.get("members")
|
|
108
|
+
else None,
|
|
109
|
+
"limits": o.get("limits"),
|
|
110
|
+
# The host clock applies to reading too: keys expire (attestations §6).
|
|
111
|
+
"clock": (lambda: datetime.fromisoformat(o["now"])) if o.get("now") else None,
|
|
112
|
+
"require_signature": o.get("requireSignature") is True,
|
|
113
|
+
"strict": o.get("strict") is True,
|
|
114
|
+
}
|
|
115
|
+
overlays = [Overlay(v["id"], v["layer"], path=os.path.join(root, v["path"])) for v in o.get("overlays") or []]
|
|
116
|
+
|
|
117
|
+
if c.get("kind") == "structure":
|
|
118
|
+
library = Library(overlays=overlays)
|
|
119
|
+
for target in c["packages"]:
|
|
120
|
+
result = read_package(os.path.join(root, target), **read_options)
|
|
121
|
+
if result.valid:
|
|
122
|
+
library.add(result)
|
|
123
|
+
s = library.structure
|
|
124
|
+
a = c.get("args") or {}
|
|
125
|
+
iri = c["iri"]
|
|
126
|
+
ops: dict[str, Callable[[], Any]] = {
|
|
127
|
+
"concept": lambda: s.concept(iri),
|
|
128
|
+
"requires": lambda: [concept_item(x) for x in s.requires(iri, transitive=a.get("transitive", False))],
|
|
129
|
+
"requiredBy": lambda: [concept_item(x) for x in s.required_by(iri)],
|
|
130
|
+
"parts": lambda: [concept_item(x) for x in s.parts(iri)],
|
|
131
|
+
"narrower": lambda: [concept_item(x) for x in s.narrower(iri, transitive=a.get("transitive", False))],
|
|
132
|
+
"broader": lambda: [concept_item(x) for x in s.broader(iri)],
|
|
133
|
+
"related": lambda: [concept_item(x) for x in s.related(iri)],
|
|
134
|
+
"sequence": lambda: None if (seq := s.sequence(iri)) is None else [concept_item(x) for x in seq],
|
|
135
|
+
"nodes": lambda: [r["node"]["ref"] for r in s.nodes(iri, include=a.get("include", "none"))],
|
|
136
|
+
}
|
|
137
|
+
return {"codes": [d.code for d in library.diagnostics.items], "result": ops[c["op"]]()}
|
|
138
|
+
|
|
139
|
+
if c.get("kind") == "search":
|
|
140
|
+
now = datetime.fromisoformat(o["now"])
|
|
141
|
+
library = Library(
|
|
142
|
+
clock=lambda: now, overlays=overlays, allow_unsigned_relations=o.get("allowUnsignedRelations") is True
|
|
143
|
+
)
|
|
144
|
+
codes: list[str] = []
|
|
145
|
+
for target in c["packages"]:
|
|
146
|
+
result = read_package(os.path.join(root, target), **read_options)
|
|
147
|
+
if not result.valid:
|
|
148
|
+
continue
|
|
149
|
+
chunks, index = None, None
|
|
150
|
+
if o.get("sidecar"):
|
|
151
|
+
bound = bind_sidecar(os.path.join(root, o["sidecar"]), result)
|
|
152
|
+
codes.extend(d.code for d in bound.diagnostics)
|
|
153
|
+
if bound.usable:
|
|
154
|
+
chunks, index = bound.chunks, bound.index
|
|
155
|
+
library.add(result, chunks, index)
|
|
156
|
+
hooks = (
|
|
157
|
+
hostile_hooks()
|
|
158
|
+
if o.get("hooks") == "hostile"
|
|
159
|
+
else hostile_ingress_hooks()
|
|
160
|
+
if o.get("hooks") == "hostile-ingress"
|
|
161
|
+
else ontology_guided()
|
|
162
|
+
if o.get("hooks") == "ontology-guided"
|
|
163
|
+
else None
|
|
164
|
+
)
|
|
165
|
+
s = c["search"]
|
|
166
|
+
options = {
|
|
167
|
+
"limit": s.get("limit", 50),
|
|
168
|
+
"include_all": s.get("includeAll", False),
|
|
169
|
+
"locale": s.get("locale"),
|
|
170
|
+
"concepts": s.get("concepts"),
|
|
171
|
+
"scope": s.get("scope"),
|
|
172
|
+
}
|
|
173
|
+
if o.get("backend") == "store":
|
|
174
|
+
# Everything listed is ingested (each package on its own with `ingestSeparately`); only the packages
|
|
175
|
+
# in `allow` are "loaded", and the search gets their package states (contract §9.4).
|
|
176
|
+
store = MemoryStoreBackend(filter_pushdown=False)
|
|
177
|
+
if o.get("ingestSeparately"):
|
|
178
|
+
for p in library.packages:
|
|
179
|
+
single = Library(clock=lambda: now)
|
|
180
|
+
single.add(p.result)
|
|
181
|
+
store.ingest(single)
|
|
182
|
+
else:
|
|
183
|
+
store.ingest(library)
|
|
184
|
+
allow = {os.path.join(root, t) for t in o.get("allow") or []}
|
|
185
|
+
allowed = [
|
|
186
|
+
package_state(p.result)
|
|
187
|
+
for p in library.packages
|
|
188
|
+
if p.result.source and p.result.source.location in allow
|
|
189
|
+
]
|
|
190
|
+
records = Search(
|
|
191
|
+
StoreScope(allowed, lambda: now, o.get("allowUnsignedRelations") is True),
|
|
192
|
+
store,
|
|
193
|
+
audiences=o.get("audiences"),
|
|
194
|
+
allow_unlabelled=o.get("allowUnlabelled") is True,
|
|
195
|
+
).search(s["query"], **options)
|
|
196
|
+
return {"codes": codes, "records": records}
|
|
197
|
+
|
|
198
|
+
def dense() -> DenseBackend:
|
|
199
|
+
return DenseBackend(library, ConformanceEmbedder(o["embedder"]))
|
|
200
|
+
|
|
201
|
+
backend: Any
|
|
202
|
+
if o.get("backend") == "all":
|
|
203
|
+
backend = EveryHitBackend(library)
|
|
204
|
+
elif o.get("backend") == "dense":
|
|
205
|
+
backend = dense()
|
|
206
|
+
elif o.get("backend") == "hybrid":
|
|
207
|
+
h = o.get("hybrid") or {}
|
|
208
|
+
backend = HybridBackend(LexicalBackend(library), dense(), k=h.get("k", 60), weights=h.get("weights"))
|
|
209
|
+
else:
|
|
210
|
+
backend = LexicalBackend(library)
|
|
211
|
+
codes.extend(d.code for d in getattr(backend, "diagnostics", None) or [])
|
|
212
|
+
# Library diagnostics (L): for example two digests for one id@version.
|
|
213
|
+
codes.extend(d.code for d in library.diagnostics.items if d.code.startswith("L"))
|
|
214
|
+
records = Search(
|
|
215
|
+
library,
|
|
216
|
+
backend,
|
|
217
|
+
audiences=o.get("audiences"),
|
|
218
|
+
hooks=hooks,
|
|
219
|
+
allow_unlabelled=o.get("allowUnlabelled") is True,
|
|
220
|
+
).search(s["query"], **options)
|
|
221
|
+
return {"codes": codes, "records": records}
|
|
222
|
+
|
|
223
|
+
result = read_package(os.path.join(root, c["target"]), **read_options)
|
|
224
|
+
codes = [d.code for d in result.diagnostics]
|
|
225
|
+
sidecar_usable = None
|
|
226
|
+
if o.get("sidecar"):
|
|
227
|
+
bound = bind_sidecar(os.path.join(root, o["sidecar"]), result)
|
|
228
|
+
codes.extend(d.code for d in bound.diagnostics)
|
|
229
|
+
sidecar_usable = bound.usable
|
|
230
|
+
evidence_matched: dict[str, list[Any]] = {}
|
|
231
|
+
if result.valid:
|
|
232
|
+
for r in Library().add(result).citations():
|
|
233
|
+
if r["evidence"]:
|
|
234
|
+
evidence_matched[r["node"]["path"]] = [e.get("matched") for e in r["evidence"]]
|
|
235
|
+
out: dict[str, Any] = {
|
|
236
|
+
"valid": result.valid,
|
|
237
|
+
"codes": codes,
|
|
238
|
+
"capabilities": result.capabilities,
|
|
239
|
+
"digest": result.digest,
|
|
240
|
+
"evidenceMatched": evidence_matched,
|
|
241
|
+
}
|
|
242
|
+
if sidecar_usable is not None:
|
|
243
|
+
out["sidecarUsable"] = sidecar_usable
|
|
244
|
+
return out
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
request = json.loads(sys.stdin.read())
|
|
249
|
+
sys.stdout.write(json.dumps(actual_for(request["root"], request["case"]), ensure_ascii=False))
|