albert-memory 0.4.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.
- albert/__init__.py +3 -0
- albert/admin.py +532 -0
- albert/api.py +698 -0
- albert/automatic_memory_release.json +14 -0
- albert/benchmark.py +272 -0
- albert/benchmark_cli.py +37 -0
- albert/capture.py +1051 -0
- albert/capture_installer.py +1000 -0
- albert/capture_promotion.py +298 -0
- albert/capture_retention.py +72 -0
- albert/capture_sdk.py +558 -0
- albert/capture_security.py +79 -0
- albert/chunking.py +79 -0
- albert/classifier.py +263 -0
- albert/claude_adapter.py +357 -0
- albert/claude_lifecycle.py +418 -0
- albert/claude_package.json +19 -0
- albert/claude_plugin/.claude-plugin/plugin.json +11 -0
- albert/claude_plugin/.mcp.json +13 -0
- albert/claude_plugin/hooks/hooks.json +70 -0
- albert/clusters.py +140 -0
- albert/codex_adapter.py +308 -0
- albert/codex_hooks/hooks.json +49 -0
- albert/codex_lifecycle.py +485 -0
- albert/codex_package.json +19 -0
- albert/compatibility.py +121 -0
- albert/config.py +144 -0
- albert/console.py +508 -0
- albert/console_backends.py +236 -0
- albert/console_map.py +518 -0
- albert/console_ops.py +919 -0
- albert/credentials.py +616 -0
- albert/cursor_adapter.py +320 -0
- albert/cursor_hooks/hooks.json +11 -0
- albert/cursor_rules/albert-memory.mdc +11 -0
- albert/db.py +68 -0
- albert/embeddings.py +91 -0
- albert/graph.py +623 -0
- albert/graph_builders.py +879 -0
- albert/graph_cypher.py +669 -0
- albert/graph_store.py +376 -0
- albert/host_targets/claude-code.json +45 -0
- albert/host_targets/codex.json +45 -0
- albert/host_targets/openclaw.json +45 -0
- albert/host_targets.py +271 -0
- albert/links.py +428 -0
- albert/mcp_server.py +457 -0
- albert/memory_safety.py +111 -0
- albert/models.py +611 -0
- albert/openclaw_adapter.py +326 -0
- albert/openclaw_adapter_trust.json +5 -0
- albert/openclaw_package.json +22 -0
- albert/openclaw_plugin/index.js +135 -0
- albert/openclaw_plugin/openclaw.plugin.json +25 -0
- albert/openclaw_plugin/package.json +12 -0
- albert/portability.py +291 -0
- albert/provenance.py +105 -0
- albert/recorder.py +166 -0
- albert/release_gate.py +83 -0
- albert/schemas.py +617 -0
- albert/scope.py +26 -0
- albert/search.py +447 -0
- albert/security.py +150 -0
- albert/services.py +1316 -0
- albert/session_context.py +162 -0
- albert/trace_feed.py +165 -0
- albert/trace_writer.py +131 -0
- albert/tracing.py +75 -0
- albert/worker.py +202 -0
- albert/workspaces.py +256 -0
- albert_memory-0.4.0.dist-info/METADATA +554 -0
- albert_memory-0.4.0.dist-info/RECORD +74 -0
- albert_memory-0.4.0.dist-info/WHEEL +4 -0
- albert_memory-0.4.0.dist-info/entry_points.txt +14 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"release": {"package": "albert-memory", "version": "0.4.0", "channel": "opt-in-beta", "general_availability": false},
|
|
4
|
+
"slos": {"hook_budget_ms": 2000, "recall_max_characters": 6000, "queue_max_items": 1000, "queue_max_bytes": 8000000, "queue_max_age_seconds": 604800, "queue_max_retries": 12, "recovery": "bounded encrypted replay; idempotency keys prevent duplicate server writes"},
|
|
5
|
+
"hosts": {
|
|
6
|
+
"openclaw": {"tested": "2026.9.6", "supported": ">=2026.9.6,<2027", "fidelity": "full", "fixture": "tests/test_openclaw_adapter.py", "documentation": "docs/integrations/openclaw.md", "golden_flow": ["start_recall", "capture", "checkpoint", "consolidation", "searchability", "deletion"]},
|
|
7
|
+
"codex": {"tested": "0.154.0", "supported": ">=0.154.0,<0.155.0", "fidelity": "reduced-no-session-end-or-failure-hook", "fixture": "tests/test_codex_adapter.py", "documentation": "docs/integrations/codex.md", "golden_flow": ["start_recall", "capture", "checkpoint", "consolidation", "searchability", "deletion"]},
|
|
8
|
+
"claude-code": {"tested": "2.1.312", "supported": ">=2.1.268,<2.2", "fidelity": "full-with-best-effort-failure-hook", "fixture": "tests/test_claude_adapter.py", "documentation": "docs/integrations/claude-code.md", "golden_flow": ["start_recall", "capture", "checkpoint", "consolidation", "searchability", "deletion"]},
|
|
9
|
+
"cursor": {"tested": "Desktop 3.27.22 / Agent CLI 2026.10.22-27dcf5f", "supported": "hooks schema v1 on the tested surfaces", "fidelity": "full-local-reduced-cloud-agent", "fixture": "tests/test_cursor_adapter.py", "documentation": "docs/integrations/cursor.md", "golden_flow": ["start_recall", "capture", "checkpoint", "consolidation", "searchability", "deletion"]}
|
|
10
|
+
},
|
|
11
|
+
"security_gates": {"tenant_isolation": "pass", "policy_fail_closed": "pass", "secret_rejection_before_queue": "pass", "poisoning_and_instruction_boundaries": "pass", "bounded_encrypted_recovery": "pass", "body_free_telemetry": "pass", "retention_and_linked_deletion": "pass", "migration_and_rollback": "pass"},
|
|
12
|
+
"evidence": ["tests/test_capture_sdk.py", "tests/test_capture_security.py", "tests/test_capture_operations.py", "tests/test_capture_migration.py", "tests/test_capture_promotion.py", "tests/test_memory_safety.py", "tests/test_public_benchmark.py"],
|
|
13
|
+
"beta": {"mode": "explicit-opt-in", "cohort": "synthetic and internal fixture deployments only", "findings": ["Codex cannot emit true session-end or failure events on its tested hook surface", "Claude Code failure capture is best effort on affected 2.1.x releases", "Cursor cloud agents have no automatic start recall or true session-end hook", "Only OpenClaw has transactional one-command installation in this release"], "unresolved_launch_blockers": []}
|
|
14
|
+
}
|
albert/benchmark.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Deterministic public retrieval and safety benchmark support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
TOKEN_RE = re.compile(r"[a-z0-9]+")
|
|
16
|
+
REQUIRED_LICENSE = "CC0-1.0"
|
|
17
|
+
DEFAULT_THRESHOLDS: dict[str, float] = {
|
|
18
|
+
"recall_at_k": 1.0,
|
|
19
|
+
"mrr": 1.0,
|
|
20
|
+
"ndcg_at_k": 1.0,
|
|
21
|
+
"answerability_precision": 1.0,
|
|
22
|
+
"branch_relevance": 1.0,
|
|
23
|
+
"task_relevance": 1.0,
|
|
24
|
+
"stale_suppression": 1.0,
|
|
25
|
+
"citation_correctness": 1.0,
|
|
26
|
+
"poisoning_safety": 1.0,
|
|
27
|
+
"secret_leakage_safety": 1.0,
|
|
28
|
+
"latency_p95_ms_max": 100.0,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _tokens(value: str) -> set[str]:
|
|
33
|
+
return set(TOKEN_RE.findall(value.lower()))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def dataset_hash(dataset_dir: Path) -> str:
|
|
37
|
+
digest = hashlib.sha256()
|
|
38
|
+
for path in sorted(dataset_dir.glob("*.json")):
|
|
39
|
+
digest.update(path.name.encode())
|
|
40
|
+
digest.update(b"\0")
|
|
41
|
+
digest.update(path.read_bytes())
|
|
42
|
+
digest.update(b"\0")
|
|
43
|
+
return digest.hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_public_inputs(dataset_dir: Path) -> dict[str, Any]:
|
|
47
|
+
"""Load only inputs available to retrieval; held-out labels remain unopened."""
|
|
48
|
+
manifest = json.loads((dataset_dir / "manifest.json").read_text())
|
|
49
|
+
if manifest.get("license") != REQUIRED_LICENSE:
|
|
50
|
+
raise ValueError(f"benchmark corpus must be {REQUIRED_LICENSE}")
|
|
51
|
+
public = json.loads((dataset_dir / "public.json").read_text())
|
|
52
|
+
if any("answers" in query or "relevance" in query for query in public["heldout_queries"]):
|
|
53
|
+
raise ValueError("held-out answers leaked into public tuning inputs")
|
|
54
|
+
return {"manifest": manifest, **public}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def retrieve(query: dict[str, Any], documents: list[dict[str, Any]], k: int) -> list[str]:
|
|
58
|
+
query_tokens = _tokens(query["text"])
|
|
59
|
+
candidates: list[tuple[int, str]] = []
|
|
60
|
+
for document in documents:
|
|
61
|
+
if document.get("poisoned") or document.get("contains_secret"):
|
|
62
|
+
continue
|
|
63
|
+
if document.get("stale") and not query.get("include_stale", False):
|
|
64
|
+
continue
|
|
65
|
+
score = len(query_tokens & _tokens(f"{document['title']} {document['text']}"))
|
|
66
|
+
if score < 2:
|
|
67
|
+
continue
|
|
68
|
+
if query.get("branch") == document.get("branch"):
|
|
69
|
+
score += 1
|
|
70
|
+
if query.get("task") == document.get("task"):
|
|
71
|
+
score += 1
|
|
72
|
+
if score:
|
|
73
|
+
candidates.append((score, document["id"]))
|
|
74
|
+
candidates.sort(key=lambda item: (-item[0], item[1]))
|
|
75
|
+
return [document_id for _, document_id in candidates[:k]]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def make_predictions(public: dict[str, Any], *, k: int) -> tuple[dict[str, list[str]], list[float]]:
|
|
79
|
+
"""Produce predictions without receiving or opening held-out labels."""
|
|
80
|
+
predictions: dict[str, list[str]] = {}
|
|
81
|
+
latencies: list[float] = []
|
|
82
|
+
for query in public["heldout_queries"]:
|
|
83
|
+
started = time.perf_counter()
|
|
84
|
+
predictions[query["id"]] = retrieve(query, public["documents"], k)
|
|
85
|
+
latencies.append((time.perf_counter() - started) * 1000)
|
|
86
|
+
return predictions, latencies
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def recall_at_k(predicted: list[str], relevant: dict[str, int]) -> float:
|
|
90
|
+
return 1.0 if relevant and any(item in relevant for item in predicted) else float(not relevant)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def reciprocal_rank(predicted: list[str], relevant: dict[str, int]) -> float:
|
|
94
|
+
if not relevant:
|
|
95
|
+
return 1.0
|
|
96
|
+
return next((1.0 / rank for rank, item in enumerate(predicted, 1) if item in relevant), 0.0)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def ndcg_at_k(predicted: list[str], relevant: dict[str, int], k: int) -> float:
|
|
100
|
+
def dcg(grades: list[int]) -> float:
|
|
101
|
+
return sum((2**grade - 1) / math.log2(index + 2) for index, grade in enumerate(grades))
|
|
102
|
+
|
|
103
|
+
actual = dcg([relevant.get(item, 0) for item in predicted[:k]])
|
|
104
|
+
ideal = dcg(sorted(relevant.values(), reverse=True)[:k])
|
|
105
|
+
return actual / ideal if ideal else 1.0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _mean(values: list[float]) -> float:
|
|
109
|
+
return sum(values) / len(values) if values else 1.0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def evaluate(
|
|
113
|
+
public: dict[str, Any],
|
|
114
|
+
answers: dict[str, Any],
|
|
115
|
+
predictions: dict[str, list[str]],
|
|
116
|
+
latencies: list[float],
|
|
117
|
+
*,
|
|
118
|
+
k: int,
|
|
119
|
+
) -> dict[str, float]:
|
|
120
|
+
documents = {item["id"]: item for item in public["documents"]}
|
|
121
|
+
queries = {item["id"]: item for item in public["heldout_queries"]}
|
|
122
|
+
labels = {item["query_id"]: item for item in answers["answers"]}
|
|
123
|
+
recalls: list[float] = []
|
|
124
|
+
reciprocal_ranks: list[float] = []
|
|
125
|
+
ndcgs: list[float] = []
|
|
126
|
+
answerable_true_positives = 0
|
|
127
|
+
predicted_answerable = 0
|
|
128
|
+
branch_matches: list[float] = []
|
|
129
|
+
task_matches: list[float] = []
|
|
130
|
+
citations: list[float] = []
|
|
131
|
+
all_retrieved: list[dict[str, Any]] = []
|
|
132
|
+
for query_id, predicted in predictions.items():
|
|
133
|
+
label = labels[query_id]
|
|
134
|
+
relevant = label["relevance"]
|
|
135
|
+
recalls.append(recall_at_k(predicted, relevant))
|
|
136
|
+
reciprocal_ranks.append(reciprocal_rank(predicted, relevant))
|
|
137
|
+
ndcgs.append(ndcg_at_k(predicted, relevant, k))
|
|
138
|
+
is_predicted_answerable = bool(predicted and predicted[0] in relevant)
|
|
139
|
+
predicted_answerable += int(is_predicted_answerable)
|
|
140
|
+
answerable_true_positives += int(is_predicted_answerable and label["answerable"])
|
|
141
|
+
top = documents[predicted[0]] if predicted else None
|
|
142
|
+
if label["answerable"]:
|
|
143
|
+
branch_matches.append(
|
|
144
|
+
float(bool(top) and top.get("branch") == queries[query_id].get("branch"))
|
|
145
|
+
)
|
|
146
|
+
task_matches.append(
|
|
147
|
+
float(bool(top) and top.get("task") == queries[query_id].get("task"))
|
|
148
|
+
)
|
|
149
|
+
citations.extend(float(item in documents and item in relevant) for item in predicted)
|
|
150
|
+
all_retrieved.extend(documents[item] for item in predicted)
|
|
151
|
+
sorted_latency = sorted(latencies)
|
|
152
|
+
p95_index = max(0, math.ceil(len(sorted_latency) * 0.95) - 1)
|
|
153
|
+
return {
|
|
154
|
+
"recall_at_k": _mean(recalls),
|
|
155
|
+
"mrr": _mean(reciprocal_ranks),
|
|
156
|
+
"ndcg_at_k": _mean(ndcgs),
|
|
157
|
+
"answerability_precision": answerable_true_positives / predicted_answerable
|
|
158
|
+
if predicted_answerable
|
|
159
|
+
else 1.0,
|
|
160
|
+
"branch_relevance": _mean(branch_matches),
|
|
161
|
+
"task_relevance": _mean(task_matches),
|
|
162
|
+
"stale_suppression": float(not any(item.get("stale") for item in all_retrieved)),
|
|
163
|
+
"citation_correctness": _mean(citations),
|
|
164
|
+
"poisoning_safety": float(not any(item.get("poisoned") for item in all_retrieved)),
|
|
165
|
+
"secret_leakage_safety": float(
|
|
166
|
+
not any(item.get("contains_secret") for item in all_retrieved)
|
|
167
|
+
),
|
|
168
|
+
"latency_p95_ms": sorted_latency[p95_index] if sorted_latency else 0.0,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _code_sha() -> str:
|
|
173
|
+
override = os.environ.get("ALBERT_BENCHMARK_CODE_SHA")
|
|
174
|
+
if override:
|
|
175
|
+
return override
|
|
176
|
+
return subprocess.run(
|
|
177
|
+
["git", "rev-parse", "HEAD"], check=True, capture_output=True, text=True
|
|
178
|
+
).stdout.strip()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _failures(metrics: dict[str, float], thresholds: dict[str, float]) -> list[str]:
|
|
182
|
+
failures = []
|
|
183
|
+
for name, threshold in thresholds.items():
|
|
184
|
+
if name.endswith("_max"):
|
|
185
|
+
metric_name = name.removesuffix("_max")
|
|
186
|
+
if metrics[metric_name] > threshold:
|
|
187
|
+
failures.append(f"{metric_name}={metrics[metric_name]:.6f} > {threshold:.6f}")
|
|
188
|
+
elif metrics[name] < threshold:
|
|
189
|
+
failures.append(f"{name}={metrics[name]:.6f} < {threshold:.6f}")
|
|
190
|
+
return failures
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def run_benchmark(
|
|
194
|
+
dataset_dir: Path,
|
|
195
|
+
output_dir: Path,
|
|
196
|
+
*,
|
|
197
|
+
seed: int = 4403,
|
|
198
|
+
k: int = 3,
|
|
199
|
+
thresholds: dict[str, float] | None = None,
|
|
200
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
201
|
+
started = time.time()
|
|
202
|
+
public = load_public_inputs(dataset_dir)
|
|
203
|
+
predictions, latencies = make_predictions(public, k=k)
|
|
204
|
+
# This is the seal break: labels are opened only after all predictions exist.
|
|
205
|
+
answers = json.loads((dataset_dir / "heldout_answers.json").read_text())
|
|
206
|
+
metrics = evaluate(public, answers, predictions, latencies, k=k)
|
|
207
|
+
active_thresholds = thresholds or DEFAULT_THRESHOLDS.copy()
|
|
208
|
+
failures = _failures(metrics, active_thresholds)
|
|
209
|
+
reproducibility = {
|
|
210
|
+
"dataset_sha256": dataset_hash(dataset_dir),
|
|
211
|
+
"code_sha": _code_sha(),
|
|
212
|
+
"config": {"k": k, "thresholds": active_thresholds},
|
|
213
|
+
"provider": "local",
|
|
214
|
+
"model": "deterministic-token-overlap-v1",
|
|
215
|
+
"seed": seed,
|
|
216
|
+
}
|
|
217
|
+
fingerprint = hashlib.sha256(
|
|
218
|
+
json.dumps(
|
|
219
|
+
{
|
|
220
|
+
"reproducibility": reproducibility,
|
|
221
|
+
"predictions": predictions,
|
|
222
|
+
"metrics": {k: v for k, v in metrics.items() if k != "latency_p95_ms"},
|
|
223
|
+
},
|
|
224
|
+
sort_keys=True,
|
|
225
|
+
).encode()
|
|
226
|
+
).hexdigest()
|
|
227
|
+
receipt = {
|
|
228
|
+
"schema_version": 1,
|
|
229
|
+
"benchmark": "albert-public-retrieval-safety",
|
|
230
|
+
"comparison": "internal_baseline",
|
|
231
|
+
"reproducibility": reproducibility,
|
|
232
|
+
"timing": {"started_unix": started, "duration_ms": (time.time() - started) * 1000},
|
|
233
|
+
"metrics": metrics,
|
|
234
|
+
"predictions": predictions,
|
|
235
|
+
"failures": failures,
|
|
236
|
+
"deterministic_fingerprint": fingerprint,
|
|
237
|
+
"passed": not failures,
|
|
238
|
+
}
|
|
239
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
(output_dir / "receipt.json").write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n")
|
|
241
|
+
rows = "\n".join(f"| {name} | {value:.6f} |" for name, value in sorted(metrics.items()))
|
|
242
|
+
config = json.dumps(reproducibility["config"], indent=2, sort_keys=True)
|
|
243
|
+
timing = receipt["timing"]
|
|
244
|
+
human = f"""# Albert public retrieval and safety benchmark
|
|
245
|
+
|
|
246
|
+
**Result:** {"PASS" if not failures else "FAIL"}\\
|
|
247
|
+
**Comparison:** Internal Albert baseline only; this is not a matched third-party comparison.\\
|
|
248
|
+
**Dataset SHA-256:** `{reproducibility["dataset_sha256"]}`\\
|
|
249
|
+
**Code SHA:** `{reproducibility["code_sha"]}`\\
|
|
250
|
+
**Provider:** `{reproducibility["provider"]}`
|
|
251
|
+
**Model:** `{reproducibility["model"]}`
|
|
252
|
+
**Seed:** `{reproducibility["seed"]}`
|
|
253
|
+
**Started (Unix):** `{timing["started_unix"]:.6f}`
|
|
254
|
+
**Duration (ms):** `{timing["duration_ms"]:.6f}`
|
|
255
|
+
**Deterministic fingerprint:** `{fingerprint}`
|
|
256
|
+
|
|
257
|
+
## Configuration
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
{config}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
| Metric | Value |
|
|
264
|
+
|---|---:|
|
|
265
|
+
{rows}
|
|
266
|
+
|
|
267
|
+
## Failures
|
|
268
|
+
|
|
269
|
+
{chr(10).join(f"- {item}" for item in failures) if failures else "- None"}
|
|
270
|
+
"""
|
|
271
|
+
(output_dir / "receipt.md").write_text(human)
|
|
272
|
+
return receipt, failures
|
albert/benchmark_cli.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from albert.benchmark import DEFAULT_THRESHOLDS, run_benchmark
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> int:
|
|
11
|
+
parser = argparse.ArgumentParser(description="Run Albert's public retrieval/safety benchmark")
|
|
12
|
+
parser.add_argument("--dataset", type=Path, default=Path("benchmarks/public-v1"))
|
|
13
|
+
parser.add_argument("--output", type=Path, default=Path("benchmark-results"))
|
|
14
|
+
parser.add_argument("--thresholds", type=Path)
|
|
15
|
+
parser.add_argument("--seed", type=int, default=4403)
|
|
16
|
+
parser.add_argument("--k", type=int, default=3)
|
|
17
|
+
args = parser.parse_args()
|
|
18
|
+
thresholds = DEFAULT_THRESHOLDS.copy()
|
|
19
|
+
if args.thresholds:
|
|
20
|
+
thresholds.update(json.loads(args.thresholds.read_text()))
|
|
21
|
+
receipt, failures = run_benchmark(
|
|
22
|
+
args.dataset, args.output, seed=args.seed, k=args.k, thresholds=thresholds
|
|
23
|
+
)
|
|
24
|
+
print(
|
|
25
|
+
json.dumps(
|
|
26
|
+
{
|
|
27
|
+
"passed": receipt["passed"],
|
|
28
|
+
"failures": failures,
|
|
29
|
+
"receipt": str(args.output / "receipt.json"),
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
return int(bool(failures))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
raise SystemExit(main())
|