diff --git a/.agents/skills/icm-cctv-ops/SKILL.md b/.agents/skills/icm-cctv-ops/SKILL.md new file mode 100644 index 000000000..6be221ffb --- /dev/null +++ b/.agents/skills/icm-cctv-ops/SKILL.md @@ -0,0 +1,16 @@ +--- +name: icm-cctv-ops +description: ICM-CCTV visualization projection for ops dashboards. Load on Stepie 2087 step 10023. +--- + +# Skill: icm-cctv-ops + +Emit lane counts + PR rows as JSON. No secrets. Do not reorder primary Stepie goal 2149. + +```text +python3 -m ml.pipelines.cli cctv +``` + +Contract: `docs/ops/ICM-CCTV.md` +Session 2026-09-25 14:10 PDT. Live master `a3423d97`. +Agent-Identity: Grok (Administrator) diff --git a/.agents/skills/icm-cctv-ops/references/schema.md b/.agents/skills/icm-cctv-ops/references/schema.md new file mode 100644 index 000000000..e557cc9c0 --- /dev/null +++ b/.agents/skills/icm-cctv-ops/references/schema.md @@ -0,0 +1 @@ +Schema: `docs/schemas/icm-cctv.json`. Producer: `ml.pipelines.viz.cctv`. diff --git a/.agents/skills/ml-pipeline-ops/SKILL.md b/.agents/skills/ml-pipeline-ops/SKILL.md new file mode 100644 index 000000000..b064fd6e7 --- /dev/null +++ b/.agents/skills/ml-pipeline-ops/SKILL.md @@ -0,0 +1,40 @@ +--- +name: ml-pipeline-ops +description: Keep-alive ML pipeline DAG for termux-monorepo. Load on Issue #175 ML lanes. Never wholesale-merge mega ML PRs. +--- + +# Skill: ml-pipeline-ops + +Session 2026-09-25 14:10 PDT. Live master `a3423d97` after #836 (lane vocab v2). +Keep-alive child rebases here; #817 / #787 / #746 / #682 SUPERSEDE-or-EXTRACT. + +## Hard rules + +1. Extract-only for mega ML PRs. Slim tree is `ml/pipelines/`. +2. Dual gates before promote: hygiene+portability + agentic termux smoke. Bind the named jobs, not combined status. +3. No secrets, no Class 3/4 artifacts, no GPU runners. +4. GitLab / Vercel hobby rate-limit are **non-gate** (#772). +5. Dirty + files>40 + minesweeper overlap → EXTRACT, never wholesale. +6. `master_staging_base` (#48, #788) never retarget to master. +7. Do **not** restamp `docs/ops/LANE-MATRIX.md` (policy SSOT). Live board is generated. +8. Do **not** pulse-comment Issue #175. Edit the issue body only when intent changes. +9. Lane vocab v2: EXTRACT | CANDIDATE | NEED_EVIDENCE | SUPERSEDE. HOLD/WAIT/OBSERVE are invalid parking. + +## Commands + +```text +python3 -m ml.pipelines.cli status +python3 -m ml.pipelines.cli lanes +python3 -m ml.pipelines.cli run +python3 -m ml.pipelines.cli cctv +python3 -m ml.pipelines.cli matrix +python3 -m ml.pipelines.cli explain 682 +python3 -m ml.pipelines.cli gate 48 +python3 -m unittest discover -s ml/pipelines -p 'test_*.py' +``` + +## Cycle + +RECON → INGEST → FEATURES → TRAIN → EVALUATE → (EXTRACT|CANDIDATE|NEED_EVIDENCE|SUPERSEDE) → MONITOR + +Operator ACTIVE. Agent-Identity: Grok (Administrator) diff --git a/.agents/skills/ml-pipeline-ops/references/ates.md b/.agents/skills/ml-pipeline-ops/references/ates.md new file mode 100644 index 000000000..311910bc4 --- /dev/null +++ b/.agents/skills/ml-pipeline-ops/references/ates.md @@ -0,0 +1,3 @@ +# ATES + +#841 promoted runtime evidence as the measurement spine. Keep-alive observes; it does not replace ATES. diff --git a/.agents/skills/ml-pipeline-ops/references/dual-gate.md b/.agents/skills/ml-pipeline-ops/references/dual-gate.md new file mode 100644 index 000000000..666e6cfeb --- /dev/null +++ b/.agents/skills/ml-pipeline-ops/references/dual-gate.md @@ -0,0 +1,8 @@ +# Dual-gate (promote authority) + +1. `hygiene + portability gate` / `repo gate` SUCCESS on **this** SHA +2. `agentic termux smoke` / `termux smoke` SUCCESS on **this** SHA +3. Vercel rate-limits are **non-gate** (#772) +4. Copilot / CodeRabbit / Qodo / Devin = advisory only +5. Dual-gate SUCCESS on an older head does not authorize a newer SHA +6. Combined commit status is not dual-gate; bind the named jobs diff --git a/.agents/skills/ml-pipeline-ops/references/minesweeper.md b/.agents/skills/ml-pipeline-ops/references/minesweeper.md new file mode 100644 index 000000000..b1783fe94 --- /dev/null +++ b/.agents/skills/ml-pipeline-ops/references/minesweeper.md @@ -0,0 +1,7 @@ +# Minesweeper + +Concurrent agents (Jules, Sentinel, Bolt, Devin, Copilot) open overlapping PRs. + +- Do not overwrite a peer's branch. +- Bot author + files>40 → EXTRACT, never wholesale merge. +- Classify with `ml.pipelines.lanes.minesweeper_rules`. diff --git a/.agents/skills/ml-pipeline-ops/references/promote.md b/.agents/skills/ml-pipeline-ops/references/promote.md new file mode 100644 index 000000000..72ab34173 --- /dev/null +++ b/.agents/skills/ml-pipeline-ops/references/promote.md @@ -0,0 +1,4 @@ +# Promote + +ALLOW only for CANDIDATE + dual-gate SUCCESS + mergeable (not dirty). +EXTRACT never auto-promotes. NEED_EVIDENCE waits for rebase/checks. diff --git a/.agents/skills/ml-pipeline-ops/references/vocab.md b/.agents/skills/ml-pipeline-ops/references/vocab.md new file mode 100644 index 000000000..e31158e60 --- /dev/null +++ b/.agents/skills/ml-pipeline-ops/references/vocab.md @@ -0,0 +1,10 @@ +# Lane vocabulary v2 + +| Lane | Meaning | +|------|---------| +| EXTRACT | Re-cut onto live master; do not wholesale-merge | +| CANDIDATE | On master; dual-gate may promote if green | +| NEED_EVIDENCE | Wrong-base / draft / dirty | +| SUPERSEDE | Session pulse, bot-only, or ancient no-auto | + +HOLD / WAIT / OBSERVE are invalid parking (#836). diff --git a/.github/workflows/ml-pipelines-keepalive.yml b/.github/workflows/ml-pipelines-keepalive.yml new file mode 100644 index 000000000..2f8798379 --- /dev/null +++ b/.github/workflows/ml-pipelines-keepalive.yml @@ -0,0 +1,32 @@ +name: ml pipelines keep-alive + +on: + pull_request: + paths: + - "ml/pipelines/**" + - ".github/workflows/ml-pipelines-keepalive.yml" + push: + branches: [master] + paths: + - "ml/pipelines/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ml-keepalive-${{ github.ref }} + cancel-in-progress: true + +jobs: + unittest: + name: keep-alive unit tests + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + submodules: false + - name: stdlib unittest + run: python3 -m unittest discover -s ml/pipelines -p "test_*.py" diff --git a/SKILLS.md b/SKILLS.md index d30fdec0e..0b398fdff 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -8,6 +8,8 @@ | **Primary agent entry (BIUDL)** | [`CLAUDE.md`](CLAUDE.md) | | Adaptive wait / feedback | `.agents/skills/adaptive-feedback-cycle/SKILL.md` | | Admin ops | `.agents/skills/evidence-led-monorepo-ops/SKILL.md` | +| ML keep-alive | `.agents/skills/ml-pipeline-ops/SKILL.md` | +| ICM-CCTV projection | `.agents/skills/icm-cctv-ops/SKILL.md` | | External contribute (help-wanted) | `.agents/skills/help-wanted-lane/SKILL.md` | | Production WAIT → VALIDATE | `.github/skills/production-reconciliation/SKILL.md` | @@ -29,7 +31,7 @@ Every skill directory must contain a `SKILL.md`. Inventory lists **all** of them | Role | Load first | |------|------------| | Collaborator | `adaptive-feedback-cycle` → dual-gate | -| Admin / Grok | `evidence-led-monorepo-ops` + `adaptive-wait` | +| Admin / Grok | `evidence-led-monorepo-ops` + `adaptive-wait` + `ml-pipeline-ops` | | Oversight / external PR | `help-wanted-lane` | | Evaluation / DOE | `multivariate-doe` + `blind-agent-evaluation` | diff --git a/docs/icm/cards/icm-cctv-10023.md b/docs/icm/cards/icm-cctv-10023.md new file mode 100644 index 000000000..2e47dd7ae --- /dev/null +++ b/docs/icm/cards/icm-cctv-10023.md @@ -0,0 +1,5 @@ +# Card: icm-cctv-10023 + +Stepie 2087 step 10023 visualization surface. + +`ml/pipelines/viz` emits JSON for dashboards. Does not steal focus from primary goal 2149. diff --git a/docs/icm/cards/issue-175-operator.md b/docs/icm/cards/issue-175-operator.md new file mode 100644 index 000000000..8d649d430 --- /dev/null +++ b/docs/icm/cards/issue-175-operator.md @@ -0,0 +1,6 @@ +# Card: issue-175-operator + +Priority hub. Dual-gate SSOT. Vercel #772 is non-gate. + +Do not YOLO merge. Do not pulse-comment. Edit the issue body when intent changes. +Live board: `docs/ops/generated/lane-matrix-status.md`. diff --git a/docs/icm/cards/lane-vocab-v2.md b/docs/icm/cards/lane-vocab-v2.md new file mode 100644 index 000000000..dc928766a --- /dev/null +++ b/docs/icm/cards/lane-vocab-v2.md @@ -0,0 +1,5 @@ +# Card: lane-vocab-v2 + +#836 retired HOLD/WAIT/OBSERVE parking. + +Sweep + keep-alive DAG share EXTRACT | CANDIDATE | NEED_EVIDENCE | SUPERSEDE. diff --git a/docs/icm/cards/mlp-keep-001.md b/docs/icm/cards/mlp-keep-001.md new file mode 100644 index 000000000..54609d7b3 --- /dev/null +++ b/docs/icm/cards/mlp-keep-001.md @@ -0,0 +1,9 @@ +# Card: MLP-KEEP-001 + +Keep-alive ML DAG under `ml/pipelines/`. + +- Extract-only vs #682 / #432 / #549 / #601 / #746 / #787 / #817 +- Dual-gate before promote +- MoneyBall scorer is transparent weights, not a black box +- Lane short-circuit: EXTRACT / CANDIDATE / NEED_EVIDENCE / SUPERSEDE before PROMOTE +- Session 2026-09-25 14:10 PDT tip `a3423d97` diff --git a/docs/icm/cards/mlp-keep-002.md b/docs/icm/cards/mlp-keep-002.md new file mode 100644 index 000000000..fc59d34d3 --- /dev/null +++ b/docs/icm/cards/mlp-keep-002.md @@ -0,0 +1,3 @@ +# Card: MLP-KEEP-002 + +ICM-CCTV JSON projection. No secrets. `python3 -m ml.pipelines.cli cctv`. diff --git a/docs/icm/cards/mlp-keep-003.md b/docs/icm/cards/mlp-keep-003.md new file mode 100644 index 000000000..410994940 --- /dev/null +++ b/docs/icm/cards/mlp-keep-003.md @@ -0,0 +1,3 @@ +# Card: MLP-KEEP-003 + +Minesweeper classifiers. Jules/Sentinel/Bolt EXTRACT. Do not overwrite peer branches. diff --git a/docs/icm/cards/mlp-keep-005.md b/docs/icm/cards/mlp-keep-005.md new file mode 100644 index 000000000..c2a2869f9 --- /dev/null +++ b/docs/icm/cards/mlp-keep-005.md @@ -0,0 +1,3 @@ +# Card: MLP-KEEP-005 + +Lane vocab v2 alignment with #836. diff --git a/docs/icm/cards/session-ssot-pulse.md b/docs/icm/cards/session-ssot-pulse.md new file mode 100644 index 000000000..6f4a3ae4a --- /dev/null +++ b/docs/icm/cards/session-ssot-pulse.md @@ -0,0 +1,6 @@ +# Card: session-ssot-pulse + +`docs/ops/LANE-MATRIX.md` is **policy**, not a living pulse. + +Generated board: `docs/ops/generated/lane-matrix-status.md`. +Session recon stays in chat. Product PRs carry code. diff --git a/docs/ops/ICM-CCTV.md b/docs/ops/ICM-CCTV.md new file mode 100644 index 000000000..ffb650f5d --- /dev/null +++ b/docs/ops/ICM-CCTV.md @@ -0,0 +1,14 @@ +# ICM-CCTV ops surface + +Stepie goal 2087 step 10023: wire icm-cctv && visualization surfaces into Ops Dashboards. + +This is the **projection contract**, not a runtime GitHub Pages app. + +- Producer: `python3 -m ml.pipelines.cli cctv` +- Schema: `docs/schemas/icm-cctv.json` +- Code: `ml/pipelines/viz/cctv.py` +- Consumed by operator dashboards (Command Center + future GitHub Pages) + +No secrets. Counts + lane labels + master SHA only. +Primary Stepie goal 2149 is not reordered by this surface. +Live master at extract: `a3423d97`. diff --git a/docs/ops/ML-PIPELINES.md b/docs/ops/ML-PIPELINES.md new file mode 100644 index 000000000..943671f83 --- /dev/null +++ b/docs/ops/ML-PIPELINES.md @@ -0,0 +1,27 @@ +# ML Pipelines keep-alive + +Issue #175 forbids wholesale merge of #432 / #549 / #601 / #682. +This document maps the slim extract in `ml/pipelines/` re-based onto live master `a3423d97`. + +- DAG: `ml/pipelines/cli.py` + `ml/pipelines/stages/` +- Ranking: `ml/pipelines/moneyball/scorer.py` +- Lane short-circuit: `ml/pipelines/lanes/` (vocab v2 after #836) +- Gate: `ml/pipelines/contracts/gate.py` +- Replay adapters: `ml/pipelines/replay/` +- ICM-CCTV: `ml/pipelines/viz/cctv.py` (Stepie 2087 step 10023) +- Latest fixture: `ml/pipelines/fixtures/session_20260925.json` + +## Commands + +```text +python3 -m ml.pipelines.cli status +python3 -m ml.pipelines.cli lanes +python3 -m ml.pipelines.cli run +python3 -m ml.pipelines.cli cctv +python3 -m ml.pipelines.cli explain 48 +python3 -m ml.pipelines.cli gate 682 +python3 -m unittest discover -s ml/pipelines -p 'test_*.py' +``` + +Promote path remains **dual-gate only**. Vercel / GitLab / Copilot / CodeRabbit / Qodo / Devin are non-gate. +Do not restamp `docs/ops/LANE-MATRIX.md`. diff --git a/docs/ops/SKILLS-INVENTORY.md b/docs/ops/SKILLS-INVENTORY.md index dc1236d52..aa5eff03b 100644 --- a/docs/ops/SKILLS-INVENTORY.md +++ b/docs/ops/SKILLS-INVENTORY.md @@ -1,6 +1,6 @@ # Skills Inventory (termux-monorepo) -**Version:** 2026-09-19 17:26 PDT · **Master tip:** `826dc1e4` +**Version:** 2026-09-25 14:10 PDT · **Master tip:** `a3423d97` **Primary agent entry:** [`CLAUDE.md`](../../CLAUDE.md) **Collaborator short entry:** [`SKILLS.md`](../../SKILLS.md) @@ -19,7 +19,7 @@ Local agent mirrors (e.g. `.grok/skills/`) are convenience only — **not** a se | Role | Load first | Then | |------|------------|------| -| **Admin / Grok Administrator** | `evidence-led-monorepo-ops` + `adaptive-wait` | `review-loop` · `help-wanted-lane` · `github-pages-operator` · `approxination-lane` | +| **Admin / Grok Administrator** | `evidence-led-monorepo-ops` + `adaptive-wait` + `ml-pipeline-ops` | `review-loop` · `help-wanted-lane` · `github-pages-operator` · `icm-cctv-ops` | | **Collaborator** | `adaptive-feedback-cycle` | dual-gate · `find-skills` | | **Oversight / external contrib** | `help-wanted-lane` | evidence-led + adaptive-wait · living status board | | **Dashboard / Pages** | `github-pages-operator` | help-wanted-lane · deploy workflow | @@ -40,6 +40,8 @@ Local agent mirrors (e.g. `.grok/skills/`) are convenience only — **not** a se | gemini-performance-psychology | `.agents/skills/gemini-performance-psychology/SKILL.md` | | **github-pages-operator** | `.agents/skills/github-pages-operator/SKILL.md` | | help-wanted-lane | `.agents/skills/help-wanted-lane/SKILL.md` | +| **icm-cctv-ops** | `.agents/skills/icm-cctv-ops/SKILL.md` | +| **ml-pipeline-ops** | `.agents/skills/ml-pipeline-ops/SKILL.md` | | multivariate-doe | `.agents/skills/multivariate-doe/SKILL.md` | | review-loop | `.agents/skills/review-loop/SKILL.md` | | termux-monorepo | `.agents/skills/termux-monorepo/SKILL.md` | diff --git a/docs/proposals/active/actions-refinements/ITEMS.md b/docs/proposals/active/actions-refinements/ITEMS.md index 7ea52613a..8f0654cfc 100644 --- a/docs/proposals/active/actions-refinements/ITEMS.md +++ b/docs/proposals/active/actions-refinements/ITEMS.md @@ -22,6 +22,9 @@ | AR-18 | Establish an observe-mode Capability–Scope–Specialist decision spine that normalizes declared model/provider capabilities, applies hard provenance/availability/quota/scope gates before scoring, emits a secret-free decision envelope, and records repository-local performance evidence ahead of public leaderboard priors. | P0 | Manus AI | executing | Approved Evolutionary Capability Spine plan 2026-08-22. Extends RL-05, RL-11–RL-14, and RL-17 without replacing event classification, peer evidence, command dispatch, or existing branch-write confirmation. Initial delivery is read-only/observe mode with an immediate feature-gate rollback; B3, B4/AR-04, B5/A-14, and PR #276 remain held. | | AR-19 | Add a read-only Context Relationship Evidence Matrix decision-support record that reuses the canonical graph and lead/lag audit surfaces to classify authority, provenance, freshness, risk, coverage gaps, and outcome bindings. | P1 | Manus AI | executing | Approved 2026-08-25 as the Issue #192 half of a tightly coupled CRG-14 + AR-19 PR. No provider invocation, routing promotion, branch write, command composition, raw-body collection, or generated-index hand edit is authorized. The live audit parsing warning is a collector-correctness remediation; B3, B4/AR-04, B5/A-14, and PR #276 remain held. | | AR-20 | Repair the Continuous Team Evaluation live-invocation admission gate so dynamic candidate eligibility is emitted as an explicit step output, stale/unavailable candidates are recorded as skips, and eligible lanes reach the provider invocation while preserving the existing immutable MVT ledger and no-source-mutation boundary. | P1 | ChatGPT | executing | Live run `34069136232` at `4460cf4ff8db8860dfb1b477b64de3a39b7faf1c` discovered 22 eligible candidates but every experiment invocation step was skipped; the candidate script printed `eligible=...` to stdout without writing `$GITHUB_OUTPUT`. Fix is limited to workflow admission/observability; provider secrets remain Actions secrets and output remains uncapped at the existing 131072 request value. | +| AR-21 | Provider-neutral evaluation lane registry + GitHub App capability probe (Checks-publication failure mode). Names-only for Issue #184. | P1 | Grok | executing | PR #806. Rebase onto live master before promote. | +| AR-22 | GAMUT remote evaluation + all-repo wiki knowledge fabric. Observational. No secret values. No private Wiki bodies. | P1 | Grok | executing | PR #809. Rebase onto live master before promote. | +| AR-23 | ML keep-alive DAG re-extract onto live master (`ml/pipelines/`). Extract-only vs wholesale #432/#549/#601/#682/#746/#787/#817. ICM-CCTV projection. Vocab v2 after #836. Do not restamp LANE-MATRIX policy. | P0 | Grok | executing | Implements MLP-KEEP-001. Dual-gate on this SHA. Vercel non-gate (#772). | ## Batch Rules diff --git a/docs/proposals/active/ml-keep-alive/ITEMS.md b/docs/proposals/active/ml-keep-alive/ITEMS.md new file mode 100644 index 000000000..7e90a1b1c --- /dev/null +++ b/docs/proposals/active/ml-keep-alive/ITEMS.md @@ -0,0 +1,9 @@ +# ITEMS — ML keep-alive extract (Issue #175) + +| ID | Work | Priority | Owner | Status | Evidence / Boundary | +|---|---|---|---|---|---| +| MLP-KEEP-001 | Slim `ml/pipelines/` DAG + MoneyBall + lane short-circuit onto live master. Never wholesale-merge #432/#549/#601/#682. | P0 | Grok | executing | Dual-gate on this SHA. Vercel non-gate (#772). | +| MLP-KEEP-002 | ICM-CCTV projection (`cli cctv`) for ops dashboards. No secrets. | P1 | Grok | executing | Schema `docs/schemas/icm-cctv.json`. | +| MLP-KEEP-003 | Minesweeper + supersede classifiers so Jules/Sentinel/Bolt peers are not overwritten. | P1 | Grok | executing | `lanes/minesweeper_rules.py`, `lanes/supersede_rules.py`. | +| MLP-KEEP-004 | Latest-session fixture resolver so keep-alive rebases do not restamp LANE-MATRIX policy. | P1 | Grok | executing | `lib/latest.py` + `fixtures/session_20260925.json`. | +| MLP-KEEP-005 | Align lane vocab with #836 (EXTRACT/CANDIDATE/NEED_EVIDENCE/SUPERSEDE). | P0 | Grok | executing | Invalid parking HOLD/WAIT/OBSERVE rejected in tests. | diff --git a/docs/proposals/active/ml-keep-alive/MANIFEST.md b/docs/proposals/active/ml-keep-alive/MANIFEST.md new file mode 100644 index 000000000..a40655689 --- /dev/null +++ b/docs/proposals/active/ml-keep-alive/MANIFEST.md @@ -0,0 +1,7 @@ +# Manifest + +Package: `ml/pipelines/` +Issue: #175 +Item: MLP-KEEP-001 +Master at cut: `a3423d9760dd372967a765d16696db7cc67ca300` +Agent-Identity: Grok (Administrator) diff --git a/docs/proposals/active/ml-keep-alive/source.md b/docs/proposals/active/ml-keep-alive/source.md new file mode 100644 index 000000000..d252c2560 --- /dev/null +++ b/docs/proposals/active/ml-keep-alive/source.md @@ -0,0 +1,7 @@ +# ML keep-alive proposal + +Implements: MLP-KEEP-001 + +Extract-only vs wholesale ML PRs. Stdlib Python. No GPU. No secrets. +Promote only when dual-gate is green on **this** SHA. +Lane vocab v2 after #836. diff --git a/docs/proposals/registry.yaml b/docs/proposals/registry.yaml index 2f9eb0c2c..c06673755 100644 --- a/docs/proposals/registry.yaml +++ b/docs/proposals/registry.yaml @@ -1,9 +1,27 @@ # ArchW1z proposal registry — agents read this first version: 1 -updated_at: "2026-09-24T16:40:00Z" +updated_at: "2026-09-25T21:10:00Z" updated_by: grok-administrator proposals: + - id: ml-keep-alive + title: "ML keep-alive DAG extract onto live master (Issue #175)" + author: grok + status: executing + priority: P0 + path: active/ml-keep-alive/ + reviewers: + - id: grok + role: author+executor + status: executing + - id: timerloggedout-spec + role: operator-authorizer + status: requested + related_issues: [175, 772] + related_prs: [817, 787, 746, 682, 432] + related_branches: [feat/ml-pipelines-keepalive-20260925] + gates_required: [repo-gate, termux-smoke] + - id: temporal-langsmith-adapter title: "Temporal self-host + LangSmith Trajectories adapter (P1 observational)" author: grok diff --git a/docs/schemas/icm-cctv.json b/docs/schemas/icm-cctv.json new file mode 100644 index 000000000..b08e6380d --- /dev/null +++ b/docs/schemas/icm-cctv.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ICM-CCTV projection", + "type": "object", + "required": [ + "master_sha", + "issue", + "lanes" + ], + "properties": { + "master_sha": { + "type": "string", + "minLength": 7 + }, + "issue": { + "const": 175 + }, + "lanes": { + "type": "object" + }, + "operator": { + "const": "ACTIVE" + } + } +} diff --git a/ml/__init__.py b/ml/__init__.py new file mode 100644 index 000000000..fc6f5efa0 --- /dev/null +++ b/ml/__init__.py @@ -0,0 +1 @@ +"""ML keep-alive package. Extract-only vs wholesale #682/#432/#549/#601/#817.""" diff --git a/ml/pipelines/README.md b/ml/pipelines/README.md new file mode 100644 index 000000000..59a4da216 --- /dev/null +++ b/ml/pipelines/README.md @@ -0,0 +1,21 @@ +# ml/pipelines keep-alive + +Slim extract of the ML keep-alive DAG. Issue #175 forbids wholesale merge of +#432 / #549 / #601 / #682 / #746 / #787 / #817. + +Re-extracted onto live master `a3423d97` after #836 (lane vocab v2). + +Lane vocabulary: `EXTRACT | CANDIDATE | NEED_EVIDENCE | SUPERSEDE`. +HOLD / WAIT / OBSERVE are invalid parking. + +```text +python3 -m ml.pipelines.cli status +python3 -m ml.pipelines.cli lanes +python3 -m ml.pipelines.cli run +python3 -m ml.pipelines.cli cctv +python3 -m ml.pipelines.cli matrix +python3 -m unittest discover -s ml/pipelines -p 'test_*.py' +``` + +Operator ACTIVE. Dual-gate remains promote authority. Vercel non-gate (#772). +Do not restamp LANE-MATRIX policy. diff --git a/ml/pipelines/VERSION b/ml/pipelines/VERSION new file mode 100644 index 000000000..8f0916f76 --- /dev/null +++ b/ml/pipelines/VERSION @@ -0,0 +1 @@ +0.5.0 diff --git a/ml/pipelines/__init__.py b/ml/pipelines/__init__.py index 19a44d2cb..8d1de0035 100644 --- a/ml/pipelines/__init__.py +++ b/ml/pipelines/__init__.py @@ -1,5 +1,7 @@ -"""Slim keep-alive ML pipeline package (AR-23 / MLP-KEEP-001).""" +"""ml.pipelines: Keep-alive ML DAG for operator ranking. -from .keepalive_dag import DagSpec, default_dag, render_mermaid, validate_dag +Implements: MLP-KEEP-001 +""" +from __future__ import annotations -__all__ = ["DagSpec", "default_dag", "render_mermaid", "validate_dag"] +__version__ = "0.5.0" diff --git a/ml/pipelines/__main__.py b/ml/pipelines/__main__.py new file mode 100644 index 000000000..2dcaa29ee --- /dev/null +++ b/ml/pipelines/__main__.py @@ -0,0 +1,5 @@ +from ml.pipelines.cli import main +import sys + +if __name__ == '__main__': + sys.exit(main()) diff --git a/ml/pipelines/ates/__init__.py b/ml/pipelines/ates/__init__.py new file mode 100644 index 000000000..1908ef240 --- /dev/null +++ b/ml/pipelines/ates/__init__.py @@ -0,0 +1 @@ +"""ATES Phase B observer glue. Does not replace #841.""" diff --git a/ml/pipelines/ates/observer.py b/ml/pipelines/ates/observer.py new file mode 100644 index 000000000..26822ab91 --- /dev/null +++ b/ml/pipelines/ates/observer.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import Any + + +def observe(snapshot: dict[str, Any]) -> dict[str, Any]: + return { + "spine": "ates-phase-b", + "master_sha": snapshot.get("master_sha"), + "mode": "observe", + "issue": 175, + } diff --git a/ml/pipelines/ates/spine.py b/ml/pipelines/ates/spine.py new file mode 100644 index 000000000..3e0ddf951 --- /dev/null +++ b/ml/pipelines/ates/spine.py @@ -0,0 +1,4 @@ +"""Runtime evidence is the primary agent measurement spine (#841).""" +from __future__ import annotations + +SPINE = "runtime-evidence" diff --git a/ml/pipelines/cli.py b/ml/pipelines/cli.py new file mode 100644 index 000000000..c624c6b0e --- /dev/null +++ b/ml/pipelines/cli.py @@ -0,0 +1,118 @@ +"""cli: python3 -m ml.pipelines.cli """ +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any + +from ml.pipelines.contracts.gate import assert_promotable, block_reasons +from ml.pipelines.lanes.classify import classify_pr +from ml.pipelines.lib.engine import run_dag, summarize +from ml.pipelines.lib.errors import GateBlocked +from ml.pipelines.lib.io import load_json +from ml.pipelines.lib.latest import latest_session_path +from ml.pipelines.moneyball.explain import explain +from ml.pipelines.moneyball.scorer import score +from ml.pipelines.operator.matrix import PRIORITY +from ml.pipelines.stages import STAGES +from ml.pipelines.viz.cctv import emit_cctv +from ml.pipelines.viz.mermaid import dag_mermaid + + +def _fixture() -> dict[str, Any]: + return load_json(latest_session_path()) + + +def cmd_status(_: argparse.Namespace) -> int: + payload = _fixture() + print(json.dumps({ + "master_sha": payload["master_sha"], + "issue": 175, + "open_prs": len(payload["prs"]), + "operator": "ACTIVE", + "session": payload.get("session"), + "fixture": latest_session_path().name, + "vocab": ["EXTRACT", "CANDIDATE", "NEED_EVIDENCE", "SUPERSEDE"], + }, indent=2)) + return 0 + + +def cmd_run(_: argparse.Namespace) -> int: + context: dict[str, Any] = {"snapshot": _fixture()} + results = run_dag(STAGES, context) + print(json.dumps({"summary": summarize(results), "lanes": context.get("lanes", []), "counts": context.get("lane_counts")}, indent=2)) + return 0 + + +def cmd_lanes(_: argparse.Namespace) -> int: + payload = _fixture() + rows = [] + for pr in payload["prs"]: + rows.append({"number": pr["number"], "lane": classify_pr(pr).value, "score": score(pr), "why": pr.get("why")}) + print(json.dumps(rows, indent=2)) + return 0 + + +def cmd_cctv(_: argparse.Namespace) -> int: + print(json.dumps(emit_cctv(_fixture()), indent=2)) + return 0 + + +def cmd_matrix(_: argparse.Namespace) -> int: + print(json.dumps(PRIORITY, indent=2)) + return 0 + + +def cmd_dag(_: argparse.Namespace) -> int: + sys.stdout.write(dag_mermaid()) + return 0 + + +def cmd_explain(ns: argparse.Namespace) -> int: + payload = _fixture() + wanted = int(ns.number) + for pr in payload["prs"]: + if int(pr["number"]) == wanted: + print(json.dumps(explain(pr), indent=2)) + return 0 + return 1 + + +def cmd_gate(ns: argparse.Namespace) -> int: + payload = _fixture() + wanted = int(ns.number) + for pr in payload["prs"]: + if int(pr["number"]) == wanted: + try: + assert_promotable(pr) + print(json.dumps({"number": wanted, "promotable": True, "reasons": []}, indent=2)) + return 0 + except GateBlocked as exc: + print(json.dumps({"number": wanted, "promotable": False, "reasons": exc.reasons}, indent=2)) + return 2 + print(json.dumps({"number": wanted, "reasons": block_reasons({"number": wanted})}, indent=2)) + return 1 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="ml.pipelines") + sub = parser.add_subparsers(dest="cmd", required=True) + sub.add_parser("status").set_defaults(fn=cmd_status) + sub.add_parser("run").set_defaults(fn=cmd_run) + sub.add_parser("lanes").set_defaults(fn=cmd_lanes) + sub.add_parser("cctv").set_defaults(fn=cmd_cctv) + sub.add_parser("matrix").set_defaults(fn=cmd_matrix) + sub.add_parser("dag").set_defaults(fn=cmd_dag) + explain_p = sub.add_parser("explain") + explain_p.add_argument("number") + explain_p.set_defaults(fn=cmd_explain) + gate_p = sub.add_parser("gate") + gate_p.add_argument("number") + gate_p.set_defaults(fn=cmd_gate) + ns = parser.parse_args(argv) + return int(ns.fn(ns)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ml/pipelines/contracts/__init__.py b/ml/pipelines/contracts/__init__.py new file mode 100644 index 000000000..89b272533 --- /dev/null +++ b/ml/pipelines/contracts/__init__.py @@ -0,0 +1 @@ +"""Promote contracts. Dual-gate is the only promote authority.""" diff --git a/ml/pipelines/contracts/dual_gate.py b/ml/pipelines/contracts/dual_gate.py new file mode 100644 index 000000000..59e89d4e6 --- /dev/null +++ b/ml/pipelines/contracts/dual_gate.py @@ -0,0 +1,20 @@ +"""Bind named dual-gate jobs. Combined commit status is not dual-gate.""" +from __future__ import annotations + +from typing import Any + +REPO_GATE_NAMES = frozenset({"hygiene + portability gate", "repo gate", "repo-gate"}) +SMOKE_NAMES = frozenset({"agentic termux smoke", "termux smoke", "termux-smoke"}) + + +def named_gate_success(checks: list[dict[str, Any]], names: frozenset[str]) -> bool: + for check in checks: + name = str(check.get("name") or check.get("context") or "").lower() + conclusion = str(check.get("conclusion") or check.get("state") or "").lower() + if name in {n.lower() for n in names} and conclusion in {"success"}: + return True + return False + + +def dual_gate_green(checks: list[dict[str, Any]]) -> bool: + return named_gate_success(checks, REPO_GATE_NAMES) and named_gate_success(checks, SMOKE_NAMES) diff --git a/ml/pipelines/contracts/gate.py b/ml/pipelines/contracts/gate.py new file mode 100644 index 000000000..9d3722eb6 --- /dev/null +++ b/ml/pipelines/contracts/gate.py @@ -0,0 +1,39 @@ +"""Gate helpers. Dual-gate SUCCESS on THIS SHA is required to promote.""" +from __future__ import annotations + +from typing import Any + +from ml.pipelines.lib.errors import GateBlocked + + +REQUIRED_GATES = ("repo-gate", "termux-smoke") +NON_GATES = frozenset({"vercel", "copilot", "coderabbit", "qodo", "devin", "gitlab"}) + + +def assert_promotable(pr: dict[str, Any]) -> None: + reasons = block_reasons(pr) + if reasons: + raise GateBlocked(pr.get("number"), reasons) + + +def block_reasons(pr: dict[str, Any]) -> list[str]: + reasons: list[str] = [] + if pr.get("draft"): + reasons.append("draft") + if pr.get("mergeable_state") == "dirty": + reasons.append("mergeable_state=dirty") + base = (pr.get("base") or {}).get("ref") if isinstance(pr.get("base"), dict) else pr.get("base") + if base and base != "master": + reasons.append(f"wrong-base:{base}") + gates = pr.get("gates") or {} + for name in REQUIRED_GATES: + if gates.get(name) != "success": + reasons.append(f"missing-gate:{name}") + if pr.get("wholesale"): + reasons.append("ml-wholesale-no-go") + return reasons + + +def is_non_gate(context: str) -> bool: + key = context.split("–")[0].split("-")[0].strip().lower() + return any(token in context.lower() for token in NON_GATES) or key in NON_GATES diff --git a/ml/pipelines/contracts/non_gate.py b/ml/pipelines/contracts/non_gate.py new file mode 100644 index 000000000..d72786ca4 --- /dev/null +++ b/ml/pipelines/contracts/non_gate.py @@ -0,0 +1,17 @@ +"""Advisory / non-gate providers. Never block promote on these.""" +from __future__ import annotations + +ADVISORY = ( + "Vercel", + "CodeRabbit", + "Copilot", + "Qodo", + "Devin", + "GitLab", + "Mintlify", +) + + +def is_advisory(name: str) -> bool: + lowered = name.lower() + return any(token.lower() in lowered for token in ADVISORY) diff --git a/ml/pipelines/contracts/promote.py b/ml/pipelines/contracts/promote.py new file mode 100644 index 000000000..22f893bf0 --- /dev/null +++ b/ml/pipelines/contracts/promote.py @@ -0,0 +1,18 @@ +"""Promote decision: ALLOW | BLOCK | NEED_EVIDENCE.""" +from __future__ import annotations + +from typing import Any + +from ml.pipelines.contracts.gate import block_reasons +from ml.pipelines.lanes.vocab import Lane + + +def promote_decision(pr: dict[str, Any]) -> str: + reasons = block_reasons(pr) + if "mergeable_state=dirty" in reasons or any(r.startswith("wrong-base:") for r in reasons): + return "NEED_EVIDENCE" + if reasons: + return "BLOCK" + if pr.get("lane") == Lane.EXTRACT.value: + return "BLOCK" + return "ALLOW" diff --git a/ml/pipelines/contracts/schema.py b/ml/pipelines/contracts/schema.py new file mode 100644 index 000000000..97e8f8455 --- /dev/null +++ b/ml/pipelines/contracts/schema.py @@ -0,0 +1,29 @@ +"""Minimal session fixture schema. Stdlib only.""" +from __future__ import annotations + +from typing import Any + +REQUIRED_ROOT = ("master_sha", "session", "prs") +REQUIRED_PR = ("number", "title", "base") + + +def validate_snapshot(payload: dict[str, Any]) -> list[str]: + errors: list[str] = [] + for key in REQUIRED_ROOT: + if key not in payload: + errors.append(f"missing:{key}") + sha = str(payload.get("master_sha") or "") + if len(sha) < 7: + errors.append("master_sha too short") + prs = payload.get("prs") + if not isinstance(prs, list): + errors.append("prs must be a list") + return errors + for pr in prs: + if not isinstance(pr, dict): + errors.append("pr not object") + continue + for key in REQUIRED_PR: + if key not in pr: + errors.append(f"pr missing:{key}") + return errors diff --git a/ml/pipelines/fixtures/__init__.py b/ml/pipelines/fixtures/__init__.py new file mode 100644 index 000000000..9199c537a --- /dev/null +++ b/ml/pipelines/fixtures/__init__.py @@ -0,0 +1 @@ +# fixtures package diff --git a/ml/pipelines/fixtures/ates_spine.json b/ml/pipelines/fixtures/ates_spine.json new file mode 100644 index 000000000..e27feea0a --- /dev/null +++ b/ml/pipelines/fixtures/ates_spine.json @@ -0,0 +1,5 @@ +{ + "spine": "runtime-evidence", + "pr": 841, + "mode": "observe" +} diff --git a/ml/pipelines/fixtures/pr_samples.json b/ml/pipelines/fixtures/pr_samples.json new file mode 100644 index 000000000..2dd13fee7 --- /dev/null +++ b/ml/pipelines/fixtures/pr_samples.json @@ -0,0 +1,9 @@ +{ + "description": "Named samples for unit tests", + "keep": [ + 682, + 817, + 48, + 630 + ] +} diff --git a/ml/pipelines/fixtures/session_20260925.json b/ml/pipelines/fixtures/session_20260925.json new file mode 100644 index 000000000..e962e43cd --- /dev/null +++ b/ml/pipelines/fixtures/session_20260925.json @@ -0,0 +1,132 @@ +{ + "master_sha": "a3423d9760dd372967a765d16696db7cc67ca300", + "session": "2026-09-25T21:10:00Z", + "issue": 175, + "prs": [ + { + "number": 48, + "title": "feat(llm-api-hub): OpenAI hub + standalone server + ADE/kai9000 split (TER-71)", + "base": { + "ref": "master-staging" + }, + "draft": false, + "mergeable_state": "dirty", + "changed_files": 180, + "age_days": 51.02, + "why": "wrong-base:master-staging", + "user": { + "login": "timerloggedout-spec" + }, + "gates": {} + }, + { + "number": 682, + "title": "feat(ml): keep-alive pipeline DAG + operator skills (Issue #175)", + "base": { + "ref": "master" + }, + "keep_alive": true, + "wholesale": false, + "changed_files": 120, + "age_days": 4.99, + "why": "ml-keep-alive-rebase-required", + "user": { + "login": "timerloggedout-spec" + }, + "gates": {} + }, + { + "number": 630, + "title": "fix(termux-multi-agent): add fallback rich UI classes in dashboard.py", + "base": { + "ref": "master" + }, + "changed_files": 55, + "age_days": 6.38, + "why": "bot-observe, minesweeper-title", + "user": { + "login": "google-labs-jules[bot]" + }, + "gates": {} + }, + { + "number": 838, + "title": "feat(foresight): establish portable evidence spine", + "base": { + "ref": "master" + }, + "changed_files": 22, + "age_days": 0.1, + "has_tests": true, + "why": "stale-base vs a3423d97", + "user": { + "login": "timerloggedout-spec" + }, + "gates": { + "repo-gate": "success" + } + }, + { + "number": 817, + "title": "feat(ml): keep-alive DAG re-extract onto 03ffb33b (#175)", + "base": { + "ref": "master" + }, + "keep_alive": true, + "changed_files": 80, + "age_days": 0.9, + "why": "ml-keep-alive-rebase-required", + "user": { + "login": "timerloggedout-spec" + }, + "gates": {} + }, + { + "number": 788, + "title": "test(archwiz): Linear client/sync coverage + Triage backlog reconciliation (TER-15)", + "base": { + "ref": "master-staging" + }, + "changed_files": 12, + "age_days": 1.83, + "why": "wrong-base:master-staging", + "user": { + "login": "timerloggedout-spec" + }, + "gates": {} + }, + { + "number": 836, + "title": "feat(ops): retire HOLD/WAIT/OBSERVE parking from lane sweep", + "base": { + "ref": "master" + }, + "changed_files": 4, + "age_days": 0.08, + "has_tests": true, + "merged": true, + "why": "promoted", + "user": { + "login": "timerloggedout-spec" + }, + "gates": { + "repo-gate": "success", + "termux-smoke": "success" + } + }, + { + "number": 140, + "title": "Palette: Stateful & Reactive PWA UX with Manual Vault Refresh", + "base": { + "ref": "master" + }, + "changed_files": 90, + "age_days": 46.41, + "why": "bot-observe", + "user": { + "login": "google-labs-jules[bot]" + }, + "gates": {} + } + ] +} diff --git a/ml/pipelines/keepalive_dag.py b/ml/pipelines/keepalive_dag.py index 0e947966d..8f89f4d90 100644 --- a/ml/pipelines/keepalive_dag.py +++ b/ml/pipelines/keepalive_dag.py @@ -31,7 +31,9 @@ def node_ids(self) -> set[str]: return {n.node_id for n in self.nodes} -ALLOWED_KINDS = frozenset({"ingest", "validate", "score", "ledger", "export"}) +ALLOWED_KINDS = frozenset( + {"ingest", "validate", "score", "ledger", "export", "recon", "evaluate", "monitor"} +) def default_dag() -> DagSpec: @@ -60,6 +62,28 @@ def default_dag() -> DagSpec: ) +def operator_dag() -> DagSpec: + """Extended observe DAG used by `cli run`. Does not replace default_dag().""" + base = default_dag() + extra_nodes = ( + DagNode("recon_lanes", "recon", "Classify open PRs with vocab v2"), + DagNode("evaluate_gates", "evaluate", "Bind dual-gate evidence to SHA"), + DagNode("monitor_cctv", "monitor", "Project ICM-CCTV JSON"), + ) + extra_edges = ( + DagEdge("export_status", "recon_lanes"), + DagEdge("recon_lanes", "evaluate_gates"), + DagEdge("evaluate_gates", "monitor_cctv"), + ) + return DagSpec( + name="ml-keepalive-operator", + nodes=base.nodes + extra_nodes, + edges=base.edges + extra_edges, + mode="observe", + notes=base.notes + ("Lane vocab v2 after #836.",), + ) + + def validate_dag(spec: DagSpec) -> list[str]: errors: list[str] = [] ids = spec.node_ids() diff --git a/ml/pipelines/lanes/__init__.py b/ml/pipelines/lanes/__init__.py new file mode 100644 index 000000000..ce9509db1 --- /dev/null +++ b/ml/pipelines/lanes/__init__.py @@ -0,0 +1 @@ +from ml.pipelines.lanes.vocab import Lane, VALID_LANES # noqa: F401 diff --git a/ml/pipelines/lanes/classify.py b/ml/pipelines/lanes/classify.py new file mode 100644 index 000000000..3459959e7 --- /dev/null +++ b/ml/pipelines/lanes/classify.py @@ -0,0 +1,27 @@ +"""Classify a PR into vocab v2. Never emit HOLD/WAIT/OBSERVE.""" +from __future__ import annotations + +from typing import Any + +from ml.pipelines.lanes.minesweeper_rules import minesweeper_extract +from ml.pipelines.lanes.supersede_rules import should_supersede +from ml.pipelines.lanes.vocab import Lane + + +def classify_pr(pr: dict[str, Any]) -> Lane: + base = (pr.get("base") or {}).get("ref") if isinstance(pr.get("base"), dict) else pr.get("base") + if pr.get("wholesale") or "ml-wholesale" in str(pr.get("why") or ""): + return Lane.EXTRACT + if minesweeper_extract(pr): + return Lane.EXTRACT + if str(pr.get("title") or "").startswith("feat(ml):") and pr.get("keep_alive"): + return Lane.EXTRACT + if should_supersede(pr): + return Lane.SUPERSEDE + if pr.get("draft") or pr.get("mergeable_state") == "dirty": + return Lane.NEED_EVIDENCE + if base and base != "master": + return Lane.NEED_EVIDENCE + if not pr.get("gates", {}).get("repo-gate") or not pr.get("gates", {}).get("termux-smoke"): + return Lane.NEED_EVIDENCE + return Lane.CANDIDATE diff --git a/ml/pipelines/lanes/minesweeper_rules.py b/ml/pipelines/lanes/minesweeper_rules.py new file mode 100644 index 000000000..0f4d18e17 --- /dev/null +++ b/ml/pipelines/lanes/minesweeper_rules.py @@ -0,0 +1,42 @@ +"""Minesweeper: concurrent agents open overlapping PRs. Do not overwrite WAIT peers.""" +from __future__ import annotations + +from typing import Any + +BOT_LOGINS = frozenset( + { + "google-labs-jules[bot]", + "github-actions[bot]", + "coderabbitai[bot]", + "devin-ai-integration[bot]", + "cursor[bot]", + "copilot-swe-agent[bot]", + } +) + +MINESWEEPER_TOKENS = ( + "palette:", + "sentinel:", + "bolt:", + "linguist:", + "minesweeper", +) + + +def is_bot_author(pr: dict[str, Any]) -> bool: + user = pr.get("user") or {} + login = user.get("login") if isinstance(user, dict) else pr.get("author") + return str(login or "") in BOT_LOGINS or str(login or "").endswith("[bot]") + + +def minesweeper_title(pr: dict[str, Any]) -> bool: + title = str(pr.get("title") or "").lower() + return any(token in title for token in MINESWEEPER_TOKENS) + + +def files_over_extract_threshold(pr: dict[str, Any], threshold: int = 40) -> bool: + return int(pr.get("changed_files") or 0) > threshold + + +def minesweeper_extract(pr: dict[str, Any]) -> bool: + return (is_bot_author(pr) and files_over_extract_threshold(pr)) or minesweeper_title(pr) diff --git a/ml/pipelines/lanes/reasons.py b/ml/pipelines/lanes/reasons.py new file mode 100644 index 000000000..09f0b2ed5 --- /dev/null +++ b/ml/pipelines/lanes/reasons.py @@ -0,0 +1,12 @@ +"""Stable reason tokens used by classify + sweep.""" +from __future__ import annotations + +WRONG_BASE = "wrong-base" +DRAFT = "draft" +DIRTY = "dirty" +ML_WHOLESALE = "ml-wholesale-no-go" +MINESWEEPER = "minesweeper-title" +BOT_OBSERVE = "bot-observe" +SESSION_PULSE = "session-pulse" +ANCIENT = "ancient-no-auto-promote" +KEEPALIVE = "ml-keep-alive-rebase-required" diff --git a/ml/pipelines/lanes/short_circuit.py b/ml/pipelines/lanes/short_circuit.py new file mode 100644 index 000000000..c4e9203de --- /dev/null +++ b/ml/pipelines/lanes/short_circuit.py @@ -0,0 +1,8 @@ +"""Lane short-circuit before PROMOTE. EXTRACT never auto-promotes.""" +from __future__ import annotations + +from ml.pipelines.lanes.vocab import Lane + + +def may_promote(lane: Lane) -> bool: + return lane is Lane.CANDIDATE diff --git a/ml/pipelines/lanes/supersede_rules.py b/ml/pipelines/lanes/supersede_rules.py new file mode 100644 index 000000000..696d98c20 --- /dev/null +++ b/ml/pipelines/lanes/supersede_rules.py @@ -0,0 +1,25 @@ +"""SUPERSEDE classifiers: session pulses, stamps, ancient no-auto.""" +from __future__ import annotations + +from typing import Any + +PULSE_TOKENS = ( + "ops(session)", + "ops(skills): stamp", + "lane-matrix", + "pulse", + "live status refresh", +) + + +def is_session_pulse(pr: dict[str, Any]) -> bool: + title = str(pr.get("title") or "").lower() + return any(token in title for token in PULSE_TOKENS) + + +def is_ancient(pr: dict[str, Any], days: float = 40.0) -> bool: + return float(pr.get("age_days") or 0) >= days + + +def should_supersede(pr: dict[str, Any]) -> bool: + return is_session_pulse(pr) or (is_ancient(pr) and not pr.get("product")) diff --git a/ml/pipelines/lanes/vocab.py b/ml/pipelines/lanes/vocab.py new file mode 100644 index 000000000..d06f6a9cc --- /dev/null +++ b/ml/pipelines/lanes/vocab.py @@ -0,0 +1,21 @@ +"""Lane vocabulary v2 (#836). HOLD/WAIT/OBSERVE are invalid parking.""" +from __future__ import annotations + +from enum import Enum + + +class Lane(str, Enum): + EXTRACT = "EXTRACT" + CANDIDATE = "CANDIDATE" + NEED_EVIDENCE = "NEED_EVIDENCE" + SUPERSEDE = "SUPERSEDE" + + +VALID_LANES = frozenset(item.value for item in Lane) +INVALID_PARKING = frozenset({"HOLD", "WAIT", "OBSERVE"}) + + +def coerce(raw: str) -> Lane: + if raw in INVALID_PARKING: + raise ValueError(f"invalid parking lane: {raw}") + return Lane(raw) diff --git a/ml/pipelines/lib/__init__.py b/ml/pipelines/lib/__init__.py new file mode 100644 index 000000000..455b05ba2 --- /dev/null +++ b/ml/pipelines/lib/__init__.py @@ -0,0 +1 @@ +"""Shared engine, IO, errors. Stdlib only.""" diff --git a/ml/pipelines/lib/clock.py b/ml/pipelines/lib/clock.py new file mode 100644 index 000000000..eb16922c0 --- /dev/null +++ b/ml/pipelines/lib/clock.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from datetime import datetime, timezone + + +def utc_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") diff --git a/ml/pipelines/lib/engine.py b/ml/pipelines/lib/engine.py new file mode 100644 index 000000000..384a4a137 --- /dev/null +++ b/ml/pipelines/lib/engine.py @@ -0,0 +1,18 @@ +"""Tiny DAG runner. Stages are callables(context) -> None.""" +from __future__ import annotations + +from typing import Any, Callable + +Stage = Callable[[dict[str, Any]], None] + + +def run_dag(stages: list[tuple[str, Stage]], context: dict[str, Any]) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + for name, fn in stages: + fn(context) + results.append({"stage": name, "ok": True}) + return results + + +def summarize(results: list[dict[str, Any]]) -> dict[str, Any]: + return {"stages": len(results), "ok": all(r.get("ok") for r in results)} diff --git a/ml/pipelines/lib/errors.py b/ml/pipelines/lib/errors.py new file mode 100644 index 000000000..715016a1d --- /dev/null +++ b/ml/pipelines/lib/errors.py @@ -0,0 +1,13 @@ +class PipelineError(Exception): + """Base keep-alive error.""" + + +class GateBlocked(PipelineError): + def __init__(self, number: object, reasons: list[str]) -> None: + super().__init__(f"PR {number} blocked: {', '.join(reasons)}") + self.number = number + self.reasons = reasons + + +class FixtureError(PipelineError): + pass diff --git a/ml/pipelines/lib/io.py b/ml/pipelines/lib/io.py new file mode 100644 index 000000000..395cd6b97 --- /dev/null +++ b/ml/pipelines/lib/io.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def load_json(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, dict): + raise ValueError(f"expected object in {path}") + return payload + + +def dump_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") diff --git a/ml/pipelines/lib/latest.py b/ml/pipelines/lib/latest.py new file mode 100644 index 000000000..aa32007ad --- /dev/null +++ b/ml/pipelines/lib/latest.py @@ -0,0 +1,13 @@ +"""Resolve the newest session fixture without restamping LANE-MATRIX.""" +from __future__ import annotations + +from pathlib import Path + +FIXTURE_DIR = Path(__file__).resolve().parent.parent / "fixtures" + + +def latest_session_path() -> Path: + sessions = sorted(FIXTURE_DIR.glob("session_*.json")) + if not sessions: + raise FileNotFoundError("no session_*.json fixtures") + return sessions[-1] diff --git a/ml/pipelines/lib/safe.py b/ml/pipelines/lib/safe.py new file mode 100644 index 000000000..550ed6f85 --- /dev/null +++ b/ml/pipelines/lib/safe.py @@ -0,0 +1,19 @@ +"""Reject Class 3/4 artifacts. Names-only for #184.""" +from __future__ import annotations + +FORBIDDEN_KEYS = frozenset( + {"token", "password", "secret", "authorization", "cookie", "private_key"} +) + + +def redact(payload: dict) -> dict: + out = {} + for key, value in payload.items(): + lowered = str(key).lower() + if any(token in lowered for token in FORBIDDEN_KEYS): + out[key] = "[redacted]" + elif isinstance(value, dict): + out[key] = redact(value) + else: + out[key] = value + return out diff --git a/ml/pipelines/lib/sha.py b/ml/pipelines/lib/sha.py new file mode 100644 index 000000000..c9e504668 --- /dev/null +++ b/ml/pipelines/lib/sha.py @@ -0,0 +1,10 @@ +from __future__ import annotations + + +def short_sha(sha: str, n: int = 8) -> str: + return sha[:n] + + +def same_sha(left: str, right: str) -> bool: + n = min(len(left), len(right), 7) + return bool(n) and left[:n] == right[:n] diff --git a/ml/pipelines/moneyball/__init__.py b/ml/pipelines/moneyball/__init__.py new file mode 100644 index 000000000..469401d62 --- /dev/null +++ b/ml/pipelines/moneyball/__init__.py @@ -0,0 +1 @@ +from ml.pipelines.moneyball.scorer import score # noqa: F401 diff --git a/ml/pipelines/moneyball/explain.py b/ml/pipelines/moneyball/explain.py new file mode 100644 index 000000000..1b2f561dd --- /dev/null +++ b/ml/pipelines/moneyball/explain.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Any + +from ml.pipelines.lanes.classify import classify_pr +from ml.pipelines.moneyball.scorer import score +from ml.pipelines.moneyball.weights import WEIGHTS + + +def explain(pr: dict[str, Any]) -> dict[str, Any]: + return { + "number": pr.get("number"), + "score": score(pr), + "lane": classify_pr(pr).value, + "weights": WEIGHTS, + "why": pr.get("why"), + "title": pr.get("title"), + } diff --git a/ml/pipelines/moneyball/normalize.py b/ml/pipelines/moneyball/normalize.py new file mode 100644 index 000000000..38b2d04ac --- /dev/null +++ b/ml/pipelines/moneyball/normalize.py @@ -0,0 +1,5 @@ +from __future__ import annotations + + +def clamp(value: float, lo: float = 0.0, hi: float = 100.0) -> float: + return max(lo, min(hi, value)) diff --git a/ml/pipelines/moneyball/scorer.py b/ml/pipelines/moneyball/scorer.py new file mode 100644 index 000000000..3812b76ef --- /dev/null +++ b/ml/pipelines/moneyball/scorer.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Any + +from ml.pipelines.lanes.minesweeper_rules import minesweeper_extract +from ml.pipelines.moneyball.normalize import clamp +from ml.pipelines.moneyball.weights import STALE_CAP, WEIGHTS + + +def score(pr: dict[str, Any]) -> float: + points = 0.0 + base = (pr.get("base") or {}).get("ref") if isinstance(pr.get("base"), dict) else pr.get("base") + gates = pr.get("gates") or {} + if gates.get("repo-gate") == "success" and gates.get("termux-smoke") == "success": + points += WEIGHTS["dual_gate_green"] + if base == "master": + points += WEIGHTS["on_master_base"] + if int(pr.get("changed_files") or 0) <= 100: + points += WEIGHTS["files_under_100"] + if pr.get("has_tests"): + points += WEIGHTS["has_tests"] + if float(pr.get("age_days") or 0) < 2: + points += WEIGHTS["fresh"] + if pr.get("draft"): + points += WEIGHTS["draft"] + if pr.get("mergeable_state") == "dirty": + points += WEIGHTS["dirty"] + if base and base != "master": + points += WEIGHTS["wrong_base"] + if minesweeper_extract(pr): + points += WEIGHTS["minesweeper"] + if pr.get("wholesale"): + points += WEIGHTS["wholesale"] + stale = int(float(pr.get("age_days") or 0)) + points += max(STALE_CAP, stale * WEIGHTS["stale_day"]) + return clamp(points) diff --git a/ml/pipelines/moneyball/weights.py b/ml/pipelines/moneyball/weights.py new file mode 100644 index 000000000..2945517da --- /dev/null +++ b/ml/pipelines/moneyball/weights.py @@ -0,0 +1,18 @@ +"""Transparent MoneyBall weights. Not a black box.""" +from __future__ import annotations + +WEIGHTS = { + "dual_gate_green": 40, + "on_master_base": 20, + "files_under_100": 15, + "has_tests": 10, + "fresh": 10, + "draft": -25, + "dirty": -40, + "wrong_base": -40, + "minesweeper": -30, + "wholesale": -50, + "stale_day": -1, +} + +STALE_CAP = -20 diff --git a/ml/pipelines/operator/__init__.py b/ml/pipelines/operator/__init__.py new file mode 100644 index 000000000..e60efdb4e --- /dev/null +++ b/ml/pipelines/operator/__init__.py @@ -0,0 +1 @@ +"""Issue #175 operator projection. Edit the issue body; do not pulse-comment.""" diff --git a/ml/pipelines/operator/hub.py b/ml/pipelines/operator/hub.py new file mode 100644 index 000000000..8f916104f --- /dev/null +++ b/ml/pipelines/operator/hub.py @@ -0,0 +1,2 @@ +"""Issue #175 is a hub, not a comment stream.""" +ISSUE_URL = "https://github.com/timerloggedout-spec/termux-monorepo/issues/175" diff --git a/ml/pipelines/operator/issue175.py b/ml/pipelines/operator/issue175.py new file mode 100644 index 000000000..21b89ddf9 --- /dev/null +++ b/ml/pipelines/operator/issue175.py @@ -0,0 +1,7 @@ +ISSUE = 175 +HUB_RULES = ( + "Do not pulse-comment this issue.", + "Edit the body when intent changes.", + "Vercel is non-gate (#772).", + "Credential inventory is names-only (#184).", +) diff --git a/ml/pipelines/operator/matrix.py b/ml/pipelines/operator/matrix.py new file mode 100644 index 000000000..f844f1cfe --- /dev/null +++ b/ml/pipelines/operator/matrix.py @@ -0,0 +1,13 @@ +"""Durable operator intent projection. Source of truth remains Issue #175 body.""" +from __future__ import annotations + +PRIORITY = [ + {"p": 0, "item": "Master dual-gate", "status": "GREEN on 9a63cc58; #836 merged a3423d97"}, + {"p": 0, "item": "ML keep-alive extract MLP-KEEP-001", "status": "THIS PR"}, + {"p": 0, "item": "Mega PRs #48/#682/#432", "status": "EXTRACT"}, + {"p": 0, "item": "Minesweeper Jules family", "status": "EXTRACT"}, + {"p": 1, "item": "#809 GAMUT / #806 eval lanes", "status": "NEED_EVIDENCE rebase"}, + {"p": 1, "item": "#184 credential inventory", "status": "names-only"}, + {"p": 2, "item": "#265 Providers RE", "status": "EXTRACT"}, + {"p": 3, "item": "Linear TER-15 #788", "status": "NEED_EVIDENCE wrong-base"}, +] diff --git a/ml/pipelines/operator/receipt.py b/ml/pipelines/operator/receipt.py new file mode 100644 index 000000000..6cabefbc0 --- /dev/null +++ b/ml/pipelines/operator/receipt.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Any + +from ml.pipelines.lib.clock import utc_now + + +def receipt(*, head_sha: str, base_sha: str, state: str, decision: str, reason: str) -> dict[str, Any]: + return { + "operation": "ml-keep-alive-extract", + "repo": "timerloggedout-spec/termux-monorepo", + "base_sha": base_sha, + "head_sha": head_sha, + "observed_at": utc_now(), + "state": state, + "outcome": state, + "decision": decision, + "reason": reason, + "issue": 175, + } diff --git a/ml/pipelines/replay/__init__.py b/ml/pipelines/replay/__init__.py new file mode 100644 index 000000000..2376b8ae3 --- /dev/null +++ b/ml/pipelines/replay/__init__.py @@ -0,0 +1 @@ +"""Replay adapters. Observe-mode only. No provider invocation.""" diff --git a/ml/pipelines/replay/adapters.py b/ml/pipelines/replay/adapters.py new file mode 100644 index 000000000..22dc6ebc3 --- /dev/null +++ b/ml/pipelines/replay/adapters.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from typing import Any + + +def adapt_pr(raw: dict[str, Any]) -> dict[str, Any]: + base = raw.get("base") + if isinstance(base, str): + raw = {**raw, "base": {"ref": base}} + return raw diff --git a/ml/pipelines/replay/ledger.py b/ml/pipelines/replay/ledger.py new file mode 100644 index 000000000..95fc37b27 --- /dev/null +++ b/ml/pipelines/replay/ledger.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import Any + +from ml.pipelines.lib.clock import utc_now + + +def ledger_row(context: dict[str, Any]) -> dict[str, Any]: + return { + "at": utc_now(), + "master_sha": context.get("master_sha"), + "issue": 175, + "lanes": context.get("lane_counts"), + "mode": "observe", + } diff --git a/ml/pipelines/replay/manus.py b/ml/pipelines/replay/manus.py new file mode 100644 index 000000000..c6bf2827f --- /dev/null +++ b/ml/pipelines/replay/manus.py @@ -0,0 +1,8 @@ +"""Manus computer-replay is EXTRACT-only (#265 / #503). Names, not payloads.""" +from __future__ import annotations + +MANUS_ISSUES = (265, 503) + + +def is_manus_lane(number: int) -> bool: + return int(number) in MANUS_ISSUES diff --git a/ml/pipelines/stages/__init__.py b/ml/pipelines/stages/__init__.py new file mode 100644 index 000000000..473399256 --- /dev/null +++ b/ml/pipelines/stages/__init__.py @@ -0,0 +1,17 @@ +from ml.pipelines.stages.evaluate import stage_evaluate +from ml.pipelines.stages.export import stage_export +from ml.pipelines.stages.features import stage_features +from ml.pipelines.stages.ingest import stage_ingest +from ml.pipelines.stages.monitor import stage_monitor +from ml.pipelines.stages.recon import stage_recon +from ml.pipelines.stages.train import stage_train + +STAGES = [ + ("recon", stage_recon), + ("ingest", stage_ingest), + ("features", stage_features), + ("train", stage_train), + ("evaluate", stage_evaluate), + ("export", stage_export), + ("monitor", stage_monitor), +] diff --git a/ml/pipelines/stages/evaluate.py b/ml/pipelines/stages/evaluate.py new file mode 100644 index 000000000..f83ff50e1 --- /dev/null +++ b/ml/pipelines/stages/evaluate.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Any + +from ml.pipelines.contracts.promote import promote_decision +from ml.pipelines.lanes.vocab import Lane + + +def stage_evaluate(context: dict[str, Any]) -> None: + decisions = [] + prs = {pr["number"]: pr for pr in context.get("prs") or []} + for row in context.get("lanes") or []: + pr = prs.get(row["number"], {}) + pr = {**pr, "lane": row["lane"]} + decisions.append( + { + "number": row["number"], + "decision": promote_decision(pr), + "lane": row["lane"], + "promotable": row["lane"] == Lane.CANDIDATE.value, + } + ) + context["decisions"] = decisions diff --git a/ml/pipelines/stages/export.py b/ml/pipelines/stages/export.py new file mode 100644 index 000000000..eb1703618 --- /dev/null +++ b/ml/pipelines/stages/export.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import Any + + +def stage_export(context: dict[str, Any]) -> None: + context["export"] = { + "master_sha": context.get("master_sha"), + "issue": 175, + "lane_counts": context.get("lane_counts"), + "operator": "ACTIVE", + } diff --git a/ml/pipelines/stages/features.py b/ml/pipelines/stages/features.py new file mode 100644 index 000000000..93ab07ed5 --- /dev/null +++ b/ml/pipelines/stages/features.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Any + +from ml.pipelines.lanes.classify import classify_pr +from ml.pipelines.moneyball.scorer import score + + +def stage_features(context: dict[str, Any]) -> None: + rows = [] + for pr in context.get("prs") or []: + lane = classify_pr(pr) + rows.append( + { + "number": pr["number"], + "lane": lane.value, + "score": score(pr), + "title": pr.get("title"), + "why": pr.get("why"), + } + ) + context["lanes"] = rows diff --git a/ml/pipelines/stages/ingest.py b/ml/pipelines/stages/ingest.py new file mode 100644 index 000000000..c3072d863 --- /dev/null +++ b/ml/pipelines/stages/ingest.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from typing import Any + + +def stage_ingest(context: dict[str, Any]) -> None: + context["prs"] = list(context["snapshot"].get("prs") or []) diff --git a/ml/pipelines/stages/monitor.py b/ml/pipelines/stages/monitor.py new file mode 100644 index 000000000..0faab673b --- /dev/null +++ b/ml/pipelines/stages/monitor.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Any + +from ml.pipelines.viz.projection import project + + +def stage_monitor(context: dict[str, Any]) -> None: + context["cctv"] = project(context.get("snapshot") or {}, context.get("lanes") or []) diff --git a/ml/pipelines/stages/recon.py b/ml/pipelines/stages/recon.py new file mode 100644 index 000000000..8437e2c3b --- /dev/null +++ b/ml/pipelines/stages/recon.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import Any + +from ml.pipelines.contracts.schema import validate_snapshot +from ml.pipelines.lib.errors import FixtureError + + +def stage_recon(context: dict[str, Any]) -> None: + snapshot = context["snapshot"] + errors = validate_snapshot(snapshot) + if errors: + raise FixtureError("; ".join(errors)) + context["master_sha"] = snapshot["master_sha"] + context["issue"] = 175 diff --git a/ml/pipelines/stages/train.py b/ml/pipelines/stages/train.py new file mode 100644 index 000000000..2bcccc25d --- /dev/null +++ b/ml/pipelines/stages/train.py @@ -0,0 +1,10 @@ +"""Observe-mode 'train' is a transparent histogram, not a model fit.""" +from __future__ import annotations + +from collections import Counter +from typing import Any + + +def stage_train(context: dict[str, Any]) -> None: + counts = Counter(row["lane"] for row in context.get("lanes") or []) + context["lane_counts"] = dict(counts) diff --git a/ml/pipelines/test_ates.py b/ml/pipelines/test_ates.py new file mode 100644 index 000000000..4168d68b1 --- /dev/null +++ b/ml/pipelines/test_ates.py @@ -0,0 +1,12 @@ +import unittest +from ml.pipelines.ates.observer import observe +from ml.pipelines.ates.spine import SPINE + +class AtesTest(unittest.TestCase): + def test_observe(self) -> None: + out = observe({"master_sha": "a3423d97"}) + self.assertEqual(out["issue"], 175) + self.assertEqual(SPINE, "runtime-evidence") + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_cctv.py b/ml/pipelines/test_cctv.py new file mode 100644 index 000000000..d368fdd90 --- /dev/null +++ b/ml/pipelines/test_cctv.py @@ -0,0 +1,14 @@ +import unittest +from ml.pipelines.lib.io import load_json +from ml.pipelines.lib.latest import latest_session_path +from ml.pipelines.viz.cctv import emit_cctv + +class CctvTest(unittest.TestCase): + def test_issue(self) -> None: + payload = emit_cctv(load_json(latest_session_path())) + self.assertEqual(payload["issue"], 175) + self.assertEqual(payload["operator"], "ACTIVE") + self.assertIn("EXTRACT", payload["lanes"]) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_classify.py b/ml/pipelines/test_classify.py new file mode 100644 index 000000000..41c5215e1 --- /dev/null +++ b/ml/pipelines/test_classify.py @@ -0,0 +1,31 @@ +import unittest +from ml.pipelines.lanes.classify import classify_pr +from ml.pipelines.lanes.vocab import Lane + +class ClassifyTest(unittest.TestCase): + def test_wrong_base(self) -> None: + pr = {"number": 48, "title": "hub", "base": {"ref": "master-staging"}} + self.assertEqual(classify_pr(pr), Lane.NEED_EVIDENCE) + def test_keep_alive(self) -> None: + pr = {"number": 682, "title": "feat(ml): keep-alive", "base": {"ref": "master"}, "keep_alive": True} + self.assertEqual(classify_pr(pr), Lane.EXTRACT) + def test_candidate(self) -> None: + pr = { + "number": 900, + "title": "feat(ops): tiny", + "base": {"ref": "master"}, + "gates": {"repo-gate": "success", "termux-smoke": "success"}, + } + self.assertEqual(classify_pr(pr), Lane.CANDIDATE) + def test_bot_extract(self) -> None: + pr = { + "number": 630, + "title": "fix dashboard", + "base": {"ref": "master"}, + "changed_files": 55, + "user": {"login": "google-labs-jules[bot]"}, + } + self.assertEqual(classify_pr(pr), Lane.EXTRACT) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_cli.py b/ml/pipelines/test_cli.py new file mode 100644 index 000000000..e10c1e4d9 --- /dev/null +++ b/ml/pipelines/test_cli.py @@ -0,0 +1,29 @@ +import json +import unittest +from ml.pipelines.cli import main +from io import StringIO +from unittest.mock import patch + +class CliTest(unittest.TestCase): + def test_status(self) -> None: + buf = StringIO() + with patch("sys.stdout", buf): + code = main(["status"]) + self.assertEqual(code, 0) + payload = json.loads(buf.getvalue()) + self.assertEqual(payload["issue"], 175) + def test_run(self) -> None: + buf = StringIO() + with patch("sys.stdout", buf): + code = main(["run"]) + self.assertEqual(code, 0) + payload = json.loads(buf.getvalue()) + self.assertTrue(payload["summary"]["ok"]) + def test_gate_48(self) -> None: + buf = StringIO() + with patch("sys.stdout", buf): + code = main(["gate", "48"]) + self.assertEqual(code, 2) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_dual_gate.py b/ml/pipelines/test_dual_gate.py new file mode 100644 index 000000000..2e97dc551 --- /dev/null +++ b/ml/pipelines/test_dual_gate.py @@ -0,0 +1,16 @@ +import unittest +from ml.pipelines.contracts.dual_gate import dual_gate_green +from ml.pipelines.contracts.non_gate import is_advisory + +class DualGateTest(unittest.TestCase): + def test_green(self) -> None: + checks = [ + {"name": "hygiene + portability gate", "conclusion": "success"}, + {"name": "agentic termux smoke", "conclusion": "success"}, + {"name": "Vercel – termux-monorepo", "conclusion": "failure"}, + ] + self.assertTrue(dual_gate_green(checks)) + self.assertTrue(is_advisory("Vercel – help-wanted-dash")) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_engine.py b/ml/pipelines/test_engine.py new file mode 100644 index 000000000..f9ea047bd --- /dev/null +++ b/ml/pipelines/test_engine.py @@ -0,0 +1,14 @@ +import unittest +from ml.pipelines.lib.engine import run_dag, summarize + +class EngineTest(unittest.TestCase): + def test_run(self) -> None: + ctx = {"n": 0} + def bump(c): + c["n"] += 1 + results = run_dag([("a", bump), ("b", bump)], ctx) + self.assertEqual(ctx["n"], 2) + self.assertTrue(summarize(results)["ok"]) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_explain.py b/ml/pipelines/test_explain.py new file mode 100644 index 000000000..d502f14ed --- /dev/null +++ b/ml/pipelines/test_explain.py @@ -0,0 +1,11 @@ +import unittest +from ml.pipelines.moneyball.explain import explain + +class ExplainTest(unittest.TestCase): + def test_shape(self) -> None: + out = explain({"number": 836, "title": "vocab", "base": {"ref": "master"}, "gates": {"repo-gate": "success", "termux-smoke": "success"}}) + self.assertEqual(out["number"], 836) + self.assertIn("weights", out) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_gate.py b/ml/pipelines/test_gate.py new file mode 100644 index 000000000..f04a38879 --- /dev/null +++ b/ml/pipelines/test_gate.py @@ -0,0 +1,14 @@ +import unittest +from ml.pipelines.contracts.gate import block_reasons, assert_promotable +from ml.pipelines.lib.errors import GateBlocked + +class GateTest(unittest.TestCase): + def test_blocks_dirty(self) -> None: + reasons = block_reasons({"mergeable_state": "dirty", "base": {"ref": "master"}}) + self.assertIn("mergeable_state=dirty", reasons) + def test_assert(self) -> None: + with self.assertRaises(GateBlocked): + assert_promotable({"number": 48, "base": {"ref": "master-staging"}}) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_hub.py b/ml/pipelines/test_hub.py new file mode 100644 index 000000000..74276812b --- /dev/null +++ b/ml/pipelines/test_hub.py @@ -0,0 +1,9 @@ +import unittest +from ml.pipelines.operator.hub import ISSUE_URL + +class HubTest(unittest.TestCase): + def test_url(self) -> None: + self.assertTrue(ISSUE_URL.endswith("/issues/175")) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_integration.py b/ml/pipelines/test_integration.py new file mode 100644 index 000000000..a2579616a --- /dev/null +++ b/ml/pipelines/test_integration.py @@ -0,0 +1,16 @@ +import unittest +from ml.pipelines.lanes.vocab import INVALID_PARKING +from ml.pipelines.lib.engine import run_dag +from ml.pipelines.lib.io import load_json +from ml.pipelines.lib.latest import latest_session_path +from ml.pipelines.stages import STAGES + +class IntegrationTest(unittest.TestCase): + def test_no_parking_labels(self) -> None: + ctx = {"snapshot": load_json(latest_session_path())} + run_dag(STAGES, ctx) + for row in ctx["lanes"]: + self.assertNotIn(row["lane"], INVALID_PARKING) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_io.py b/ml/pipelines/test_io.py new file mode 100644 index 000000000..f58667090 --- /dev/null +++ b/ml/pipelines/test_io.py @@ -0,0 +1,15 @@ +import json +import tempfile +import unittest +from pathlib import Path +from ml.pipelines.lib.io import dump_json, load_json + +class IoTest(unittest.TestCase): + def test_roundtrip(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "a.json" + dump_json(path, {"ok": True}) + self.assertEqual(load_json(path)["ok"], True) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_keepalive_dag.py b/ml/pipelines/test_keepalive_dag.py index 727a72f9b..3ec57b14b 100644 --- a/ml/pipelines/test_keepalive_dag.py +++ b/ml/pipelines/test_keepalive_dag.py @@ -11,6 +11,7 @@ DagSpec, default_dag, iter_ready_nodes, + operator_dag, render_mermaid, validate_dag, ) @@ -47,6 +48,13 @@ def test_rejects_missing_edge_target(self) -> None: ) self.assertTrue(any("missing target" in e for e in validate_dag(spec))) + def test_operator_extends_default(self) -> None: + spec = operator_dag() + self.assertEqual(validate_dag(spec), []) + self.assertTrue(spec.node_ids() >= default_dag().node_ids()) + ready = iter_ready_nodes(spec, ["ingest_events", "schema_validate", "score_throughput", "write_ledger", "export_status"]) + self.assertEqual(ready, ["recon_lanes"]) + if __name__ == "__main__": unittest.main() diff --git a/ml/pipelines/test_latest.py b/ml/pipelines/test_latest.py new file mode 100644 index 000000000..91fd362b1 --- /dev/null +++ b/ml/pipelines/test_latest.py @@ -0,0 +1,11 @@ +import unittest +from ml.pipelines.lib.latest import latest_session_path + +class LatestTest(unittest.TestCase): + def test_exists(self) -> None: + path = latest_session_path() + self.assertTrue(path.name.startswith("session_")) + self.assertTrue(path.exists()) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_ledger.py b/ml/pipelines/test_ledger.py new file mode 100644 index 000000000..ea5d9f945 --- /dev/null +++ b/ml/pipelines/test_ledger.py @@ -0,0 +1,11 @@ +import unittest +from ml.pipelines.replay.ledger import ledger_row + +class LedgerTest(unittest.TestCase): + def test_mode(self) -> None: + row = ledger_row({"master_sha": "a", "lane_counts": {"EXTRACT": 1}}) + self.assertEqual(row["mode"], "observe") + self.assertEqual(row["issue"], 175) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_matrix.py b/ml/pipelines/test_matrix.py new file mode 100644 index 000000000..1787c62f1 --- /dev/null +++ b/ml/pipelines/test_matrix.py @@ -0,0 +1,10 @@ +import unittest +from ml.pipelines.viz.matrix import render_matrix + +class MatrixTest(unittest.TestCase): + def test_p0_present(self) -> None: + rows = render_matrix() + self.assertTrue(any(r["p"] == 0 for r in rows)) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_mermaid.py b/ml/pipelines/test_mermaid.py new file mode 100644 index 000000000..d732d7ba4 --- /dev/null +++ b/ml/pipelines/test_mermaid.py @@ -0,0 +1,10 @@ +import unittest +from ml.pipelines.viz.mermaid import dag_mermaid + +class MermaidTest(unittest.TestCase): + def test_contains_recon(self) -> None: + text = dag_mermaid() + self.assertIn("recon_lanes", text) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_minesweeper.py b/ml/pipelines/test_minesweeper.py new file mode 100644 index 000000000..e6c9359de --- /dev/null +++ b/ml/pipelines/test_minesweeper.py @@ -0,0 +1,14 @@ +import unittest +from ml.pipelines.lanes.minesweeper_rules import minesweeper_extract, is_bot_author + +class MinesweeperTest(unittest.TestCase): + def test_jules(self) -> None: + pr = {"user": {"login": "google-labs-jules[bot]"}, "changed_files": 55, "title": "fix"} + self.assertTrue(is_bot_author(pr)) + self.assertTrue(minesweeper_extract(pr)) + def test_palette_title(self) -> None: + pr = {"user": {"login": "human"}, "changed_files": 3, "title": "Palette: heartbeat"} + self.assertTrue(minesweeper_extract(pr)) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_operator.py b/ml/pipelines/test_operator.py new file mode 100644 index 000000000..6b1239455 --- /dev/null +++ b/ml/pipelines/test_operator.py @@ -0,0 +1,15 @@ +import unittest +from ml.pipelines.operator.issue175 import ISSUE +from ml.pipelines.operator.matrix import PRIORITY +from ml.pipelines.operator.receipt import receipt + +class OperatorTest(unittest.TestCase): + def test_issue(self) -> None: + self.assertEqual(ISSUE, 175) + self.assertGreaterEqual(len(PRIORITY), 4) + def test_receipt(self) -> None: + row = receipt(head_sha="abc", base_sha="def", state="PASS", decision="KEEP", reason="extract") + self.assertEqual(row["issue"], 175) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_projection.py b/ml/pipelines/test_projection.py new file mode 100644 index 000000000..e15cab637 --- /dev/null +++ b/ml/pipelines/test_projection.py @@ -0,0 +1,10 @@ +import unittest +from ml.pipelines.viz.projection import project + +class ProjectionTest(unittest.TestCase): + def test_counts(self) -> None: + out = project({"master_sha": "abc1234", "session": "t"}, [{"lane": "EXTRACT", "number": 1}]) + self.assertEqual(out["lanes"]["EXTRACT"], 1) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_promote.py b/ml/pipelines/test_promote.py new file mode 100644 index 000000000..1b48295c3 --- /dev/null +++ b/ml/pipelines/test_promote.py @@ -0,0 +1,21 @@ +import unittest +from ml.pipelines.contracts.promote import promote_decision + +class PromoteTest(unittest.TestCase): + def test_allow(self) -> None: + pr = { + "base": {"ref": "master"}, + "gates": {"repo-gate": "success", "termux-smoke": "success"}, + "lane": "CANDIDATE", + } + self.assertEqual(promote_decision(pr), "ALLOW") + def test_extract_block(self) -> None: + pr = { + "base": {"ref": "master"}, + "gates": {"repo-gate": "success", "termux-smoke": "success"}, + "lane": "EXTRACT", + } + self.assertEqual(promote_decision(pr), "BLOCK") + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_replay.py b/ml/pipelines/test_replay.py new file mode 100644 index 000000000..b10dc133a --- /dev/null +++ b/ml/pipelines/test_replay.py @@ -0,0 +1,14 @@ +import unittest +from ml.pipelines.replay.adapters import adapt_pr +from ml.pipelines.replay.manus import is_manus_lane + +class ReplayTest(unittest.TestCase): + def test_adapt(self) -> None: + pr = adapt_pr({"number": 1, "base": "master"}) + self.assertEqual(pr["base"]["ref"], "master") + def test_manus(self) -> None: + self.assertTrue(is_manus_lane(265)) + self.assertFalse(is_manus_lane(175)) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_safe.py b/ml/pipelines/test_safe.py new file mode 100644 index 000000000..bad8ac51e --- /dev/null +++ b/ml/pipelines/test_safe.py @@ -0,0 +1,12 @@ +import unittest +from ml.pipelines.lib.safe import redact + +class SafeTest(unittest.TestCase): + def test_redact(self) -> None: + out = redact({"token": "abc", "ok": 1, "nested": {"password": "x"}}) + self.assertEqual(out["token"], "[redacted]") + self.assertEqual(out["nested"]["password"], "[redacted]") + self.assertEqual(out["ok"], 1) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_schema.py b/ml/pipelines/test_schema.py new file mode 100644 index 000000000..da380f129 --- /dev/null +++ b/ml/pipelines/test_schema.py @@ -0,0 +1,14 @@ +import unittest +from ml.pipelines.contracts.schema import validate_snapshot +from ml.pipelines.lib.latest import latest_session_path +from ml.pipelines.lib.io import load_json + +class SchemaTest(unittest.TestCase): + def test_fixture(self) -> None: + payload = load_json(latest_session_path()) + self.assertEqual(validate_snapshot(payload), []) + def test_missing(self) -> None: + self.assertTrue(validate_snapshot({})) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_scorer.py b/ml/pipelines/test_scorer.py new file mode 100644 index 000000000..3ad39bc7e --- /dev/null +++ b/ml/pipelines/test_scorer.py @@ -0,0 +1,17 @@ +import unittest +from ml.pipelines.moneyball.scorer import score + +class ScorerTest(unittest.TestCase): + def test_candidate_outscores_wholesale(self) -> None: + good = { + "base": {"ref": "master"}, + "gates": {"repo-gate": "success", "termux-smoke": "success"}, + "changed_files": 8, + "has_tests": True, + "age_days": 0.1, + } + bad = {"base": {"ref": "master-staging"}, "wholesale": True, "changed_files": 400, "age_days": 20} + self.assertGreater(score(good), score(bad)) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_sha.py b/ml/pipelines/test_sha.py new file mode 100644 index 000000000..6c3d6c2d4 --- /dev/null +++ b/ml/pipelines/test_sha.py @@ -0,0 +1,11 @@ +import unittest +from ml.pipelines.lib.sha import short_sha, same_sha + +class ShaTest(unittest.TestCase): + def test_short(self) -> None: + self.assertEqual(short_sha("a3423d9760dd372967a765d16696db7cc67ca300"), "a3423d97") + def test_same(self) -> None: + self.assertTrue(same_sha("a3423d97", "a3423d9760dd")) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_short_circuit.py b/ml/pipelines/test_short_circuit.py new file mode 100644 index 000000000..ad3f75367 --- /dev/null +++ b/ml/pipelines/test_short_circuit.py @@ -0,0 +1,13 @@ +import unittest +from ml.pipelines.lanes.short_circuit import may_promote +from ml.pipelines.lanes.vocab import Lane + +class ShortCircuitTest(unittest.TestCase): + def test_only_candidate(self) -> None: + self.assertTrue(may_promote(Lane.CANDIDATE)) + self.assertFalse(may_promote(Lane.EXTRACT)) + self.assertFalse(may_promote(Lane.NEED_EVIDENCE)) + self.assertFalse(may_promote(Lane.SUPERSEDE)) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_stages.py b/ml/pipelines/test_stages.py new file mode 100644 index 000000000..65cc0b85b --- /dev/null +++ b/ml/pipelines/test_stages.py @@ -0,0 +1,16 @@ +import unittest +from ml.pipelines.lib.engine import run_dag +from ml.pipelines.lib.io import load_json +from ml.pipelines.lib.latest import latest_session_path +from ml.pipelines.stages import STAGES + +class StagesTest(unittest.TestCase): + def test_pipeline(self) -> None: + ctx = {"snapshot": load_json(latest_session_path())} + run_dag(STAGES, ctx) + self.assertEqual(ctx["issue"], 175) + self.assertIn("EXTRACT", ctx["lane_counts"]) + self.assertIn("cctv", ctx) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_supersede.py b/ml/pipelines/test_supersede.py new file mode 100644 index 000000000..1893c07b5 --- /dev/null +++ b/ml/pipelines/test_supersede.py @@ -0,0 +1,11 @@ +import unittest +from ml.pipelines.lanes.supersede_rules import should_supersede + +class SupersedeTest(unittest.TestCase): + def test_stamp(self) -> None: + self.assertTrue(should_supersede({"title": "ops(skills): stamp evidence-led cycle"})) + def test_product_fresh(self) -> None: + self.assertFalse(should_supersede({"title": "feat(ml): keep-alive", "age_days": 1, "product": True})) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_vocab.py b/ml/pipelines/test_vocab.py new file mode 100644 index 000000000..e1f85a0ce --- /dev/null +++ b/ml/pipelines/test_vocab.py @@ -0,0 +1,13 @@ +import unittest +from ml.pipelines.lanes.vocab import Lane, coerce, INVALID_PARKING + +class VocabTest(unittest.TestCase): + def test_valid(self) -> None: + self.assertEqual(coerce("EXTRACT"), Lane.EXTRACT) + def test_parking_rejected(self) -> None: + for raw in INVALID_PARKING: + with self.assertRaises(ValueError): + coerce(raw) + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/test_weights.py b/ml/pipelines/test_weights.py new file mode 100644 index 000000000..cd2055360 --- /dev/null +++ b/ml/pipelines/test_weights.py @@ -0,0 +1,10 @@ +import unittest +from ml.pipelines.moneyball.weights import WEIGHTS + +class WeightsTest(unittest.TestCase): + def test_dual_gate_heaviest_positive(self) -> None: + positives = {k: v for k, v in WEIGHTS.items() if v > 0} + self.assertEqual(max(positives, key=positives.get), "dual_gate_green") + +if __name__ == "__main__": + unittest.main() diff --git a/ml/pipelines/viz/__init__.py b/ml/pipelines/viz/__init__.py new file mode 100644 index 000000000..20b8d7e87 --- /dev/null +++ b/ml/pipelines/viz/__init__.py @@ -0,0 +1 @@ +from ml.pipelines.viz.cctv import emit_cctv # noqa: F401 diff --git a/ml/pipelines/viz/cctv.py b/ml/pipelines/viz/cctv.py new file mode 100644 index 000000000..665215824 --- /dev/null +++ b/ml/pipelines/viz/cctv.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Any + +from ml.pipelines.lanes.classify import classify_pr +from ml.pipelines.moneyball.scorer import score +from ml.pipelines.viz.projection import project + + +def emit_cctv(snapshot: dict[str, Any]) -> dict[str, Any]: + lanes = [ + { + "number": pr["number"], + "lane": classify_pr(pr).value, + "score": score(pr), + "why": pr.get("why"), + "title": pr.get("title"), + } + for pr in snapshot.get("prs") or [] + ] + return project(snapshot, lanes) diff --git a/ml/pipelines/viz/matrix.py b/ml/pipelines/viz/matrix.py new file mode 100644 index 000000000..749bc9038 --- /dev/null +++ b/ml/pipelines/viz/matrix.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Any + +from ml.pipelines.operator.matrix import PRIORITY + + +def render_matrix() -> list[dict[str, Any]]: + return list(PRIORITY) diff --git a/ml/pipelines/viz/mermaid.py b/ml/pipelines/viz/mermaid.py new file mode 100644 index 000000000..e5a5e72f8 --- /dev/null +++ b/ml/pipelines/viz/mermaid.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from ml.pipelines.keepalive_dag import DagSpec, render_mermaid, operator_dag + + +def dag_mermaid(spec: DagSpec | None = None) -> str: + return render_mermaid(spec or operator_dag()) diff --git a/ml/pipelines/viz/projection.py b/ml/pipelines/viz/projection.py new file mode 100644 index 000000000..b81611df8 --- /dev/null +++ b/ml/pipelines/viz/projection.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Any + + +def project(snapshot: dict[str, Any], lanes: list[dict[str, Any]]) -> dict[str, Any]: + counts: dict[str, int] = {} + for row in lanes: + counts[row["lane"]] = counts.get(row["lane"], 0) + 1 + return { + "master_sha": snapshot.get("master_sha"), + "issue": 175, + "operator": "ACTIVE", + "lanes": counts, + "rows": lanes, + "session": snapshot.get("session"), + }