sediment-api 0.1.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.
@@ -0,0 +1,182 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Attribution ground-truth precision report.
3
+
4
+ Loads a JSONL ground-truth manifest, derives one org's attributions, and
5
+ prints notes-attributed and jaccard-attributed precision/recall separately.
6
+ The reusable schema and scoring logic live in
7
+ ``sediment_derive.precision_report`` beside the scorer precision harness; this
8
+ script is only the operator-facing stdout wrapper.
9
+
10
+ sediment report precision --org acme-corp \
11
+ --manifest packages/derive/tests/fixtures/ground_truth/illustrative_manifest.jsonl
12
+ sediment report precision --org acme-corp --manifest gt.jsonl --json
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+ from dataclasses import asdict
22
+
23
+ from sediment_core import normalize_org_id
24
+ from sediment_derive import MirrorManager
25
+ from sediment_derive.precision_report import (
26
+ DEFAULT_SWEEP_THRESHOLDS,
27
+ DEFAULT_THRESHOLD,
28
+ AttributionPrecisionReport,
29
+ generate_precision_report_for_org,
30
+ load_ground_truth_manifest,
31
+ )
32
+
33
+ from ..database import add_database_url_argument, one_shot_fact_store
34
+
35
+ _DEFAULT_MIRROR_PATH = "./mirrors"
36
+
37
+
38
+ def _parse_thresholds(raw: str) -> list[float]:
39
+ try:
40
+ thresholds = [float(part.strip()) for part in raw.split(",") if part.strip()]
41
+ except ValueError as exc:
42
+ raise argparse.ArgumentTypeError(
43
+ "thresholds must be comma-separated numbers"
44
+ ) from exc
45
+ if not thresholds:
46
+ raise argparse.ArgumentTypeError("at least one threshold is required")
47
+ for threshold in thresholds:
48
+ if threshold < 0.0 or threshold > 1.0:
49
+ raise argparse.ArgumentTypeError("thresholds must be between 0.0 and 1.0")
50
+ return thresholds
51
+
52
+
53
+ def _result_dict(report: AttributionPrecisionReport) -> dict[str, object]:
54
+ return {
55
+ "threshold": report.threshold,
56
+ "by_source": {
57
+ source.value: _source_dict(source_report)
58
+ for source, source_report in report.by_source.items()
59
+ },
60
+ }
61
+
62
+
63
+ def _source_dict(report: AttributionPrecisionReport) -> dict[str, object]:
64
+ return {
65
+ "default": asdict(report.default),
66
+ "sweep": [asdict(row) for row in report.sweep],
67
+ "skipped_unlabelled_predictions": report.skipped_unlabelled_predictions,
68
+ "skipped_repository_ground_truth": report.skipped_repository_ground_truth,
69
+ "skipped_repository_predictions": report.skipped_repository_predictions,
70
+ }
71
+
72
+
73
+ def _print_table(report: AttributionPrecisionReport) -> None:
74
+ print(f"default_threshold: {report.threshold:.2f}")
75
+ for source, source_report in report.by_source.items():
76
+ row = source_report.default
77
+ print(f"{source.value}:")
78
+ print(
79
+ " "
80
+ f"precision={row.precision:.4f} recall={row.recall:.4f} "
81
+ f"tp={row.true_positives} fp={row.false_positives} "
82
+ f"fn={row.false_negatives} tn={row.true_negatives}"
83
+ )
84
+ print(
85
+ " "
86
+ f"skipped_unlabelled_predictions="
87
+ f"{source_report.skipped_unlabelled_predictions}"
88
+ )
89
+ print(
90
+ f" skipped_repository_ground_truth={source_report.skipped_repository_ground_truth} "
91
+ f"skipped_repository_predictions={source_report.skipped_repository_predictions}"
92
+ )
93
+ print(" sweep:")
94
+ for sweep_row in source_report.sweep:
95
+ print(
96
+ " "
97
+ f"threshold={sweep_row.threshold:.2f} "
98
+ f"precision={sweep_row.precision:.4f} "
99
+ f"recall={sweep_row.recall:.4f} "
100
+ f"tp={sweep_row.true_positives} "
101
+ f"fp={sweep_row.false_positives} "
102
+ f"fn={sweep_row.false_negatives} "
103
+ f"tn={sweep_row.true_negatives}"
104
+ )
105
+
106
+
107
+ def build_parser() -> argparse.ArgumentParser:
108
+ """The argv contract for this report, extracted so the generated
109
+ CLI reference can walk it."""
110
+ parser = argparse.ArgumentParser(
111
+ prog="precision_report",
112
+ description="Score derived attributions against a ground-truth manifest.",
113
+ )
114
+ parser.add_argument(
115
+ "--org",
116
+ default=os.environ.get("SEDIMENT_ORG_ID"),
117
+ required=not os.environ.get("SEDIMENT_ORG_ID"),
118
+ help="org id (default: $SEDIMENT_ORG_ID)",
119
+ )
120
+ parser.add_argument("--manifest", required=True, help="JSONL ground-truth manifest")
121
+ parser.add_argument(
122
+ "--threshold",
123
+ type=float,
124
+ default=DEFAULT_THRESHOLD,
125
+ help=f"default scoring threshold (default: {DEFAULT_THRESHOLD})",
126
+ )
127
+ parser.add_argument(
128
+ "--sweep-thresholds",
129
+ type=_parse_thresholds,
130
+ default=list(DEFAULT_SWEEP_THRESHOLDS),
131
+ help="comma-separated sweep thresholds (default: 0.1,0.2,...,0.9)",
132
+ )
133
+ parser.add_argument(
134
+ "--json", action="store_true", help="emit JSON instead of a text report"
135
+ )
136
+ add_database_url_argument(parser)
137
+ parser.add_argument(
138
+ "--mirror-path",
139
+ default=os.environ.get("SEDIMENT_MIRROR_PATH", _DEFAULT_MIRROR_PATH),
140
+ help="base dir for git mirrors (default: $SEDIMENT_MIRROR_PATH or "
141
+ f"{_DEFAULT_MIRROR_PATH!r})",
142
+ )
143
+ return parser
144
+
145
+
146
+ def main(argv: list[str] | None = None) -> int:
147
+ args = build_parser().parse_args(argv)
148
+
149
+ if not 0.0 <= args.threshold <= 1.0:
150
+ print(
151
+ "error: --threshold must be between 0.0 and 1.0",
152
+ file=sys.stderr,
153
+ )
154
+ return 2
155
+ try:
156
+ org_id = normalize_org_id(args.org)
157
+ manifest = load_ground_truth_manifest(args.manifest)
158
+ except ValueError as exc:
159
+ print(f"error: {exc}", file=sys.stderr)
160
+ return 2
161
+
162
+ with one_shot_fact_store(
163
+ args.database_url, operation="generate precision report"
164
+ ) as store:
165
+ report = generate_precision_report_for_org(
166
+ store,
167
+ MirrorManager(args.mirror_path),
168
+ org_id,
169
+ manifest,
170
+ threshold=args.threshold,
171
+ sweep_threshold_values=args.sweep_thresholds,
172
+ )
173
+
174
+ if args.json:
175
+ print(json.dumps(_result_dict(report), indent=2))
176
+ else:
177
+ _print_table(report)
178
+ return 0
179
+
180
+
181
+ if __name__ == "__main__":
182
+ raise SystemExit(main())
@@ -0,0 +1,132 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Recovery-pair yield report: failed CI runs, emitted pairs, skips.
3
+
4
+ Prints ``sediment_export.generate_recovery_yield_report`` for one org: a
5
+ compact stdout summary by default, or ``--json`` for machines.
6
+
7
+ sediment report recovery-yield --org acme-corp
8
+ sediment report recovery-yield --org acme-corp --json
9
+
10
+ Storage is read the same way ``model_report.py`` reads it:
11
+ ``--database-url``/``SEDIMENT_DATABASE_URL`` and ``--mirror-path``/
12
+ ``SEDIMENT_MIRROR_PATH``. A missing mirror path is not fatal; unmirrored repos
13
+ are counted in the recovery derivation's ``mirror_absent`` skip tally.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import sys
22
+ from dataclasses import asdict
23
+
24
+ from sediment_core import normalize_org_id
25
+ from sediment_derive import MirrorManager
26
+ from sediment_export import (
27
+ DiffSizeStats,
28
+ RecoveryYieldReport,
29
+ generate_recovery_yield_report,
30
+ )
31
+
32
+ from ..database import add_database_url_argument, one_shot_fact_store
33
+
34
+ _DEFAULT_MIRROR_PATH = "./mirrors"
35
+
36
+
37
+ def _print_table(row: RecoveryYieldReport) -> None:
38
+ print(f"failed_ci_runs: {row.total_failed_ci_runs}")
39
+ print(f"recovery_pairs: {row.recovery_pairs}")
40
+ print(f"recovery_yield: {row.recovery_yield_rate:.1%}")
41
+ print("skipped:")
42
+ if not row.skipped:
43
+ print(" none")
44
+ else:
45
+ for reason, count in sorted(row.skipped.items()):
46
+ print(f" {reason}: {count}")
47
+ distribution = row.diff_size_distribution
48
+ print("diff_size_distribution:")
49
+ print(f" max_recovery_diff_lines: {distribution.max_recovery_diff_lines}")
50
+ print(" kept:")
51
+ _print_diff_size_stats(distribution.kept)
52
+ print(" dropped:")
53
+ _print_diff_size_stats(distribution.dropped)
54
+
55
+
56
+ def _print_diff_size_stats(stats: DiffSizeStats) -> None:
57
+ print(f" count: {stats.count}")
58
+ print(f" min: {stats.min}")
59
+ print(f" median: {stats.median}")
60
+ print(f" p90: {stats.p90}")
61
+ print(f" max: {stats.max}")
62
+ print(" histogram:")
63
+ if not stats.histogram:
64
+ print(" none")
65
+ return
66
+ for bucket, count in stats.histogram.items():
67
+ print(f" {bucket}: {count}")
68
+
69
+
70
+ def _row_dict(row: RecoveryYieldReport) -> dict:
71
+ return {
72
+ "total_failed_ci_runs": row.total_failed_ci_runs,
73
+ "recovery_pairs": row.recovery_pairs,
74
+ "recovery_yield_rate": row.recovery_yield_rate,
75
+ "skipped": dict(row.skipped),
76
+ "diff_size_distribution": asdict(row.diff_size_distribution),
77
+ }
78
+
79
+
80
+ def build_parser() -> argparse.ArgumentParser:
81
+ """The argv contract for this report, extracted so the generated
82
+ CLI reference can walk it."""
83
+ parser = argparse.ArgumentParser(
84
+ prog="recovery_yield_report", description=__doc__.splitlines()[0]
85
+ )
86
+ parser.add_argument(
87
+ "--org",
88
+ default=os.environ.get("SEDIMENT_ORG_ID"),
89
+ required=not os.environ.get("SEDIMENT_ORG_ID"),
90
+ help="org id (default: $SEDIMENT_ORG_ID)",
91
+ )
92
+ parser.add_argument(
93
+ "--json", action="store_true", help="emit a JSON row instead of a table"
94
+ )
95
+ add_database_url_argument(parser)
96
+ parser.add_argument(
97
+ "--mirror-path",
98
+ default=os.environ.get("SEDIMENT_MIRROR_PATH", _DEFAULT_MIRROR_PATH),
99
+ help="base dir for git mirrors (default: $SEDIMENT_MIRROR_PATH or "
100
+ f"{_DEFAULT_MIRROR_PATH!r})",
101
+ )
102
+ return parser
103
+
104
+
105
+ def main(argv: list[str] | None = None) -> int:
106
+ args = build_parser().parse_args(argv)
107
+
108
+ try:
109
+ org_id = normalize_org_id(args.org)
110
+ except ValueError as exc:
111
+ print(f"error: {exc}", file=sys.stderr)
112
+ return 2
113
+
114
+ with one_shot_fact_store(
115
+ args.database_url, operation="generate recovery-yield report"
116
+ ) as store:
117
+ row = generate_recovery_yield_report(
118
+ store,
119
+ MirrorManager(args.mirror_path),
120
+ org_id,
121
+ )
122
+
123
+ if args.json:
124
+ print(json.dumps(_row_dict(row), indent=2))
125
+ else:
126
+ print(f"org: {org_id}")
127
+ _print_table(row)
128
+ return 0
129
+
130
+
131
+ if __name__ == "__main__":
132
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Ingest routers — one module per door. Routers write facts only: no
3
+ attribution, no derivation at ingest (ADR 0001)."""
@@ -0,0 +1,149 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """
3
+ POST /ingest/ci — vendor-neutral CI ingest. The GitHub webhook door lives
4
+ at /ingest/github/ci, so the plain name belongs to the door any CI can
5
+ call.
6
+
7
+ One authenticated bearer POST any CI pipeline can ``curl`` to store a
8
+ ``CIOutcome`` fact. The caller normalizes its own CI system into a
9
+ ``CIProvider`` value and the known ``CIResult`` vocabulary; the server
10
+ validates at the trust boundary and stores — no per-vendor parsing, no
11
+ new fact shape.
12
+
13
+ Dedup: the store's ``uq_ci_run`` UNIQUE index on provider run id and attempt
14
+ collapses redeliveries; ``stored: false`` means success, not an error.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ from fastapi import APIRouter, Depends
22
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
23
+ from sediment_core import (
24
+ BranchName,
25
+ CIOutcome,
26
+ CIReason,
27
+ CIProvider,
28
+ CIResult,
29
+ CommitSha,
30
+ FactStore,
31
+ ForgeProvider,
32
+ ForgeHost,
33
+ ProviderRepositoryId,
34
+ NonEmptyId,
35
+ NonEmptyContent,
36
+ RepoSlug,
37
+ WorkflowName,
38
+ WorkflowPath,
39
+ )
40
+
41
+ from ..config import settings
42
+ from ..deps import get_store, verify_ingest_token
43
+
44
+ router = APIRouter(tags=["ci-vendor"])
45
+
46
+
47
+ class VendorCIRequest(BaseModel):
48
+ """The vendor-neutral CI outcome payload — callers normalize their own
49
+ CI system into this shape. ``extra=\"forbid\"`` so a caller naming
50
+ an org or fabricating fields is rejected at the boundary.
51
+
52
+ The identity fields carry the shared core definitions on
53
+ the envelope, so malformed input 422s at the door rather than
54
+ surfacing as a ValidationError-turned-500 inside the handler:
55
+ ``CommitSha`` (40- and 64-char object names — a SHA-256 repo can use
56
+ this door), ``RepoSlug`` (required — a vendor fact without a repo has
57
+ no CI-attach join), ``BranchName`` (``refs/heads/x`` normalizes to
58
+ ``x``, one recovery lineage bucket per pipeline), and a required
59
+ non-empty ``run_id``. ``run_url`` is optional location metadata."""
60
+
61
+ model_config = ConfigDict(extra="forbid")
62
+
63
+ provider: CIProvider = Field(
64
+ description=(
65
+ "The normalized CI system. The sender declares it from integration "
66
+ "configuration; Sediment does not infer it from a URL"
67
+ )
68
+ )
69
+ run_id: NonEmptyId = Field(
70
+ description=(
71
+ "The provider-issued pipeline-run id, unique within the deployment "
72
+ "organization and provider namespace"
73
+ )
74
+ )
75
+ run_attempt: int | None = Field(default=None, ge=1, le=2**63 - 1, strict=True)
76
+ repo: RepoSlug
77
+ repository_provider: ForgeProvider | None = None
78
+ repository_host: ForgeHost | None = None
79
+ repository_id: ProviderRepositoryId | None = None
80
+ commit_sha: CommitSha
81
+ branch: BranchName
82
+ # Workflow names preserve descriptive content; paths identify definitions.
83
+ workflow_name: WorkflowName = ""
84
+ workflow_id: NonEmptyId | None = None
85
+ workflow_path: WorkflowPath | None = None
86
+ result: CIResult
87
+ run_url: NonEmptyContent | None = None
88
+ provider_result: NonEmptyContent | None = None
89
+ error_type: NonEmptyContent | None = None
90
+ reason: CIReason | None = None
91
+ source_event_type: NonEmptyContent | None = None
92
+ source_spec_version: NonEmptyContent | None = None
93
+ source_event_id: NonEmptyId | None = None
94
+
95
+ @model_validator(mode="after")
96
+ def _complete_repository_identity(self):
97
+ present = tuple(
98
+ value is not None
99
+ for value in (
100
+ self.repository_provider,
101
+ self.repository_host,
102
+ self.repository_id,
103
+ )
104
+ )
105
+ if any(present) and not all(present):
106
+ raise ValueError("repository identity must be wholly present or absent")
107
+ return self
108
+
109
+ @field_validator("repo")
110
+ @classmethod
111
+ def _repo_required(cls, v: str) -> str:
112
+ # RepoSlug admits "" as the webhook parsers' absent sentinel; a
113
+ # vendor caller controls its own pipeline and must name the repo.
114
+ if not v:
115
+ raise ValueError("repo is required (owner/repo)")
116
+ return v
117
+
118
+
119
+ @router.post("/ci")
120
+ def ingest_ci_vendor(
121
+ body: VendorCIRequest,
122
+ _: None = Depends(verify_ingest_token),
123
+ store: FactStore = Depends(get_store),
124
+ ) -> dict[str, Any]:
125
+ outcome = CIOutcome(
126
+ org_id=settings.org_id,
127
+ provider=body.provider,
128
+ run_id=body.run_id,
129
+ run_attempt=body.run_attempt,
130
+ repo=body.repo,
131
+ repository_provider=body.repository_provider,
132
+ repository_host=body.repository_host,
133
+ repository_id=body.repository_id,
134
+ commit_sha=body.commit_sha,
135
+ branch=body.branch,
136
+ result=body.result,
137
+ workflow_name=body.workflow_name,
138
+ workflow_id=body.workflow_id,
139
+ workflow_path=body.workflow_path or None,
140
+ run_url=body.run_url,
141
+ provider_result=body.provider_result,
142
+ error_type=body.error_type,
143
+ reason=body.reason,
144
+ source_event_type=body.source_event_type,
145
+ source_spec_version=body.source_spec_version,
146
+ source_event_id=body.source_event_id,
147
+ )
148
+ receipt = store.store_ci_outcome_receipt(outcome)
149
+ return {"fact_id": receipt.fact_id, "stored": receipt.stored}