sediment-api 0.6.0__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.
Files changed (76) hide show
  1. {sediment_api-0.6.0 → sediment_api-0.7.0}/PKG-INFO +7 -7
  2. {sediment_api-0.6.0 → sediment_api-0.7.0}/pyproject.toml +7 -7
  3. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/__init__.py +1 -1
  4. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/main.py +2 -1
  5. sediment_api-0.7.0/sediment_api/reports/compute_report.py +157 -0
  6. sediment_api-0.7.0/sediment_api/routers/compute.py +102 -0
  7. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/routers/query.py +43 -1
  8. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/routers/v1.py +1 -0
  9. sediment_api-0.7.0/tests/test_compute_ingest.py +124 -0
  10. sediment_api-0.7.0/tests/test_compute_report_cli.py +112 -0
  11. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_credential_authorities.py +1 -1
  12. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_v1.py +2 -1
  13. {sediment_api-0.6.0 → sediment_api-0.7.0}/.gitignore +0 -0
  14. {sediment_api-0.6.0 → sediment_api-0.7.0}/LICENSE +0 -0
  15. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/config.py +0 -0
  16. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/database.py +0 -0
  17. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/deps.py +0 -0
  18. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/mirror_gc.py +0 -0
  19. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/reports/__init__.py +0 -0
  20. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/reports/abandonment_report.py +0 -0
  21. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/reports/attribution_share_report.py +0 -0
  22. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/reports/dataset_diagnostics.py +0 -0
  23. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/reports/label_confidence_inspection.py +0 -0
  24. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/reports/lifecycle_report.py +0 -0
  25. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/reports/merge_retention_report.py +0 -0
  26. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/reports/model_report.py +0 -0
  27. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/reports/precision_report.py +0 -0
  28. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/reports/recovery_yield_report.py +0 -0
  29. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/routers/__init__.py +0 -0
  30. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/routers/ci_vendor.py +0 -0
  31. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/routers/forge.py +0 -0
  32. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/routers/gateway.py +0 -0
  33. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/routers/otlp.py +0 -0
  34. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/routers/reports.py +0 -0
  35. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/services/__init__.py +0 -0
  36. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/services/operational_reports.py +0 -0
  37. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/worker.py +0 -0
  38. {sediment_api-0.6.0 → sediment_api-0.7.0}/sediment_api/workers.py +0 -0
  39. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/conftest.py +0 -0
  40. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_attribution_share_report.py +0 -0
  41. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_baseline_gap_repro.py +0 -0
  42. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_bug_repro_offset.py +0 -0
  43. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_capture_record_isolation.py +0 -0
  44. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_ci_vendor.py +0 -0
  45. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_config.py +0 -0
  46. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_context_discovery_authority.py +0 -0
  47. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_context_discovery_query.py +0 -0
  48. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_context_discovery_workers.py +0 -0
  49. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_context_evidence_query.py +0 -0
  50. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_context_query.py +0 -0
  51. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_database_lifecycle.py +0 -0
  52. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_database_outage.py +0 -0
  53. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_database_role_startup.py +0 -0
  54. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_dataset_diagnostics_report.py +0 -0
  55. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_e2e_smoke.py +0 -0
  56. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_evidence_query.py +0 -0
  57. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_evidence_worker_imports.py +0 -0
  58. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_evidence_workers.py +0 -0
  59. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_gateway_capture_receipt.py +0 -0
  60. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_gateway_identity.py +0 -0
  61. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_lifecycle_report_cli.py +0 -0
  62. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_merge_retention_cli.py +0 -0
  63. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_one_shot_database.py +0 -0
  64. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_operational_reports.py +0 -0
  65. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_operational_reports_service.py +0 -0
  66. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_postgres_gateway_tracer.py +0 -0
  67. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_push_mirror.py +0 -0
  68. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_query.py +0 -0
  69. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_report_attachment_scope.py +0 -0
  70. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_report_routes.py +0 -0
  71. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_repository_identity_ingest.py +0 -0
  72. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_repository_identity_model_service.py +0 -0
  73. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_repository_identity_queries.py +0 -0
  74. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_retrieval_authority.py +0 -0
  75. {sediment_api-0.6.0 → sediment_api-0.7.0}/tests/test_worker_processes.py +0 -0
  76. {sediment_api-0.6.0 → 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.6.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
@@ -10,11 +10,11 @@ License-Expression: AGPL-3.0-or-later
10
10
  License-File: LICENSE
11
11
  Requires-Python: >=3.12
12
12
  Requires-Dist: anyio>=4.15.1
13
- Requires-Dist: fastapi>=0.141.1
13
+ Requires-Dist: fastapi>=0.142.2
14
14
  Requires-Dist: httpx>=0.27
15
- Requires-Dist: pydantic-settings>=2.15.0
16
- Requires-Dist: sediment-capture==0.6.0
17
- Requires-Dist: sediment-core==0.6.0
18
- Requires-Dist: sediment-derive==0.6.0
19
- Requires-Dist: sediment-export==0.6.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.6.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"
@@ -8,14 +8,14 @@ license-files = ["LICENSE"]
8
8
  dependencies = [
9
9
  # Security floor for HTTPX/Starlette, including installations from wheels.
10
10
  "anyio>=4.15.1",
11
- "fastapi>=0.141.1",
11
+ "fastapi>=0.142.2",
12
12
  "httpx>=0.27",
13
13
  "uvicorn>=0.54.0",
14
- "pydantic-settings>=2.15.0",
15
- "sediment-core==0.6.0",
16
- "sediment-capture==0.6.0",
17
- "sediment-derive==0.6.0",
18
- "sediment-export==0.6.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]
@@ -3,4 +3,4 @@
3
3
 
4
4
  # Single source for the API version string — FastAPI's ``app.version``
5
5
  # (reported by /health) and GET /v1/me both read it, so the two cannot drift.
6
- __version__ = "0.6.0"
6
+ __version__ = "0.7.0"
@@ -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 for coverage in dossier.coverage.values()
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),
@@ -35,6 +35,7 @@ _SESSION_FACT_TABLES = (
35
35
  FactTable.EDIT_OBSERVATIONS,
36
36
  FactTable.REJECTED_EDITS,
37
37
  FactTable.RETRY_LINKAGES,
38
+ FactTable.COMPUTE_ALLOCATIONS,
38
39
  )
39
40
 
40
41
 
@@ -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.6.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