canli-validation-mcp 0.4.0 → 0.6.0

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.
@@ -0,0 +1,50 @@
1
+ // js/validate/track-record.js
2
+ // The pure computation behind POST /api/v1/validate/track-record, shared by the API route and the
3
+ // MCP package's local mode (mcp/src/local mirrors it byte for byte), so the two cannot disagree.
4
+ import { minimumTrackRecordLength, probabilisticSharpe } from "../dsr-core.js";
5
+
6
+ // Minimum track record length for an observed Sharpe to clear a benchmark Sharpe at a confidence
7
+ // level, and, when the record's length is sent, the probabilistic Sharpe against that benchmark:
8
+ // Bailey and López de Prado, "The Sharpe Ratio Efficient Frontier" (2012), checked against the
9
+ // paper's worked examples in js/dsr-paper-vectors.test.js.
10
+
11
+ const REQUIRED = ["observed_sharpe_annualized", "periods_per_year", "skew", "non_excess_kurtosis"];
12
+
13
+ export function compute(body) {
14
+ const missing = REQUIRED.filter((k) => body[k] === undefined);
15
+ if (missing.length) throw new RangeError(`Missing required fields: ${missing.join(", ")}`);
16
+ const inputs = {
17
+ observed_sharpe_annualized: Number(body.observed_sharpe_annualized),
18
+ benchmark_sharpe_annualized: body.benchmark_sharpe_annualized === undefined ? 0 : Number(body.benchmark_sharpe_annualized),
19
+ periods_per_year: Number(body.periods_per_year),
20
+ skew: Number(body.skew),
21
+ non_excess_kurtosis: Number(body.non_excess_kurtosis),
22
+ confidence: body.confidence === undefined ? 0.95 : Number(body.confidence),
23
+ };
24
+ if (!(inputs.periods_per_year > 0 && inputs.periods_per_year <= 10000)) throw new RangeError("periods_per_year must be between 0 and 10000");
25
+ if (!(inputs.non_excess_kurtosis >= 1 && inputs.non_excess_kurtosis <= 100)) throw new RangeError("non_excess_kurtosis must be between 1 and 100 (a Normal distribution is 3)");
26
+ const minimum = minimumTrackRecordLength(inputs);
27
+ const result = { minimum_observations: Math.ceil(minimum.observations), minimum_years: minimum.years, confidence: minimum.confidence };
28
+ if (body.observations !== undefined) {
29
+ const observations = Number(body.observations);
30
+ if (!Number.isInteger(observations) || observations < 2 || observations > 1000000) throw new RangeError("observations must be an integer from 2 to 1000000");
31
+ const psr = probabilisticSharpe({ ...inputs, observations });
32
+ result.record = {
33
+ observations,
34
+ years: observations / inputs.periods_per_year,
35
+ psr_against_benchmark: psr.probabilistic_sharpe_ratio,
36
+ long_enough: observations >= result.minimum_observations,
37
+ };
38
+ }
39
+ return { derived_inputs: inputs, result, plain_reading: reading(inputs, result) };
40
+ }
41
+
42
+ function reading(inputs, r) {
43
+ const pct = (x) => `${(x * 100).toFixed(1)} percent`;
44
+ // A Sharpe derived from a return series carries every digit of the float; three decimals read.
45
+ const sharpe = (x) => String(Number(Number(x).toFixed(3)));
46
+ const need = `To be ${pct(r.confidence)} confident that a Sharpe of ${sharpe(inputs.observed_sharpe_annualized)} is above ${sharpe(inputs.benchmark_sharpe_annualized)}, the track record needs at least ${r.minimum_observations} observations, about ${r.minimum_years.toFixed(2)} years.`;
47
+ if (!r.record) return `${need} This counts sample uncertainty and the shape of the returns only; it is not a forecast.`;
48
+ const verdict = r.record.long_enough ? "is long enough" : "is not long enough yet";
49
+ return `${need} The record sent, ${r.record.observations} observations, ${verdict}: the probability its Sharpe is above the benchmark is ${pct(r.record.psr_against_benchmark)}. Neither number is a forecast.`;
50
+ }
@@ -0,0 +1,176 @@
1
+ // =============================================================================
2
+ // canonical-json.mjs
3
+ // -----------------------------------------------------------------------------
4
+ // ONE canonicalisation for every published `content_hash` on this site.
5
+ //
6
+ // WHY THIS FILE EXISTS. `reproduce.py` -- the kit a reader runs to check that the
7
+ // published record reproduces -- recomputes every artifact's hash with Python's
8
+ //
9
+ // json.dumps(payload_without_content_hash, sort_keys=True, separators=(",", ":"))
10
+ //
11
+ // Seven generators carried their own copy-pasted `canonical()` that happened to
12
+ // agree with it. `build-paper-evidence-vectors.mjs` carried NO copy and hashed
13
+ // with a bare `JSON.stringify`, which preserves insertion order instead of
14
+ // sorting. Its artifact therefore never reproduced, L1 read 158/1, and because
15
+ // the nightly ceremony gates on L1 the whole publish halted -- OTS anchoring,
16
+ // capacity and founder commitments included. One generator missing a helper the
17
+ // other seven had duplicated took the ceremony down.
18
+ //
19
+ // That is the same shape as `normalizeEditableCopy`, which was pasted into three
20
+ // generators while the verifier had no copy at all. A helper that must agree
21
+ // across N call sites cannot live as N copies. It lives here.
22
+ //
23
+ // WHAT "CANONICAL" MEANS HERE, exactly, because two of these are traps:
24
+ //
25
+ // * KEY ORDER -- recursively sorted, matching `sort_keys=True`. This is the one
26
+ // the missing copy got wrong.
27
+ // * SEPARATORS -- "," and ":" with no spaces, matching `separators=(",", ":")`.
28
+ // * NON-ASCII -- escaped to \uXXXX, matching Python's default `ensure_ascii=True`.
29
+ // JS `JSON.stringify` emits raw UTF-8 instead. For a pure-ASCII document the
30
+ // two settings emit IDENTICAL bytes, so this divergence stays invisible until
31
+ // the first accented character -- which is exactly how "Lopez de Prado" in one
32
+ // contract halted the nightly for three nights.
33
+ // * NUMBERS -- Python and JS disagree on when to switch to exponent form
34
+ // (Python at >=1e16 and <1e-4, JS at >=1e21 and <1e-6) and on the exponent's
35
+ // own spelling (Python "1e-05", JS "1e-5"). `pythonNumber` reproduces
36
+ // Python's rule. A number this cannot render faithfully THROWS rather than
37
+ // emitting a hash that will not reproduce: a wrong hash fails at 22:12 in the
38
+ // nightly, an exception fails in the generator that caused it.
39
+ //
40
+ // The differential test in `canonical-json.test.mjs` is what makes any of this
41
+ // believable: it hands the same values to Python and compares the bytes.
42
+ // =============================================================================
43
+
44
+ /**
45
+ * Render a number the way Python re-emits it after parsing the literal JS wrote.
46
+ *
47
+ * THE CHAIN THIS HAS TO PREDICT, because getting it wrong is invisible until the
48
+ * nightly ceremony halts: a JS generator builds an object, writes it with
49
+ * `JSON.stringify(payload, null, 2)`, and `reproduce.py` later PARSES that file and
50
+ * re-emits it with `json.dumps`. So the target is not "what Python's repr does to
51
+ * this JS number" -- it is "what Python does to the LITERAL JSON.stringify writes".
52
+ *
53
+ * That distinction is the whole rule. `1e16` reaches the file as
54
+ * `10000000000000000`, which Python parses as an INT and echoes digit for digit;
55
+ * predicting Python's float repr (`1e+16`) there would be wrong. `1e-7` reaches the
56
+ * file as `1e-7`, which Python parses as a float and re-spells `1e-07`.
57
+ *
58
+ * KNOWN LIMIT, stated because it bounds where this module may be used: a float
59
+ * Python wrote as `1.0` parses to the JS number `1`, and no JS function can tell it
60
+ * from an integer. So this canonicalisation is faithful for payloads BUILT in JS and
61
+ * written by `JSON.stringify` -- the artifact and its hash then agree -- and is NOT a
62
+ * general re-canonicaliser for files a Python exporter produced.
63
+ */
64
+ export function pythonNumber(value) {
65
+ if (!Number.isFinite(value)) {
66
+ // Python writes Infinity/NaN; JSON has no such literals and JS writes null.
67
+ // Neither is reproducible, so refuse rather than publish an unreproducible hash.
68
+ throw new Error(`canonical-json: ${value} has no reproducible JSON form`);
69
+ }
70
+ // Exactly the bytes JSON.stringify will put in the artifact file.
71
+ const literal = JSON.stringify(value);
72
+ if (!/[.eE]/.test(literal)) {
73
+ // No decimal point and no exponent: Python parses an int and echoes the digits.
74
+ return literal;
75
+ }
76
+ // Python parses a float, so the output is `repr(float)`: shortest round-trip
77
+ // digits -- identical in both languages -- laid out by Python's rule, which is
78
+ // fixed notation for a decimal exponent in [-4, 16) and exponent form otherwise.
79
+ const [mantissa, exponent] = value.toExponential().split("e");
80
+ const e = Number(exponent);
81
+ const negative = mantissa.startsWith("-");
82
+ const digits = mantissa.replace("-", "").replace(".", "");
83
+ if (e >= -4 && e < 16) {
84
+ let whole;
85
+ let fraction;
86
+ if (e >= 0) {
87
+ whole = digits.slice(0, e + 1).padEnd(e + 1, "0");
88
+ fraction = digits.slice(e + 1);
89
+ } else {
90
+ whole = "0";
91
+ fraction = "0".repeat(-e - 1) + digits;
92
+ }
93
+ // Python always keeps at least one fractional digit on a float.
94
+ return `${negative ? "-" : ""}${whole}.${fraction === "" ? "0" : fraction}`;
95
+ }
96
+ const sign = e < 0 ? "-" : "+";
97
+ const magnitude = String(Math.abs(e)).padStart(2, "0");
98
+ return `${mantissa}e${sign}${magnitude}`;
99
+ }
100
+
101
+ // Every code unit above ASCII. Written as an escape range rather than as literal
102
+ // characters so the source file itself stays ASCII -- a file about non-ASCII
103
+ // escaping is the worst possible place to smuggle in a stray accented byte.
104
+ const NON_ASCII = /[\u0080-\uffff]/g;
105
+
106
+ /** Escape a string the way Python's `json.dumps(ensure_ascii=True)` does. */
107
+ export function pythonString(value) {
108
+ // JSON.stringify already handles quotes, backslashes and control characters
109
+ // identically to Python. Only non-ASCII is spelled differently, so escape that
110
+ // and leave the rest of JS's output alone. Astral characters are already
111
+ // surrogate pairs in JS, and Python escapes them as a surrogate pair too.
112
+ return JSON.stringify(value).replace(
113
+ NON_ASCII,
114
+ (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
115
+ );
116
+ }
117
+
118
+ /** The canonical byte string an artifact's `content_hash` is taken over. */
119
+ export function canonicalJson(value) {
120
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
121
+ if (value && typeof value === "object") {
122
+ return `{${Object.keys(value)
123
+ .sort()
124
+ .map((k) => `${pythonString(k)}:${canonicalJson(value[k])}`)
125
+ .join(",")}}`;
126
+ }
127
+ if (typeof value === "number") return pythonNumber(value);
128
+ if (typeof value === "string") return pythonString(value);
129
+ if (value === null || typeof value === "boolean") return JSON.stringify(value);
130
+ // undefined, function, symbol, bigint: JSON.stringify would silently drop or
131
+ // throw. Neither belongs in a published artifact, so say so by name.
132
+ throw new Error(`canonical-json: ${typeof value} has no canonical JSON form`);
133
+ }
134
+
135
+ /** `sha256:...` over the payload with its own `content_hash` field removed. */
136
+ export function contentHash(payload, createHash) {
137
+ const body = Object.fromEntries(
138
+ Object.entries(payload).filter(([k]) => k !== "content_hash"),
139
+ );
140
+ return `sha256:${createHash("sha256").update(canonicalJson(body)).digest("hex")}`;
141
+ }
142
+
143
+ /**
144
+ * Write a glass-box artifact, stamp its `content_hash`, and PROVE the file on disk
145
+ * reproduces that hash before returning.
146
+ *
147
+ * WHY THE READ-BACK. Computing a hash and writing a file are two steps, and every
148
+ * incident in this pipeline's history lives in the gap between them: a generator
149
+ * that hashed insertion-ordered bytes while the reader sorted them, an exporter that
150
+ * emitted raw UTF-8 while the reader escaped it. In both cases the generator
151
+ * succeeded, the artifact looked fine, and the failure surfaced days later as a
152
+ * halted nightly ceremony pointing at the reader rather than the writer.
153
+ *
154
+ * So this re-reads what it just wrote, re-canonicalises it the way `reproduce.py`
155
+ * will, and throws if the two disagree. The generator that caused the problem is the
156
+ * thing that fails, at the moment it causes it. A list of "artifacts to check" would
157
+ * have to be maintained and would silently omit the next new one; a self-check
158
+ * cannot be omitted by anyone who uses the helper.
159
+ */
160
+ export function writeArtifact(path, payload, { createHash, readFileSync, writeFileSync }) {
161
+ const stamped = { ...payload };
162
+ delete stamped.content_hash;
163
+ stamped.content_hash = contentHash(stamped, createHash);
164
+ writeFileSync(path, `${JSON.stringify(stamped, null, 2)}\n`);
165
+
166
+ const reread = JSON.parse(readFileSync(path, "utf8"));
167
+ const observed = contentHash(reread, createHash);
168
+ if (observed !== reread.content_hash) {
169
+ throw new Error(
170
+ `canonical-json: ${path} does not reproduce its own content_hash ` +
171
+ `(wrote ${reread.content_hash}, re-read as ${observed}). ` +
172
+ "The published record would not reproduce and the nightly ceremony would halt.",
173
+ );
174
+ }
175
+ return stamped.content_hash;
176
+ }
@@ -0,0 +1,429 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://canlicapital.com/standards/paper-evidence/v0/schema.json",
4
+ "title": "canli.paper-evidence.v0",
5
+ "description": "A record of paper-traded or simulated strategy performance that states what it does not know. The schema exists because the failure mode of a performance claim is not a wrong number, it is a number whose basis, cost assumptions, search history and missing evidence are unstated. Every required field below is one of those.",
6
+ "type": "object",
7
+ "required": [
8
+ "schema",
9
+ "generated_at",
10
+ "capital",
11
+ "identity",
12
+ "period",
13
+ "returns",
14
+ "costs",
15
+ "selection",
16
+ "risk",
17
+ "corrections",
18
+ "provenance",
19
+ "claim_maturity"
20
+ ],
21
+ "additionalProperties": false,
22
+ "properties": {
23
+ "schema": {
24
+ "const": "canli.paper-evidence.v0"
25
+ },
26
+ "generated_at": {
27
+ "type": "string",
28
+ "format": "date-time"
29
+ },
30
+ "capital": {
31
+ "type": "object",
32
+ "description": "What kind of money produced this record. The single most misreadable fact about any track record, so it is required and enumerated rather than described in prose.",
33
+ "required": [
34
+ "kind",
35
+ "execution"
36
+ ],
37
+ "additionalProperties": false,
38
+ "properties": {
39
+ "kind": {
40
+ "enum": [
41
+ "PAPER",
42
+ "FUNDED",
43
+ "SIMULATED",
44
+ "MIXED"
45
+ ],
46
+ "description": "PAPER: orders placed at a broker against a paper account. SIMULATED: no broker involved. FUNDED: real capital at risk. MIXED: any combination, which must be decomposed in `notes`."
47
+ },
48
+ "execution": {
49
+ "enum": [
50
+ "BROKER_PAPER_FILLS",
51
+ "LOCAL_SIMULATED_FILLS",
52
+ "BROKER_LIVE_FILLS",
53
+ "MIXED"
54
+ ],
55
+ "description": "Where the fills came from. A broker paper fill and a locally modelled fill are different evidence and may not be merged silently."
56
+ },
57
+ "venue": {
58
+ "type": "string"
59
+ },
60
+ "notes": {
61
+ "type": "string"
62
+ }
63
+ }
64
+ },
65
+ "identity": {
66
+ "type": "object",
67
+ "description": "What this record is about, and whether that thing was frozen before the returns were seen.",
68
+ "required": [
69
+ "name",
70
+ "kind",
71
+ "preregistered"
72
+ ],
73
+ "additionalProperties": false,
74
+ "properties": {
75
+ "name": {
76
+ "type": "string",
77
+ "minLength": 1
78
+ },
79
+ "kind": {
80
+ "enum": [
81
+ "SLEEVE",
82
+ "BOOK",
83
+ "CANDIDATE"
84
+ ]
85
+ },
86
+ "preregistered": {
87
+ "type": "boolean",
88
+ "description": "Whether the strategy identity was frozen before its out-of-sample returns were opened. False is a legitimate answer and is far more useful than an absent field."
89
+ },
90
+ "preregistration_ref": {
91
+ "type": "string"
92
+ },
93
+ "constituents": {
94
+ "type": "array",
95
+ "items": {
96
+ "type": "string"
97
+ }
98
+ }
99
+ }
100
+ },
101
+ "period": {
102
+ "type": "object",
103
+ "required": [
104
+ "first_observation",
105
+ "last_observation",
106
+ "observation_count",
107
+ "frequency"
108
+ ],
109
+ "additionalProperties": false,
110
+ "properties": {
111
+ "first_observation": {
112
+ "type": "string",
113
+ "format": "date"
114
+ },
115
+ "last_observation": {
116
+ "type": "string",
117
+ "format": "date"
118
+ },
119
+ "observation_count": {
120
+ "type": "integer",
121
+ "minimum": 0
122
+ },
123
+ "frequency": {
124
+ "enum": [
125
+ "DAILY",
126
+ "WEEKLY",
127
+ "MONTHLY",
128
+ "HOURLY",
129
+ "IRREGULAR"
130
+ ]
131
+ },
132
+ "calendar": {
133
+ "type": "string"
134
+ }
135
+ }
136
+ },
137
+ "returns": {
138
+ "type": "object",
139
+ "description": "Return figures, always net-of-what stated. A return series whose cost treatment is unstated is not a measurement.",
140
+ "required": [
141
+ "basis",
142
+ "cumulative",
143
+ "series_available"
144
+ ],
145
+ "additionalProperties": false,
146
+ "properties": {
147
+ "basis": {
148
+ "enum": [
149
+ "GROSS",
150
+ "NET_OF_MODELLED_COSTS",
151
+ "NET_OF_REALISED_COSTS"
152
+ ],
153
+ "description": "NET_OF_REALISED_COSTS requires broker-observed costs, not a model."
154
+ },
155
+ "cumulative": {
156
+ "type": "number"
157
+ },
158
+ "annualised": {
159
+ "type": [
160
+ "number",
161
+ "null"
162
+ ]
163
+ },
164
+ "sharpe_annualised": {
165
+ "type": [
166
+ "number",
167
+ "null"
168
+ ]
169
+ },
170
+ "sharpe_reportable": {
171
+ "type": "boolean",
172
+ "description": "Whether the sample supports reporting a Sharpe at all. A short record must set this false and leave sharpe_annualised null rather than publishing a figure the sample cannot carry."
173
+ },
174
+ "series_available": {
175
+ "type": "boolean"
176
+ },
177
+ "series_url": {
178
+ "type": "string"
179
+ }
180
+ }
181
+ },
182
+ "costs": {
183
+ "type": "object",
184
+ "description": "What was charged and what was assumed. Absent cost modelling is itself a disclosure.",
185
+ "required": [
186
+ "modelled"
187
+ ],
188
+ "additionalProperties": false,
189
+ "properties": {
190
+ "modelled": {
191
+ "type": "boolean"
192
+ },
193
+ "components": {
194
+ "type": "array",
195
+ "items": {
196
+ "enum": [
197
+ "SPREAD",
198
+ "FEES",
199
+ "IMPACT",
200
+ "LATENCY",
201
+ "FINANCING",
202
+ "BORROW",
203
+ "FUNDING"
204
+ ]
205
+ }
206
+ },
207
+ "turnover_annualised": {
208
+ "type": [
209
+ "number",
210
+ "null"
211
+ ]
212
+ },
213
+ "notes": {
214
+ "type": "string"
215
+ },
216
+ "not_modelled": {
217
+ "type": "array",
218
+ "items": {
219
+ "type": "string"
220
+ },
221
+ "description": "Cost categories the publisher does NOT charge. Optional in v0 and a candidate to become required in the next version, because a cost model described only by its inclusions is structurally misleading: a reader cannot distinguish a cost judged immaterial from one nobody considered, since both appear as silence. Unmodelled costs almost always flatter, so this list has a known direction."
222
+ },
223
+ "coverage_url": {
224
+ "type": "string"
225
+ }
226
+ }
227
+ },
228
+ "selection": {
229
+ "type": "object",
230
+ "description": "How much searching produced this result. Without it a Sharpe ratio is uninterpretable, which is the single most common defect in published performance.",
231
+ "required": [
232
+ "trials_counted",
233
+ "trial_count",
234
+ "deflation_applied"
235
+ ],
236
+ "additionalProperties": false,
237
+ "properties": {
238
+ "trials_counted": {
239
+ "type": "boolean",
240
+ "description": "Whether the publisher actually maintains a trial ledger. False is honest; an unstated trial count is not."
241
+ },
242
+ "trial_count": {
243
+ "type": [
244
+ "integer",
245
+ "null"
246
+ ],
247
+ "minimum": 0
248
+ },
249
+ "trial_unit": {
250
+ "type": "string"
251
+ },
252
+ "deflation_applied": {
253
+ "type": "boolean"
254
+ },
255
+ "deflated_sharpe_ratio": {
256
+ "type": [
257
+ "number",
258
+ "null"
259
+ ],
260
+ "minimum": 0,
261
+ "maximum": 1
262
+ },
263
+ "deflation_method": {
264
+ "type": "string"
265
+ }
266
+ }
267
+ },
268
+ "risk": {
269
+ "type": "object",
270
+ "required": [
271
+ "max_drawdown_realised",
272
+ "drawdown_basis"
273
+ ],
274
+ "additionalProperties": false,
275
+ "properties": {
276
+ "max_drawdown_realised": {
277
+ "type": [
278
+ "number",
279
+ "null"
280
+ ]
281
+ },
282
+ "drawdown_basis": {
283
+ "enum": [
284
+ "OBSERVED",
285
+ "MODEL_ESTIMATED",
286
+ "NOT_ESTABLISHED"
287
+ ]
288
+ },
289
+ "max_drawdown_model_expected": {
290
+ "type": [
291
+ "number",
292
+ "null"
293
+ ]
294
+ },
295
+ "exposure_notes": {
296
+ "type": "string"
297
+ }
298
+ }
299
+ },
300
+ "corrections": {
301
+ "type": "object",
302
+ "description": "Whether anything in this record has been withdrawn. A record with no correction history and no statement that it has none is silent, not clean.",
303
+ "required": [
304
+ "count",
305
+ "withdrawn_figures"
306
+ ],
307
+ "additionalProperties": false,
308
+ "properties": {
309
+ "count": {
310
+ "type": "integer",
311
+ "minimum": 0
312
+ },
313
+ "withdrawn_figures": {
314
+ "type": "array",
315
+ "items": {
316
+ "type": "object",
317
+ "required": [
318
+ "figure",
319
+ "withdrawn_on",
320
+ "reason"
321
+ ],
322
+ "additionalProperties": false,
323
+ "properties": {
324
+ "figure": {
325
+ "type": "string"
326
+ },
327
+ "withdrawn_on": {
328
+ "type": "string",
329
+ "format": "date"
330
+ },
331
+ "reason": {
332
+ "type": "string"
333
+ },
334
+ "superseded_by": {
335
+ "type": "string"
336
+ }
337
+ }
338
+ }
339
+ },
340
+ "log_url": {
341
+ "type": "string"
342
+ }
343
+ }
344
+ },
345
+ "provenance": {
346
+ "type": "object",
347
+ "description": "What a reader can check without trusting the publisher.",
348
+ "required": [
349
+ "source_bindings",
350
+ "independently_verifiable"
351
+ ],
352
+ "additionalProperties": false,
353
+ "properties": {
354
+ "source_bindings": {
355
+ "type": "array",
356
+ "minItems": 1,
357
+ "items": {
358
+ "type": "object",
359
+ "required": [
360
+ "path",
361
+ "sha256"
362
+ ],
363
+ "additionalProperties": false,
364
+ "properties": {
365
+ "path": {
366
+ "type": "string"
367
+ },
368
+ "sha256": {
369
+ "type": "string",
370
+ "pattern": "^(sha256:)?[0-9a-f]{64}$"
371
+ },
372
+ "url": {
373
+ "type": "string"
374
+ }
375
+ }
376
+ }
377
+ },
378
+ "independently_verifiable": {
379
+ "type": "boolean"
380
+ },
381
+ "verification_url": {
382
+ "type": "string"
383
+ },
384
+ "signed": {
385
+ "type": "boolean"
386
+ },
387
+ "signature_scheme": {
388
+ "type": "string"
389
+ }
390
+ }
391
+ },
392
+ "claim_maturity": {
393
+ "type": "object",
394
+ "description": "What this record does NOT establish. Required, because the omission of this section is what makes an otherwise accurate record misleading.",
395
+ "required": [
396
+ "establishes",
397
+ "does_not_establish"
398
+ ],
399
+ "additionalProperties": false,
400
+ "properties": {
401
+ "establishes": {
402
+ "type": "array",
403
+ "items": {
404
+ "type": "string"
405
+ }
406
+ },
407
+ "does_not_establish": {
408
+ "type": "array",
409
+ "minItems": 1,
410
+ "items": {
411
+ "type": "string"
412
+ },
413
+ "description": "At least one entry. Every record fails to establish something, and a publisher who cannot name one has not looked."
414
+ },
415
+ "external_review_count": {
416
+ "type": "integer",
417
+ "minimum": 0
418
+ },
419
+ "independent_replication_count": {
420
+ "type": "integer",
421
+ "minimum": 0
422
+ }
423
+ }
424
+ },
425
+ "notes": {
426
+ "type": "string"
427
+ }
428
+ }
429
+ }