sediment-api 0.6.1__tar.gz → 0.7.0__tar.gz
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.
- {sediment_api-0.6.1 → sediment_api-0.7.0}/PKG-INFO +6 -6
- {sediment_api-0.6.1 → sediment_api-0.7.0}/pyproject.toml +6 -6
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/__init__.py +1 -1
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/main.py +2 -1
- sediment_api-0.7.0/sediment_api/reports/compute_report.py +157 -0
- sediment_api-0.7.0/sediment_api/routers/compute.py +102 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/routers/query.py +43 -1
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/routers/v1.py +1 -0
- sediment_api-0.7.0/tests/test_compute_ingest.py +124 -0
- sediment_api-0.7.0/tests/test_compute_report_cli.py +112 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_credential_authorities.py +1 -1
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_v1.py +2 -1
- {sediment_api-0.6.1 → sediment_api-0.7.0}/.gitignore +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/LICENSE +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/config.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/database.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/deps.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/mirror_gc.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/reports/__init__.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/reports/abandonment_report.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/reports/attribution_share_report.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/reports/dataset_diagnostics.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/reports/label_confidence_inspection.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/reports/lifecycle_report.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/reports/merge_retention_report.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/reports/model_report.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/reports/precision_report.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/reports/recovery_yield_report.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/routers/__init__.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/routers/ci_vendor.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/routers/forge.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/routers/gateway.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/routers/otlp.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/routers/reports.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/services/__init__.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/services/operational_reports.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/worker.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/workers.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/conftest.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_attribution_share_report.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_baseline_gap_repro.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_bug_repro_offset.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_capture_record_isolation.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_ci_vendor.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_config.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_context_discovery_authority.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_context_discovery_query.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_context_discovery_workers.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_context_evidence_query.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_context_query.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_database_lifecycle.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_database_outage.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_database_role_startup.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_dataset_diagnostics_report.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_e2e_smoke.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_evidence_query.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_evidence_worker_imports.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_evidence_workers.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_gateway_capture_receipt.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_gateway_identity.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_lifecycle_report_cli.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_merge_retention_cli.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_one_shot_database.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_operational_reports.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_operational_reports_service.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_postgres_gateway_tracer.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_push_mirror.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_query.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_report_attachment_scope.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_report_routes.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_repository_identity_ingest.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_repository_identity_model_service.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_repository_identity_queries.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_retrieval_authority.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_worker_processes.py +0 -0
- {sediment_api-0.6.1 → sediment_api-0.7.0}/tests/test_worker_routes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sediment-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Sediment ingest API: facts in through three doors
|
|
5
5
|
Project-URL: Source, https://github.com/sediment-ai/sediment
|
|
6
6
|
Project-URL: Documentation, https://github.com/sediment-ai/sediment/tree/main/docs
|
|
@@ -12,9 +12,9 @@ Requires-Python: >=3.12
|
|
|
12
12
|
Requires-Dist: anyio>=4.15.1
|
|
13
13
|
Requires-Dist: fastapi>=0.142.2
|
|
14
14
|
Requires-Dist: httpx>=0.27
|
|
15
|
-
Requires-Dist: pydantic-settings>=2.16.
|
|
16
|
-
Requires-Dist: sediment-capture==0.
|
|
17
|
-
Requires-Dist: sediment-core==0.
|
|
18
|
-
Requires-Dist: sediment-derive==0.
|
|
19
|
-
Requires-Dist: sediment-export==0.
|
|
15
|
+
Requires-Dist: pydantic-settings>=2.16.2
|
|
16
|
+
Requires-Dist: sediment-capture==0.7.0
|
|
17
|
+
Requires-Dist: sediment-core==0.7.0
|
|
18
|
+
Requires-Dist: sediment-derive==0.7.0
|
|
19
|
+
Requires-Dist: sediment-export==0.7.0
|
|
20
20
|
Requires-Dist: uvicorn>=0.54.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sediment-api"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.0"
|
|
4
4
|
description = "Sediment ingest API: facts in through three doors"
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
6
|
license = "AGPL-3.0-or-later"
|
|
@@ -11,11 +11,11 @@ dependencies = [
|
|
|
11
11
|
"fastapi>=0.142.2",
|
|
12
12
|
"httpx>=0.27",
|
|
13
13
|
"uvicorn>=0.54.0",
|
|
14
|
-
"pydantic-settings>=2.16.
|
|
15
|
-
"sediment-core==0.
|
|
16
|
-
"sediment-capture==0.
|
|
17
|
-
"sediment-derive==0.
|
|
18
|
-
"sediment-export==0.
|
|
14
|
+
"pydantic-settings>=2.16.2",
|
|
15
|
+
"sediment-core==0.7.0",
|
|
16
|
+
"sediment-capture==0.7.0",
|
|
17
|
+
"sediment-derive==0.7.0",
|
|
18
|
+
"sediment-export==0.7.0",
|
|
19
19
|
]
|
|
20
20
|
|
|
21
21
|
[project.urls]
|
|
@@ -34,7 +34,7 @@ from sqlalchemy.exc import (
|
|
|
34
34
|
from . import __version__
|
|
35
35
|
from .config import settings
|
|
36
36
|
from .deps import BodySizeLimitMiddleware
|
|
37
|
-
from .routers import ci_vendor, forge, gateway, otlp, query, reports, v1
|
|
37
|
+
from .routers import ci_vendor, compute, forge, gateway, otlp, query, reports, v1
|
|
38
38
|
from .workers import WorkerSupervisor
|
|
39
39
|
|
|
40
40
|
logging.basicConfig(
|
|
@@ -229,6 +229,7 @@ app.include_router(gateway.router, prefix="/ingest")
|
|
|
229
229
|
# free for a vendor-neutral push door later.
|
|
230
230
|
app.include_router(forge.router, prefix="/ingest/github")
|
|
231
231
|
app.include_router(ci_vendor.router, prefix="/ingest")
|
|
232
|
+
app.include_router(compute.router, prefix="/ingest")
|
|
232
233
|
# OTLP receiver: path fixed by the exporter (<endpoint>/v1/logs), no prefix.
|
|
233
234
|
app.include_router(otlp.router)
|
|
234
235
|
app.include_router(query.router, prefix="/query")
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Compute report: Session completion time by final Compute allocation.
|
|
3
|
+
|
|
4
|
+
sediment report compute --org acme-corp
|
|
5
|
+
sediment report compute --org acme-corp --json
|
|
6
|
+
sediment report compute --org acme-corp --rows-out sessions.jsonl
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from sediment_core import normalize_org_id
|
|
17
|
+
from sediment_derive import ComputeArm
|
|
18
|
+
from sediment_export import (
|
|
19
|
+
ComputeReport,
|
|
20
|
+
DurationDistribution,
|
|
21
|
+
compute_report_to_json,
|
|
22
|
+
compute_timings_to_export_rows,
|
|
23
|
+
generate_compute_report_result,
|
|
24
|
+
write_jsonl,
|
|
25
|
+
)
|
|
26
|
+
from sediment_export.significance import MedianDifference
|
|
27
|
+
|
|
28
|
+
from ..database import add_database_url_argument, one_shot_fact_store
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _arm(arm: ComputeArm) -> str:
|
|
32
|
+
cpu = "unknown" if arm.cpu_millicores is None else f"{arm.cpu_millicores}m"
|
|
33
|
+
memory = (
|
|
34
|
+
"unknown" if arm.memory_bytes is None else f"{arm.memory_bytes / 2**30:.2f}GiB"
|
|
35
|
+
)
|
|
36
|
+
return f"{arm.allocation_source.value} cpu={cpu} memory={memory}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _seconds(milliseconds: float | None) -> str:
|
|
40
|
+
return "n/a" if milliseconds is None else f"{milliseconds / 1000:.1f}s"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _distribution(name: str, distribution: DurationDistribution) -> str:
|
|
44
|
+
return (
|
|
45
|
+
f"{name} median={_seconds(distribution.median)} "
|
|
46
|
+
f"p25={_seconds(distribution.p25)} p75={_seconds(distribution.p75)}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _difference(name: str, difference: MedianDifference) -> str:
|
|
51
|
+
sizes = f"n={difference.n_a}/{difference.n_b}"
|
|
52
|
+
if difference.insufficient_data:
|
|
53
|
+
return f" {name}: insufficient data ({sizes})"
|
|
54
|
+
flag = " small-n" if difference.small_n else ""
|
|
55
|
+
level = round(difference.confidence * 100)
|
|
56
|
+
return (
|
|
57
|
+
f" {name}: {_seconds(difference.difference)} "
|
|
58
|
+
f"[{level}% CI {_seconds(difference.ci_low)}, "
|
|
59
|
+
f"{_seconds(difference.ci_high)}] {sizes}{flag}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _print_table(report: ComputeReport) -> None:
|
|
64
|
+
print(f"Sessions with a Compute allocation: {report.sessions_with_allocation}")
|
|
65
|
+
print(f"timed Sessions: {report.timed_sessions}")
|
|
66
|
+
skipped = ", ".join(f"{key}={value}" for key, value in report.skipped.items())
|
|
67
|
+
print(f"skipped Sessions: {skipped}")
|
|
68
|
+
print(
|
|
69
|
+
"randomized Sessions reassigned to another source: "
|
|
70
|
+
f"{report.randomized_reassigned_sessions} "
|
|
71
|
+
"(excluded from randomized comparisons)"
|
|
72
|
+
)
|
|
73
|
+
if not report.arms:
|
|
74
|
+
print("arms: no data")
|
|
75
|
+
for summary in report.arms:
|
|
76
|
+
print(
|
|
77
|
+
f"{_arm(summary.arm)}: n={summary.sessions} "
|
|
78
|
+
f"(allocation changed in {summary.allocation_changed_sessions})"
|
|
79
|
+
)
|
|
80
|
+
for name in ("elapsed_ms", "model_ms", "between_calls_ms"):
|
|
81
|
+
print(f" {_distribution(name, getattr(summary, name))}")
|
|
82
|
+
if report.comparisons:
|
|
83
|
+
print("comparisons (arm B minus arm A; negative means arm B finished faster):")
|
|
84
|
+
for comparison in report.comparisons:
|
|
85
|
+
reading = "randomized" if comparison.randomized else "observational"
|
|
86
|
+
print(f" {_arm(comparison.arm_a)} -> {_arm(comparison.arm_b)} ({reading})")
|
|
87
|
+
print(_difference("elapsed_ms", comparison.elapsed_ms))
|
|
88
|
+
print(_difference("between_calls_ms", comparison.between_calls_ms))
|
|
89
|
+
print(f"provenance: {report.provenance}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
93
|
+
"""Return the argv contract consumed by the generated CLI reference."""
|
|
94
|
+
parser = argparse.ArgumentParser(
|
|
95
|
+
prog="compute_report",
|
|
96
|
+
description=(
|
|
97
|
+
"Compare Session completion time across final Compute allocations."
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--org",
|
|
102
|
+
default=os.environ.get("SEDIMENT_ORG_ID"),
|
|
103
|
+
required=not os.environ.get("SEDIMENT_ORG_ID"),
|
|
104
|
+
help="org id (default: $SEDIMENT_ORG_ID)",
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--json", action="store_true", help="emit JSON instead of a text report"
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--rows-out",
|
|
111
|
+
metavar="FILE",
|
|
112
|
+
help="write one Session compute timing per line to FILE as JSONL",
|
|
113
|
+
)
|
|
114
|
+
add_database_url_argument(parser)
|
|
115
|
+
return parser
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main(argv: list[str] | None = None) -> int:
|
|
119
|
+
args = build_parser().parse_args(argv)
|
|
120
|
+
try:
|
|
121
|
+
org_id = normalize_org_id(args.org)
|
|
122
|
+
except ValueError as exc:
|
|
123
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
124
|
+
return 2
|
|
125
|
+
with one_shot_fact_store(
|
|
126
|
+
args.database_url, operation="generate compute report"
|
|
127
|
+
) as store:
|
|
128
|
+
result = generate_compute_report_result(store, org_id)
|
|
129
|
+
if args.json:
|
|
130
|
+
print(json.dumps(compute_report_to_json(result.report), indent=2))
|
|
131
|
+
else:
|
|
132
|
+
_print_table(result.report)
|
|
133
|
+
if args.rows_out:
|
|
134
|
+
try:
|
|
135
|
+
write_result = write_jsonl(
|
|
136
|
+
compute_timings_to_export_rows(result.timings),
|
|
137
|
+
args.rows_out,
|
|
138
|
+
split_enabled=False,
|
|
139
|
+
)
|
|
140
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
141
|
+
print(f"error: couldn't write compute timing rows: {exc}", file=sys.stderr)
|
|
142
|
+
return 1
|
|
143
|
+
if write_result.written:
|
|
144
|
+
print(
|
|
145
|
+
f"wrote {len(result.timings)} compute timing rows to {args.rows_out}",
|
|
146
|
+
file=sys.stderr,
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
print(
|
|
150
|
+
f"wrote 0 compute timing rows; left {args.rows_out} unchanged",
|
|
151
|
+
file=sys.stderr,
|
|
152
|
+
)
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
POST /ingest/compute — vendor-neutral Compute allocation ingest.
|
|
4
|
+
|
|
5
|
+
The execution platform that runs a coding agent (a sandbox, a container
|
|
6
|
+
scheduler, a CI runner) posts the CPU and memory limits a Session's
|
|
7
|
+
environment received: once when the environment starts, and again on every
|
|
8
|
+
resize. ``allocation_source`` records who chose the allocation, so the
|
|
9
|
+
compute report never presents an agent's own request as an assigned
|
|
10
|
+
treatment. The sender is bearer-authenticated; Sediment stores its assertion
|
|
11
|
+
and does not verify it against the platform.
|
|
12
|
+
|
|
13
|
+
Dedup: the store's ``uq_compute_allocations_natural`` UNIQUE index on the
|
|
14
|
+
complete allocation collapses redeliveries; ``stored: false`` means success,
|
|
15
|
+
not an error.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from fastapi import APIRouter, Depends
|
|
23
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
24
|
+
from sediment_core import (
|
|
25
|
+
ComputeAllocation,
|
|
26
|
+
ComputeAllocationSource,
|
|
27
|
+
FactStore,
|
|
28
|
+
NonEmptyId,
|
|
29
|
+
UtcRepresentableDatetime,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from ..config import settings
|
|
33
|
+
from ..deps import get_store, verify_ingest_token
|
|
34
|
+
|
|
35
|
+
router = APIRouter(tags=["compute"])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ComputeAllocationRequest(BaseModel):
|
|
39
|
+
"""One Compute allocation as the execution platform observed it.
|
|
40
|
+
|
|
41
|
+
``extra="forbid"`` so a caller naming an org or fabricating fields is
|
|
42
|
+
rejected at the boundary. The limits carry the Fact's own bounds, so
|
|
43
|
+
malformed input 422s at the door rather than inside the handler."""
|
|
44
|
+
|
|
45
|
+
model_config = ConfigDict(extra="forbid")
|
|
46
|
+
|
|
47
|
+
session_id: NonEmptyId = Field(
|
|
48
|
+
description=(
|
|
49
|
+
"The Session ID the coding agent's captured Facts carry, such as "
|
|
50
|
+
"the gateway Session header"
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
allocation_source: ComputeAllocationSource = Field(
|
|
54
|
+
description=(
|
|
55
|
+
"Who chose the allocation: default, agent_request, operator, or "
|
|
56
|
+
"randomized. Only randomized supports a causal comparison"
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
cpu_millicores: int | None = Field(
|
|
60
|
+
default=None,
|
|
61
|
+
ge=1,
|
|
62
|
+
le=2**63 - 1,
|
|
63
|
+
strict=True,
|
|
64
|
+
description="CPU limit in millicores; 1000 is one CPU",
|
|
65
|
+
)
|
|
66
|
+
memory_bytes: int | None = Field(
|
|
67
|
+
default=None,
|
|
68
|
+
ge=1,
|
|
69
|
+
le=2**63 - 1,
|
|
70
|
+
strict=True,
|
|
71
|
+
description="Memory limit in bytes",
|
|
72
|
+
)
|
|
73
|
+
occurred_at: UtcRepresentableDatetime = Field(
|
|
74
|
+
description=(
|
|
75
|
+
"When the allocation took effect, by the sender's clock; it must "
|
|
76
|
+
"convert to a UTC instant"
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@model_validator(mode="after")
|
|
81
|
+
def _require_limit(self):
|
|
82
|
+
if self.cpu_millicores is None and self.memory_bytes is None:
|
|
83
|
+
raise ValueError("send cpu_millicores, memory_bytes, or both")
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@router.post("/compute")
|
|
88
|
+
def ingest_compute_allocation(
|
|
89
|
+
body: ComputeAllocationRequest,
|
|
90
|
+
_: None = Depends(verify_ingest_token),
|
|
91
|
+
store: FactStore = Depends(get_store),
|
|
92
|
+
) -> dict[str, Any]:
|
|
93
|
+
allocation = ComputeAllocation(
|
|
94
|
+
org_id=settings.org_id,
|
|
95
|
+
session_id=body.session_id,
|
|
96
|
+
allocation_source=body.allocation_source,
|
|
97
|
+
cpu_millicores=body.cpu_millicores,
|
|
98
|
+
memory_bytes=body.memory_bytes,
|
|
99
|
+
occurred_at=body.occurred_at,
|
|
100
|
+
)
|
|
101
|
+
receipt = store.store_compute_allocation_receipt(allocation)
|
|
102
|
+
return {"fact_id": receipt.fact_id, "stored": receipt.stored}
|
|
@@ -34,6 +34,7 @@ from sediment_core import (
|
|
|
34
34
|
AgentHarness,
|
|
35
35
|
CIProvider,
|
|
36
36
|
CIResult,
|
|
37
|
+
ComputeAllocationSource,
|
|
37
38
|
ContextCommitAnchor,
|
|
38
39
|
FactStore,
|
|
39
40
|
FactTable,
|
|
@@ -530,6 +531,7 @@ class SessionCoverageResponse:
|
|
|
530
531
|
edit_observations: SessionFactCoverageResponse
|
|
531
532
|
rejected_edits: SessionFactCoverageResponse
|
|
532
533
|
retry_linkages: SessionFactCoverageResponse
|
|
534
|
+
compute_allocations: SessionFactCoverageResponse
|
|
533
535
|
|
|
534
536
|
|
|
535
537
|
SessionEventType = Literal[
|
|
@@ -579,6 +581,23 @@ class SessionTimelineEvent:
|
|
|
579
581
|
accepted_call_id: str | None = None
|
|
580
582
|
|
|
581
583
|
|
|
584
|
+
@dataclass(frozen=True)
|
|
585
|
+
class ComputeAllocationSummary:
|
|
586
|
+
"""One visible Compute allocation; a list beside the timeline, never in it.
|
|
587
|
+
|
|
588
|
+
``sediment doctor`` copies on developer machines reject timeline event
|
|
589
|
+
types outside their fixed set, so the timeline vocabulary stays closed.
|
|
590
|
+
The route omits null fields, so an unknown limit is an absent key; the
|
|
591
|
+
defaults keep that absence inside the published contract."""
|
|
592
|
+
|
|
593
|
+
allocation_id: str
|
|
594
|
+
allocation_source: ComputeAllocationSource
|
|
595
|
+
occurred_at: datetime
|
|
596
|
+
captured_at: datetime
|
|
597
|
+
cpu_millicores: int | None = None
|
|
598
|
+
memory_bytes: int | None = None
|
|
599
|
+
|
|
600
|
+
|
|
582
601
|
@dataclass(frozen=True)
|
|
583
602
|
class AttributedCommitSummary:
|
|
584
603
|
"""Observation-backed Session edge with compatibility Attribution fields.
|
|
@@ -621,6 +640,7 @@ class SessionDossierFound:
|
|
|
621
640
|
omitted_events: int
|
|
622
641
|
gaps: list[SessionGap]
|
|
623
642
|
timeline: list[SessionTimelineEvent]
|
|
643
|
+
compute_allocations: list[ComputeAllocationSummary]
|
|
624
644
|
attributed_commits: list[AttributedCommitSummary]
|
|
625
645
|
pushes: list[PushSummary]
|
|
626
646
|
ci_outcomes: list[SessionCIOutcomeSummary]
|
|
@@ -1031,6 +1051,9 @@ def _run_session_query(
|
|
|
1031
1051
|
if dossier is None:
|
|
1032
1052
|
return SessionDossierNotFound(found=False)
|
|
1033
1053
|
|
|
1054
|
+
allocations = snapshot.read_compute_allocations(
|
|
1055
|
+
settings.org_id, session_ids={session_id}, limit=_SESSION_DOSSIER_LIMIT
|
|
1056
|
+
)
|
|
1034
1057
|
# One request boundary and complete context precede the Session filter.
|
|
1035
1058
|
boundary = datetime.now(UTC)
|
|
1036
1059
|
context = read_repository_context(snapshot, settings.org_id, as_of=boundary)
|
|
@@ -1101,12 +1124,31 @@ def _run_session_query(
|
|
|
1101
1124
|
edit_observations=_fact_coverage(dossier, "edit_observations"),
|
|
1102
1125
|
rejected_edits=_fact_coverage(dossier, "rejected_edits"),
|
|
1103
1126
|
retry_linkages=_fact_coverage(dossier, "retry_linkages"),
|
|
1127
|
+
compute_allocations=_fact_coverage(dossier, "compute_allocations"),
|
|
1104
1128
|
),
|
|
1129
|
+
# Timeline omissions only: quarantined Compute allocations stay in
|
|
1130
|
+
# their own coverage, so they never mark decision evidence incomplete.
|
|
1105
1131
|
omitted_events=sum(
|
|
1106
|
-
coverage.quarantined
|
|
1132
|
+
coverage.quarantined
|
|
1133
|
+
for fact_type, coverage in dossier.coverage.items()
|
|
1134
|
+
if fact_type != "compute_allocations"
|
|
1107
1135
|
),
|
|
1108
1136
|
gaps=gaps,
|
|
1109
1137
|
timeline=[_timeline_event(row) for row in dossier.timeline],
|
|
1138
|
+
compute_allocations=[
|
|
1139
|
+
ComputeAllocationSummary(
|
|
1140
|
+
allocation_id=item.allocation_id,
|
|
1141
|
+
allocation_source=item.allocation_source,
|
|
1142
|
+
cpu_millicores=item.cpu_millicores,
|
|
1143
|
+
memory_bytes=item.memory_bytes,
|
|
1144
|
+
occurred_at=item.occurred_at,
|
|
1145
|
+
captured_at=item.captured_at,
|
|
1146
|
+
)
|
|
1147
|
+
for item in sorted(
|
|
1148
|
+
allocations,
|
|
1149
|
+
key=lambda item: (item.occurred_at.astimezone(UTC), item.allocation_id),
|
|
1150
|
+
)
|
|
1151
|
+
],
|
|
1110
1152
|
attributed_commits=attributed_commits,
|
|
1111
1153
|
session_commit_unobserved=int(not attributed_commits),
|
|
1112
1154
|
repository_skipped=dict(binding_result.skipped),
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Compute allocation ingest and Session dossier exposure — real FactStore."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from fastapi.testclient import TestClient
|
|
7
|
+
from sediment_api.config import settings
|
|
8
|
+
from sediment_api.main import app
|
|
9
|
+
from sediment_core import ComputeAllocationSource, FactTable
|
|
10
|
+
|
|
11
|
+
INGEST = {"Authorization": "Bearer test-ingest-token-3a7e-9d21"}
|
|
12
|
+
OPERATOR = {"Authorization": "Bearer test-operator-token-3a7e-2f6c"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _payload(**changes) -> dict:
|
|
16
|
+
body = {
|
|
17
|
+
"session_id": "sandbox-session",
|
|
18
|
+
"allocation_source": "agent_request",
|
|
19
|
+
"cpu_millicores": 8000,
|
|
20
|
+
"memory_bytes": 16 * 2**30,
|
|
21
|
+
"occurred_at": "2026-10-01T12:00:00Z",
|
|
22
|
+
}
|
|
23
|
+
body.update(changes)
|
|
24
|
+
return {key: value for key, value in body.items() if value is not None}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_compute_ingest_stores_and_collapses_redelivery(client: TestClient) -> None:
|
|
28
|
+
first = client.post("/ingest/compute", json=_payload(), headers=INGEST)
|
|
29
|
+
assert first.status_code == 200
|
|
30
|
+
assert first.json()["stored"] is True
|
|
31
|
+
|
|
32
|
+
again = client.post("/ingest/compute", json=_payload(), headers=INGEST)
|
|
33
|
+
assert again.status_code == 200
|
|
34
|
+
assert again.json() == {"fact_id": first.json()["fact_id"], "stored": False}
|
|
35
|
+
|
|
36
|
+
[stored] = app.state.fact_store.read_compute_allocations(settings.org_id)
|
|
37
|
+
assert stored.allocation_id == first.json()["fact_id"]
|
|
38
|
+
assert stored.org_id == settings.org_id
|
|
39
|
+
assert stored.allocation_source is ComputeAllocationSource.AGENT_REQUEST
|
|
40
|
+
assert (stored.cpu_millicores, stored.memory_bytes) == (8000, 16 * 2**30)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_compute_ingest_accepts_one_limit(client: TestClient) -> None:
|
|
44
|
+
response = client.post(
|
|
45
|
+
"/ingest/compute",
|
|
46
|
+
json=_payload(memory_bytes=None, allocation_source="default"),
|
|
47
|
+
headers=INGEST,
|
|
48
|
+
)
|
|
49
|
+
assert response.status_code == 200
|
|
50
|
+
[stored] = app.state.fact_store.read_compute_allocations(settings.org_id)
|
|
51
|
+
assert stored.memory_bytes is None
|
|
52
|
+
# The dossier omits the unknown limit, and the contract marks it optional.
|
|
53
|
+
dossier = client.get("/query/session/sandbox-session", headers=OPERATOR).json()
|
|
54
|
+
[item] = dossier["compute_allocations"]
|
|
55
|
+
assert item["cpu_millicores"] == 8000
|
|
56
|
+
assert "memory_bytes" not in item
|
|
57
|
+
schema = app.openapi()["components"]["schemas"]["ComputeAllocationSummary"]
|
|
58
|
+
assert "memory_bytes" not in schema["required"]
|
|
59
|
+
assert "cpu_millicores" not in schema["required"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_compute_ingest_rejects_malformed_envelopes(client: TestClient) -> None:
|
|
63
|
+
for payload in (
|
|
64
|
+
_payload(cpu_millicores=None, memory_bytes=None),
|
|
65
|
+
_payload(cpu_millicores=0),
|
|
66
|
+
_payload(cpu_millicores=1.5),
|
|
67
|
+
_payload(cpu_millicores="8000"),
|
|
68
|
+
_payload(memory_bytes=True),
|
|
69
|
+
_payload(memory_bytes=2**63),
|
|
70
|
+
_payload(allocation_source="autoscaler"),
|
|
71
|
+
_payload(occurred_at="2026-10-01T12:00:00"),
|
|
72
|
+
_payload(occurred_at="9999-12-31T23:59:59-14:00"),
|
|
73
|
+
_payload(occurred_at="0001-01-01T00:00:00+14:00"),
|
|
74
|
+
_payload(session_id=" "),
|
|
75
|
+
{**_payload(), "org_id": "another-org"},
|
|
76
|
+
):
|
|
77
|
+
response = client.post("/ingest/compute", json=payload, headers=INGEST)
|
|
78
|
+
assert response.status_code == 422, payload
|
|
79
|
+
assert app.state.fact_store.read_compute_allocations(settings.org_id) == []
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_session_dossier_shows_compute_allocations(client: TestClient) -> None:
|
|
83
|
+
client.post(
|
|
84
|
+
"/ingest/compute",
|
|
85
|
+
json=_payload(allocation_source="default", cpu_millicores=2000),
|
|
86
|
+
headers=INGEST,
|
|
87
|
+
)
|
|
88
|
+
client.post(
|
|
89
|
+
"/ingest/compute",
|
|
90
|
+
json=_payload(occurred_at="2026-10-01T12:05:00Z"),
|
|
91
|
+
headers=INGEST,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
response = client.get("/query/session/sandbox-session", headers=OPERATOR)
|
|
95
|
+
|
|
96
|
+
assert response.status_code == 200
|
|
97
|
+
body = response.json()
|
|
98
|
+
assert body["coverage"]["compute_allocations"] == {
|
|
99
|
+
"total": 2,
|
|
100
|
+
"visible": 2,
|
|
101
|
+
"quarantined": 0,
|
|
102
|
+
}
|
|
103
|
+
# Installed `sediment doctor` copies reject unknown timeline event types.
|
|
104
|
+
assert body["timeline"] == []
|
|
105
|
+
assert [
|
|
106
|
+
(item["allocation_source"], item["cpu_millicores"])
|
|
107
|
+
for item in body["compute_allocations"]
|
|
108
|
+
] == [("default", 2000), ("agent_request", 8000)]
|
|
109
|
+
assert all(
|
|
110
|
+
item["memory_bytes"] == 16 * 2**30 for item in body["compute_allocations"]
|
|
111
|
+
)
|
|
112
|
+
assert "no_inference_calls_observed" in body["gaps"]
|
|
113
|
+
|
|
114
|
+
first = body["compute_allocations"][0]["allocation_id"]
|
|
115
|
+
app.state.fact_store.quarantine_fact(
|
|
116
|
+
settings.org_id, FactTable.COMPUTE_ALLOCATIONS, first, reason="test"
|
|
117
|
+
)
|
|
118
|
+
quarantined = client.get("/query/session/sandbox-session", headers=OPERATOR)
|
|
119
|
+
body = quarantined.json()
|
|
120
|
+
# A quarantined allocation never marks decision evidence incomplete.
|
|
121
|
+
assert body["omitted_events"] == 0
|
|
122
|
+
assert body["coverage"]["compute_allocations"]["quarantined"] == 1
|
|
123
|
+
assert [item["allocation_id"] for item in body["compute_allocations"]] != [first]
|
|
124
|
+
assert len(body["compute_allocations"]) == 1
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Operator rendering for the compute report, over a real Fact store."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
|
|
9
|
+
from sediment_api.reports import compute_report as cli
|
|
10
|
+
from sediment_core import (
|
|
11
|
+
ComputeAllocation,
|
|
12
|
+
ComputeAllocationSource,
|
|
13
|
+
GatewayProvider,
|
|
14
|
+
InferenceCall,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
T0 = datetime(2026, 10, 1, 12, 0, tzinfo=UTC)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _seed(store) -> None:
|
|
21
|
+
for index, (cpu, work_s) in enumerate(
|
|
22
|
+
[(2000, 60), (2000, 70), (2000, 80), (8000, 20), (8000, 30), (8000, 40)]
|
|
23
|
+
):
|
|
24
|
+
session_id = f"session-{index}"
|
|
25
|
+
store.store_compute_allocation(
|
|
26
|
+
ComputeAllocation(
|
|
27
|
+
org_id="acme",
|
|
28
|
+
session_id=session_id,
|
|
29
|
+
allocation_source=ComputeAllocationSource.RANDOMIZED,
|
|
30
|
+
cpu_millicores=cpu,
|
|
31
|
+
memory_bytes=None,
|
|
32
|
+
occurred_at=T0 - timedelta(minutes=1),
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
for call, end_s in enumerate((5, 10 + work_s)):
|
|
36
|
+
store.store_inference_call(
|
|
37
|
+
InferenceCall(
|
|
38
|
+
inference_call_id=f"{session_id}-{call}",
|
|
39
|
+
org_id="acme",
|
|
40
|
+
session_id=session_id,
|
|
41
|
+
gateway_provider=GatewayProvider.LITELLM,
|
|
42
|
+
input_messages=[],
|
|
43
|
+
output_messages=[],
|
|
44
|
+
duration_ms=5_000,
|
|
45
|
+
observed_at=T0 + timedelta(seconds=end_s),
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
store.store_compute_allocation(
|
|
49
|
+
ComputeAllocation(
|
|
50
|
+
org_id="acme",
|
|
51
|
+
session_id="never-called",
|
|
52
|
+
allocation_source=ComputeAllocationSource.AGENT_REQUEST,
|
|
53
|
+
cpu_millicores=16000,
|
|
54
|
+
occurred_at=T0,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_compute_report_table_names_arms_comparisons_and_skips(
|
|
60
|
+
postgres_store, postgres_database_url, capsys
|
|
61
|
+
) -> None:
|
|
62
|
+
_seed(postgres_store)
|
|
63
|
+
|
|
64
|
+
assert cli.main(["--org", "acme", "--database-url", postgres_database_url]) == 0
|
|
65
|
+
|
|
66
|
+
output = capsys.readouterr().out
|
|
67
|
+
assert "Sessions with a Compute allocation: 7" in output
|
|
68
|
+
assert "timed Sessions: 6" in output
|
|
69
|
+
assert "no_inference_calls=1" in output
|
|
70
|
+
assert "randomized Sessions reassigned to another source: 0" in output
|
|
71
|
+
assert "randomized cpu=2000m memory=unknown: n=3" in output
|
|
72
|
+
assert "between_calls_ms median=70.0s" in output
|
|
73
|
+
assert (
|
|
74
|
+
"randomized cpu=2000m memory=unknown -> randomized cpu=8000m "
|
|
75
|
+
"memory=unknown (randomized)"
|
|
76
|
+
) in output
|
|
77
|
+
assert " elapsed_ms: -40.0s [95% CI" in output
|
|
78
|
+
assert "small-n" in output
|
|
79
|
+
assert "policy_version=1 quarantine_revision=0" in output
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_compute_report_json_and_rows_out(
|
|
83
|
+
postgres_store, postgres_database_url, tmp_path, capsys
|
|
84
|
+
) -> None:
|
|
85
|
+
_seed(postgres_store)
|
|
86
|
+
destination = tmp_path / "sessions.jsonl"
|
|
87
|
+
|
|
88
|
+
assert (
|
|
89
|
+
cli.main(
|
|
90
|
+
[
|
|
91
|
+
"--org",
|
|
92
|
+
"acme",
|
|
93
|
+
"--database-url",
|
|
94
|
+
postgres_database_url,
|
|
95
|
+
"--json",
|
|
96
|
+
"--rows-out",
|
|
97
|
+
str(destination),
|
|
98
|
+
]
|
|
99
|
+
)
|
|
100
|
+
== 0
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
output = capsys.readouterr()
|
|
104
|
+
report = json.loads(output.out)
|
|
105
|
+
assert report["timed_sessions"] == 6
|
|
106
|
+
assert report["skipped"]["no_inference_calls"] == 1
|
|
107
|
+
assert report["randomized_reassigned_sessions"] == 0
|
|
108
|
+
assert report["comparisons"][0]["randomized"] is True
|
|
109
|
+
assert "wrote 6 compute timing rows" in output.err
|
|
110
|
+
rows = [json.loads(line) for line in destination.read_text().splitlines()]
|
|
111
|
+
assert [row["session_id"] for row in rows] == [f"session-{i}" for i in range(6)]
|
|
112
|
+
assert rows[0]["elapsed_ms"] == 70_000
|
|
@@ -10,7 +10,7 @@ from pydantic import SecretStr
|
|
|
10
10
|
from sediment_api.config import Settings
|
|
11
11
|
from sediment_api.main import app
|
|
12
12
|
|
|
13
|
-
INGEST = {"/ingest/gateway", "/ingest/ci", "/v1/logs"}
|
|
13
|
+
INGEST = {"/ingest/gateway", "/ingest/ci", "/ingest/compute", "/v1/logs"}
|
|
14
14
|
BEARER_ROUTES = [
|
|
15
15
|
(method, path)
|
|
16
16
|
for path, item in app.openapi()["paths"].items()
|
|
@@ -101,7 +101,7 @@ def test_me_happy_path(client: TestClient) -> None:
|
|
|
101
101
|
assert resp.status_code == 200
|
|
102
102
|
assert resp.json() == {
|
|
103
103
|
"org_id": ORG,
|
|
104
|
-
"version": "0.
|
|
104
|
+
"version": "0.7.0",
|
|
105
105
|
"authority": "operator",
|
|
106
106
|
"client_id": "operator",
|
|
107
107
|
}
|
|
@@ -184,6 +184,7 @@ def test_session_facts_counts_only_the_named_session(client: TestClient) -> None
|
|
|
184
184
|
"edit_observations": {"total": 0, "visible": 0},
|
|
185
185
|
"rejected_edits": {"total": 0, "visible": 0},
|
|
186
186
|
"retry_linkages": {"total": 0, "visible": 0},
|
|
187
|
+
"compute_allocations": {"total": 0, "visible": 0},
|
|
187
188
|
},
|
|
188
189
|
}
|
|
189
190
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sediment_api-0.6.1 → sediment_api-0.7.0}/sediment_api/reports/label_confidence_inspection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|