From e10b974f3e2ff05ad1daadb474802f117f5adf27 Mon Sep 17 00:00:00 2001 From: Tej Desai <29216465+wheattoast11@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:35:40 -0500 Subject: [PATCH 1/4] feat: prepare and verify bounded training goals Bind reviewed inputs to the existing trainer and pipeline owners. Retain model artifacts, compare held-out goals and preserve cancellation custody. --- .claude/skills/terminals/carl.md | 41 +- README.md | 28 + docs/carl-prepared-training-receipt.json | 478 ++++++++++ docs/carl-prepared-training-verification.md | 157 ++++ .../2026-10-08-carl-prepared-training.md | 118 +++ scripts/witness_native_harness.py | 54 +- skills/carl/SKILL.md | 41 +- src/carl_studio/__init__.py | 10 + src/carl_studio/adapters/protocol.py | 6 +- src/carl_studio/adapters/registry.py | 15 + src/carl_studio/adapters/trl_adapter.py | 10 +- src/carl_studio/bundler.py | 4 + src/carl_studio/cli/entry.py | 36 +- src/carl_studio/cli/project_data.py | 11 +- src/carl_studio/cli/shared.py | 8 + src/carl_studio/cli/training.py | 153 +++- src/carl_studio/cli/wiring.py | 17 +- src/carl_studio/eval/runner.py | 130 ++- src/carl_studio/experiment/manager.py | 136 ++- src/carl_studio/mcp/output_schemas.py | 13 + src/carl_studio/mcp/protocol.py | 1 + src/carl_studio/mcp/server.py | 128 +-- src/carl_studio/mcp/tasks.py | 48 +- src/carl_studio/run_diff.py | 33 + src/carl_studio/training/acceptance.py | 82 ++ src/carl_studio/training/pipeline.py | 413 ++++++++- src/carl_studio/training/preparation.py | 821 ++++++++++++++++++ src/carl_studio/training/trainer.py | 184 +++- src/carl_studio/types/config.py | 14 +- src/carl_studio/types/preparation.py | 136 +++ src/carl_studio/types/run.py | 14 +- tests/test_mcp_tasks.py | 32 + tests/test_mcp_v2_wire.py | 107 +++ tests/test_pipeline.py | 141 ++- tests/test_prepared_training.py | 777 +++++++++++++++++ tests/test_run_diff.py | 23 + 36 files changed, 4143 insertions(+), 277 deletions(-) create mode 100644 docs/carl-prepared-training-receipt.json create mode 100644 docs/carl-prepared-training-verification.md create mode 100644 docs/plans/2026-10-08-carl-prepared-training.md create mode 100644 src/carl_studio/training/acceptance.py create mode 100644 src/carl_studio/training/preparation.py create mode 100644 src/carl_studio/types/preparation.py create mode 100644 tests/test_prepared_training.py diff --git a/.claude/skills/terminals/carl.md b/.claude/skills/terminals/carl.md index 0018fe8..c359a49 100644 --- a/.claude/skills/terminals/carl.md +++ b/.claude/skills/terminals/carl.md @@ -9,10 +9,43 @@ Use the installed CARL MCP tools when available. Use the CARL CLI otherwise. CARL means Coherence-Aware Reinforcement Learning. 1. Inspect the current project and the requested outcome. -2. Use `validate_config` before starting training. -3. Use observe and eval to measure the result the user requested. -4. Use `list_skills` and `run_skill` for existing CARL workflows. -5. Use `list_agent_harnesses` and `delegate_agent` when another native agent should do part of the work. +2. For a goal, use `prepare_training` before starting training. Schema validation + alone does not establish data, graders, reward hooks or resource readiness. +3. Present the prepared inputs, task checks, policies, model, limits and effects. + Pass the returned `plan_id` as `prepared_plan_id` to `start_training` or + `submit_async_training` only after the user has authorized that run. +4. Keep execution completion separate from candidate acceptance. Return the + checkpoint, baseline comparison, acceptance reasons and reuse instructions. +5. Use `list_skills` and `run_skill` for existing CARL workflows. +6. Use `list_agent_harnesses` and `delegate_agent` when another native agent should do part of the work. + +## Goal preparation + +Discover the project root, selected interpreter, existing pipeline and relevant +task fixtures. Preserve the model, backend and pipeline choices. Ask only for +information the project cannot supply, one question at a time. Offer the two +requested Tinny modes; keep universal-role ablation in advanced details. + +Prepare compatible local data with stable task IDs and separate held-out tasks. +Use existing data adapters, verification records and kits. Reuse executable +project checks and source references. Never substitute unrelated datasets, +invent a grader, infer task success from answer length or move legality, or +describe a simulated environment as an independent verdict. + +Resolve readiness issues before submitting. Missing model/data inputs need an +authorized preparation action. Missing dependencies need an explicit install +proposal. The first qualified goal integration uses local TRL; other backends +declare their capabilities. Python pipelines can attach explicit reward and +evaluator callables to the existing trainer and eval runner. + +Translate relevant user policies into explicit checks or existing execution +controls. State which rules are measured and which are enforced during tool +execution. A description in a prompt is not an executed policy check. + +Repeated prepared submission reuses its recorded result. Explicit resume uses +recorded checkpoints; unresolved execution custody needs reconciliation. +Retain metadata and approved artifacts. Do not use raw conversation exports, +credentials or hidden reasoning as automatic training data. Local plugin access is FREE. Scheduled autonomy, paid services, publication, and private-runtime operations retain their own authorization and entitlement checks. diff --git a/README.md b/README.md index 1da667c..b9ef548 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,34 @@ Full extras matrix, reproducible installs via `uv.lock`, and conflict rules (e.g ## Agent plugin +CARL is a training and reinforcement-learning agent and harness for improving +models toward your goals and policies through your existing AI tools and +training pipeline. It prepares the inputs, adds coherence signals, evaluates +the candidate against its starting model, and returns a reusable artifact. + +In your agent, ask CARL to improve a model for a particular task. CARL prepares +the experiment before training and shows the inputs, limits and missing pieces. +Task results, policy checks, coherence and cost stay visible separately. + +For the first qualified local TRL integration: + +```bash +carl train --prepare --goal "Improve held-out task success" --config carl.yaml +carl train --prepared-plan Eprep_YOUR_PLAN_ID --config carl.yaml +``` + +Preparation reads local model bytes and JSON/JSONL task examples, binds the +held-out split and grader, and records a repeatable experiment. It does not +download a model, install packages, submit a job or publish. Its readiness +response distinguishes source checks from actual training and evaluation. +Declare output policies in the goal's `policies` list. Existing execution +permissions remain in force. Public publication is separate. + +The MCP equivalents are `prepare_training`, `start_training` and +`submit_async_training`, with the prepared experiment ID on submission. +Python callers can provide `task_reward_funcs` to `CARLTrainer` and `evaluator` +plus `primary_metric` to `EvalRunner` without replacing their training loop. + From a CARL checkout: ```bash diff --git a/docs/carl-prepared-training-receipt.json b/docs/carl-prepared-training-receipt.json new file mode 100644 index 0000000..7a608f6 --- /dev/null +++ b/docs/carl-prepared-training-receipt.json @@ -0,0 +1,478 @@ +{ + "last_updated": "2026-10-08", + "author": "Codex", + "applies_to": "carl-studio 0.21.0", + "artifact_contract": "ConstructionReceipt", + "construction_contract": "docs/plans/2026-10-08-carl-prepared-training.md", + "baseline": "5be38462a9c3dba089e24f1386a945dc2469b99c", + "branch": "codex/carl-prepared-training", + "pre_existing_dirty": [], + "owned_files": [ + ".claude/skills/terminals/carl.md", + "README.md", + "docs/carl-prepared-training-receipt.json", + "docs/carl-prepared-training-verification.md", + "docs/plans/2026-10-08-carl-prepared-training.md", + "scripts/witness_native_harness.py", + "skills/carl/SKILL.md", + "src/carl_studio/__init__.py", + "src/carl_studio/adapters/protocol.py", + "src/carl_studio/adapters/registry.py", + "src/carl_studio/adapters/trl_adapter.py", + "src/carl_studio/bundler.py", + "src/carl_studio/cli/entry.py", + "src/carl_studio/cli/project_data.py", + "src/carl_studio/cli/shared.py", + "src/carl_studio/cli/training.py", + "src/carl_studio/cli/wiring.py", + "src/carl_studio/eval/runner.py", + "src/carl_studio/experiment/manager.py", + "src/carl_studio/mcp/output_schemas.py", + "src/carl_studio/mcp/protocol.py", + "src/carl_studio/mcp/server.py", + "src/carl_studio/mcp/tasks.py", + "src/carl_studio/run_diff.py", + "src/carl_studio/training/acceptance.py", + "src/carl_studio/training/pipeline.py", + "src/carl_studio/training/preparation.py", + "src/carl_studio/training/trainer.py", + "src/carl_studio/types/config.py", + "src/carl_studio/types/preparation.py", + "src/carl_studio/types/run.py", + "tests/test_mcp_tasks.py", + "tests/test_mcp_v2_wire.py", + "tests/test_pipeline.py", + "tests/test_prepared_training.py", + "tests/test_run_diff.py" + ], + "source_sha256": { + ".claude/skills/terminals/carl.md": "22fe5b356980c6ca4465a78ec85187701f0214d59c06fc40a4ae285647ac1136", + "README.md": "83511abd82c39409198de295cd64b3bcbddbe4b80010d2793faaddce71d112ca", + "scripts/witness_native_harness.py": "e83b3b590d9b6d18de2cfc71e3dc830570b1ae86dfec000efd916fc278dbb8fc", + "skills/carl/SKILL.md": "3b0042b3d8db2796b2a90149b17440f2bff496657d66d7e6ce962566539b26e7", + "src/carl_studio/__init__.py": "5676ceae188261610fe454e0175f99d9fc8aefb7b4caa64a0f16141315cfb4fe", + "src/carl_studio/adapters/protocol.py": "19172f3e106084170e060c6abfcd61e87b44f9b9ba894371ab058550444250f5", + "src/carl_studio/adapters/registry.py": "065281d6affa0bb1e5d63641b93ef7f04953df7ab1f52f525e970af749d738cf", + "src/carl_studio/adapters/trl_adapter.py": "f3c4a9b3843a7e1caabb2917b95e31f477e85efda16ec5596544e43efd124748", + "src/carl_studio/bundler.py": "c4cd4706013544e1de5f14e02b402022c68961cb5e490dc8e95901356e81f03a", + "src/carl_studio/cli/entry.py": "d2ef0cc96deba7c3ff8e4415d5a8e5c6e4d9a7603b3055419181157990236273", + "src/carl_studio/cli/project_data.py": "05d800f79be1b87db2c57a2a183e7f533dce3366cfe5842663cea9f0fcd4423d", + "src/carl_studio/cli/shared.py": "d6b4a218f25a2f2363fe99445ae0e2f670899602feef7fc1ebb42a4848aacf8b", + "src/carl_studio/cli/training.py": "670a33567137f21b9bf81b85b070b27313e5bdd695bfbefd3e3046f323ac7797", + "src/carl_studio/cli/wiring.py": "cf0c7f5893f5a4fd5527cfebe81321d00784816d147cc438375f53406d243ff6", + "src/carl_studio/eval/runner.py": "be8d8118e8d412cc7849fffe5717fd20edf31ee9762df58318ca3c45985bf226", + "src/carl_studio/experiment/manager.py": "0a0d9663136d27f4786334ba2b0dae9a2d1687773bd06cdf412b2ee4b106927b", + "src/carl_studio/mcp/output_schemas.py": "4d907752a53da8322267a717ec2428e6f367442b33525a9bf44b618a6a50bb01", + "src/carl_studio/mcp/protocol.py": "854b3c6a123f9509257775e349fdf19bc40c6b2e74598895d7a0759914537f5a", + "src/carl_studio/mcp/server.py": "3a196f8eede2f5c95333d7d5607f23ea9023cec6333191af6d9d9acc715cc1e8", + "src/carl_studio/mcp/tasks.py": "a6d3a3a4f10381e0d8335f1f65c16e0400d366f551557d21fa5e151c3b80150d", + "src/carl_studio/run_diff.py": "f9dbcd4ae1f33fe592b30275e91fb83dac2761bd416b56d67076e956d840a6ee", + "src/carl_studio/training/acceptance.py": "83971e4018606c646f990f3542216875be62af1c05c06f69285e0d0bc60a26da", + "src/carl_studio/training/pipeline.py": "0f5f07548707946bb5541159dc359b8c8af6644b39f99a5a03ae56ca4d9a55f3", + "src/carl_studio/training/preparation.py": "b6073c9395a801c3ec0e65921a5e683f429525669febd5f845f0f17a1ca66372", + "src/carl_studio/training/trainer.py": "40c6dc1b76360dd3fe0bff01f3d67490aeb49638bda9631fc654bd30c7fe03c6", + "src/carl_studio/types/config.py": "1d4d250a020d70560e9132a25d36c8ee51a116eb1d6900eb6562dbf0b41e9aee", + "src/carl_studio/types/preparation.py": "09d09571c0d1095fd2522d69bab971bfab3abc35ddf3afd53c2fd8729a1fc06c", + "src/carl_studio/types/run.py": "5f307c04d2d43fdc34395333d4e6eba76940da342cdd7d71594aaa8bf5bcd3cc", + "tests/test_mcp_tasks.py": "fb9a8f4ce1d194050df7cf88abc672a7be1e2e66ddeb201df2efc1479c4c20f1", + "tests/test_mcp_v2_wire.py": "63a6d2cd44e3cc4d3a3d3e054e0ba36104fb520062410e3cc87140ad11c2fecb", + "tests/test_pipeline.py": "3592cfca535837a800a462b31ea635cb69ce638007e739514998ac178a91d4ba", + "tests/test_prepared_training.py": "c3b79a6d44bbc73c7d52c37e5256ee6aa5d2691b3e337e2c83b9240ee6401741", + "tests/test_run_diff.py": "67b717b93b0b04cfc6827c4d61604f778a3cdcc3d885b7049196e52745e27044" + }, + "verification": { + "suite": { + "command": "HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 .venv/bin/python -m pytest tests/ packages/carl-core/tests/ -q --tb=short -p no:cacheprovider", + "passed": 4378, + "skipped": 29, + "warnings": 77, + "elapsed_seconds": 88.39, + "evidence": "Executed offline tests with model/trainer/generation fixtures" + }, + "pyright": { + "command": ".venv/bin/pyright src/carl_studio/types/preparation.py src/carl_studio/training/preparation.py src/carl_studio/training/acceptance.py", + "errors": 0, + "warnings": 0 + }, + "ruff": { + "command": ".venv/bin/ruff check src/carl_studio/types/preparation.py src/carl_studio/training/preparation.py src/carl_studio/training/acceptance.py tests/test_prepared_training.py tests/test_pipeline.py tests/test_mcp_v2_wire.py", + "status": "passed", + "changed_set_baseline_diagnostics": 164, + "changed_set_current_diagnostics": 160, + "introduced_diagnostics": [] + }, + "trl_config_constructors": { + "version": "1.14.2", + "methods": [ + "SFT", + "GRPO" + ], + "max_steps": 64, + "push_to_hub": false, + "model_loading": false, + "warning": "Optional bitsandbytes CPU kernel trust warning; trust settings unchanged" + }, + "wheel_python_files_matched": 297, + "built_wheel_import": "passed", + "optional_imports": "lazy", + "native_preparation": [ + { + "host": "codex", + "receipt": "/var/home/zero/carl/build/native-probes/codex-prepare-8ce477/receipt.json", + "elapsed_s": 1.92, + "source_hashes_match": true + }, + { + "host": "claude", + "receipt": "/var/home/zero/carl/build/native-probes/claude-prepare-4aca6f/receipt.json", + "elapsed_s": 3.12, + "source_hashes_match": true + }, + { + "host": "opencode", + "receipt": "/var/home/zero/carl/build/native-probes/opencode-prepare-cf0630/receipt.json", + "elapsed_s": 4.54, + "source_hashes_match": true + } + ], + "native_provider": "synthetic localhost", + "model_training_executed": false + }, + "artifacts": { + "wheel": { + "path": "build/prepared-training/carl_studio-0.21.0-py3-none-any.whl", + "sha256": "f040079c70c493da354b1ea5f1ecdf20cf0b2bc2ff647ac9b12b9e2c61a40757" + }, + "sdist": { + "path": "build/prepared-training/carl_studio-0.21.0.tar.gz", + "sha256_record": "build/prepared-training/artifact-receipt.json" + } + }, + "review": { + "status": "complete", + "verdict": "Ready with fixes", + "reviewers": [ + "correctness", + "project-standards", + "testing", + "maintainability", + "agent-native", + "security", + "performance", + "api-contract", + "reliability", + "adversarial" + ], + "actionable_findings": [ + { + "#": 1, + "autofix_class": "gated_auto", + "confidence": 100, + "file": "src/carl_studio/cli/training.py", + "first_evidence": "src/carl_studio/cli/training.py:713: if prepared_plan_id is not None or training_config.goal is not None:", + "independent_reviewers": [ + "api-contract", + "correctness" + ], + "line": 713, + "owner": "downstream-resolver", + "pre_existing": false, + "requires_verification": true, + "reviewers": [ + "api-contract", + "correctness" + ], + "severity": "P1", + "suggested_fix": "Handle --dry-run before prepared submission; assert submit_training is not called.", + "title": "Prepared dry runs execute training", + "why_it_matters": "A prepared dry run starts baseline evaluation and training before reaching the dry-run guard.", + "evidence": [ + "src/carl_studio/cli/training.py:713: if prepared_plan_id is not None or training_config.goal is not None:" + ], + "validated": true + }, + { + "#": 2, + "autofix_class": "manual", + "confidence": 100, + "file": "src/carl_studio/training/pipeline.py", + "first_evidence": "src/carl_studio/eval/runner.py:1323 -- tokenizer = AutoTokenizer.from_pretrained(self.config.checkpoint)", + "independent_reviewers": [ + "adversarial", + "correctness" + ], + "line": 582, + "owner": "downstream-resolver", + "pre_existing": false, + "requires_verification": true, + "reviewers": [ + "adversarial", + "correctness" + ], + "severity": "P1", + "suggested_fix": "Propagate optional tokenizer_source through EvalConfig and every stage gate.", + "title": "Prepared baseline ignores the selected tokenizer", + "why_it_matters": "The baseline uses its checkpoint tokenizer while training and the candidate may use the selected override, invalidating comparison or failing chat formatting.", + "evidence": [ + "src/carl_studio/eval/runner.py:1323 -- tokenizer = AutoTokenizer.from_pretrained(self.config.checkpoint)" + ], + "validated": true + }, + { + "#": 3, + "autofix_class": "manual", + "confidence": 75, + "file": "src/carl_studio/training/pipeline.py", + "first_evidence": "src/carl_studio/training/pipeline.py:325 -- if not self._check_gate(run):", + "independent_reviewers": [ + "performance" + ], + "line": 325, + "owner": "downstream-resolver", + "pre_existing": false, + "requires_verification": true, + "reviewers": [ + "performance" + ], + "severity": "P1", + "suggested_fix": "Run stage evaluation gates in a shielded executor and await cleanup on cancellation.", + "title": "Pipeline evaluation gates stall status and cancellation", + "why_it_matters": "Synchronous model evaluation stalls the async pipeline, preventing status and cancellation processing.", + "evidence": [ + "src/carl_studio/training/pipeline.py:325 -- if not self._check_gate(run):" + ], + "validated": true + }, + { + "#": 4, + "autofix_class": "gated_auto", + "confidence": 100, + "file": "skills/carl/SKILL.md", + "first_evidence": "skills/carl/SKILL.md:15 -- Use the returned `prepared_plan_id` with `start_training` or", + "independent_reviewers": [ + "agent-native", + "api-contract" + ], + "line": 15, + "owner": "downstream-resolver", + "pre_existing": false, + "requires_verification": true, + "reviewers": [ + "agent-native", + "api-contract" + ], + "severity": "P2", + "suggested_fix": "Pass returned plan_id as prepared_plan_id; update canonical and projected guidance.", + "title": "Canonical skill names the wrong preparation response field", + "why_it_matters": "Agents are told to consume a field absent from the preparation result.", + "evidence": [ + "skills/carl/SKILL.md:15 -- Use the returned `prepared_plan_id` with `start_training` or" + ], + "validated": true + }, + { + "#": 5, + "autofix_class": "gated_auto", + "confidence": 75, + "file": "src/carl_studio/mcp/server.py", + "first_evidence": "src/carl_studio/mcp/server.py:446 -- prepared = prepare_config(config, project_root=Path(project_root) if project_root else None)", + "independent_reviewers": [ + "performance" + ], + "line": 446, + "owner": "downstream-resolver", + "pre_existing": false, + "requires_verification": true, + "reviewers": [ + "performance" + ], + "severity": "P2", + "suggested_fix": "Offload preparation and validation hashing to a worker with awaited cleanup.", + "title": "Checkpoint hashing blocks the MCP event loop", + "why_it_matters": "Exact hashing of model files runs synchronously on the MCP event loop and delays task controls.", + "evidence": [ + "src/carl_studio/mcp/server.py:446 -- prepared = prepare_config(config, project_root=Path(project_root) if project_root else None)" + ], + "validated": true + }, + { + "#": 6, + "autofix_class": "gated_auto", + "confidence": 75, + "file": "src/carl_studio/training/pipeline.py", + "first_evidence": "src/carl_studio/training/pipeline.py:566 -- evaluator = resolve_callable(prepared.goal.evaluator, root)", + "independent_reviewers": [ + "security" + ], + "line": 566, + "owner": "downstream-resolver", + "pre_existing": false, + "requires_verification": true, + "reviewers": [ + "security" + ], + "severity": "P2", + "suggested_fix": "Sanitize evaluator/reward/policy resolution failures before telemetry or task persistence.", + "title": "CWE-532 callable import errors escape sanitized boundary", + "why_it_matters": "A project module import exception can expose private text in MCP telemetry or asynchronous task failures.", + "evidence": [ + "src/carl_studio/training/pipeline.py:566 -- evaluator = resolve_callable(prepared.goal.evaluator, root)" + ], + "validated": true + }, + { + "#": 7, + "autofix_class": "gated_auto", + "confidence": 75, + "file": "src/carl_studio/training/pipeline.py", + "first_evidence": "src/carl_studio/training/pipeline.py:660 -- run = await pipeline.run()", + "independent_reviewers": [ + "reliability" + ], + "line": 660, + "owner": "downstream-resolver", + "pre_existing": false, + "requires_verification": true, + "reviewers": [ + "reliability" + ], + "severity": "P2", + "suggested_fix": "Retain the active pipeline stage TrainingRun before awaiting its execution.", + "title": "Pipeline cancellation persists placeholder instead of active stage", + "why_it_matters": "Cancellation saves a placeholder run instead of the active stage checkpoint and configuration needed for resume.", + "evidence": [ + "src/carl_studio/training/pipeline.py:660 -- run = await pipeline.run()" + ], + "validated": true + }, + { + "#": 8, + "autofix_class": "gated_auto", + "confidence": 75, + "file": "src/carl_studio/training/preparation.py", + "first_evidence": "src/carl_studio/training/preparation.py:650 -- config=config.model_dump(mode=\"json\"),", + "independent_reviewers": [ + "security-extra" + ], + "line": 650, + "owner": "downstream-resolver", + "pre_existing": false, + "requires_verification": true, + "reviewers": [ + "security-extra" + ], + "severity": "P2", + "suggested_fix": "Reject literal credential-shaped passthrough fields with a constant error; preserve numeric token budgets.", + "title": "CWE-200 preparation echoes and persists credential passthrough values", + "why_it_matters": "Unrestricted extra_args can contain literal credentials that preparation persists and returns through MCP.", + "evidence": [ + "src/carl_studio/training/preparation.py:650 -- config=config.model_dump(mode=\"json\")," + ], + "validated": true + } + ], + "testing_gaps": [ + "The new running-worker cancellation branches in mcp/tasks.py:830-854 lack tool-level coverage: existing tests/test_mcp_tasks.py tool tests cover pending, terminal, and unknown tasks only. Add an owned running task whose cleanup is observable and verify cancellation waits for cleanup; also cover a running task absent from _live_tasks returning execution_not_owned without changing its persisted status.", + "Both new cancellation tests stop their fake worker by directly reading CARLTrainer._cancel_requested. Real TRL training instead stops when the registered callback sets control.should_training_stop, so these tests remain green if callback registration or its control update breaks. A callback-driven fake would cover the cancellation mechanism that prevents training from continuing after cancellation. Have the fake trainer invoke its registered on_step_end callback and terminate only when its returned control.should_training_stop becomes true; assert should_save too. Bound the thread and task waits so a broken callback fails instead of hanging the test process." + ], + "coverage": { + "local_personas": "All 10 terminal outcomes collected; security supplemental outcome collected.", + "cross_model": "Not started: explicit local-only task authorization; local adversarial fallback completed.", + "validators": "One fresh independent batch validated all 8 findings; no shortcut or dropped findings.", + "standards": "Root AGENTS.md and CLAUDE.md fallback; no designated CODING_STANDARDS or declared packs.", + "untracked_exclusions": [], + "mode_aware_demotions": 1, + "verification": "Parent reports frozen-byte 4368 passed,29 skipped; offline wheel/sdist build;297 packaged Python files byte-match; lightweight installed imports; native prepare loops across3hosts. Reviewers inspected sources; did not rerun training.", + "source_identity": "Base-to-working-tree diff includes staged and unstaged changes." + }, + "run_id": "carl-prepared-20261008" + }, + "review_closure": { + "status": "complete", + "date": "2026-10-08", + "scope": "Closure of prior eight CE findings only", + "findings": [ + { + "#": 1, + "status": "closed", + "evidence_class": "current source and test-source inspection", + "source": "src/carl_studio/cli/training.py:726-751", + "evidence": "Prepared dry-run returns before submission; test_prepared_dry_run_does_not_execute_training." + }, + { + "#": 2, + "status": "closed", + "evidence_class": "current source and test-source inspection", + "source": "src/carl_studio/training/pipeline.py:599; src/carl_studio/eval/runner.py:1224,1326", + "evidence": "Selected tokenizer propagated through measurements and stage gates; owner-loop tokenizer assertions." + }, + { + "#": 3, + "status": "closed", + "evidence_class": "current source and test-source inspection", + "source": "src/carl_studio/training/pipeline.py:231,285,332,380-383", + "evidence": "All stage gates await run_in_worker; responsive cancellation regression." + }, + { + "#": 4, + "status": "closed", + "evidence_class": "current source and test-source inspection", + "source": "skills/carl/SKILL.md:15; .claude/skills/terminals/carl.md:15", + "evidence": "Both guides map returned plan_id to prepared_plan_id." + }, + { + "#": 5, + "status": "closed", + "evidence_class": "current source and test-source inspection", + "source": "src/carl_studio/mcp/server.py:448-450; src/carl_studio/training/pipeline.py:570,654,679", + "evidence": "Preparation and validation await context-preserving worker; legacy/auto wire responsiveness test." + }, + { + "#": 6, + "status": "closed", + "evidence_class": "current source and test-source inspection", + "source": "src/carl_studio/training/pipeline.py:645-652,723-729", + "evidence": "Callable import resolution inside constant-error boundary; invented import canary regression." + }, + { + "#": 7, + "status": "closed", + "evidence_class": "current source and test-source inspection", + "source": "src/carl_studio/training/pipeline.py:208,261,322,718-729", + "evidence": "Active stage run retained before awaits and persisted on cancellation; actual-stage identity/checkpoint test." + }, + { + "#": 8, + "status": "closed", + "evidence_class": "current source and test-source inspection", + "source": "src/carl_studio/training/preparation.py:410-418", + "evidence": "Credential-shaped fields refused before serialization; api_key canary refusal and numeric token-budget acceptance test." + } + ], + "residuals": [], + "additional_coverage": [ + "Owned task cancellation waits for cleanup: tests/test_mcp_tasks.py:371-393.", + "Unowned running task retains status: tests/test_mcp_tasks.py:395-401.", + "Stop/save callback exercised: tests/test_prepared_training.py:688-708.", + "Existing send-it tier gate precedes prepared dispatch: cli/training.py:714-726." + ], + "verification": "Parent reports earlier 8 repair checks and owning160 passed; latest expanded result pending. Closure reviewer ran no tests/training/network.", + "training_qualification": "Source and synthetic/mocked regression evidence remain distinct from actual model training." + }, + "effects_not_executed": [ + "model training", + "paid provider calls", + "GPU model workload", + "public publication", + "deployment", + "migration", + "host plugin installation", + "git push" + ], + "rollback": "Revert the explicit feature commit; preserve owner records and sibling Tin files", + "reviewed_transcript": { + "sha256": "9defa22397d360565470d560f04f5bfa35e28504014d8dfc4eb507d32459d2fc", + "deleted": true, + "raw_content_retained": false + } +} diff --git a/docs/carl-prepared-training-verification.md b/docs/carl-prepared-training-verification.md new file mode 100644 index 0000000..1257e18 --- /dev/null +++ b/docs/carl-prepared-training-verification.md @@ -0,0 +1,157 @@ +--- +last_updated: 2026-10-08 +author: Codex +applies_to: carl-studio 0.21.0 +--- + +# Prepared training verification + +CARL prepares a bounded experiment, consumes matching reviewed inputs through +the existing trainer or SendItPipeline, compares the baseline and candidate, +and retains the checkpoint and acceptance result at the experiment owner. + +The source baseline is `5be38462a9c3dba089e24f1386a945dc2469b99c`. +The feature branch is `codex/carl-prepared-training`. +The CARL checkout was clean before this work. The parent agent owns the changes; +reviewers were read-only. The approved construction contract is in +[the plan](plans/2026-10-08-carl-prepared-training.md). + +## Entry points + +```bash +carl train --prepare --config carl.yaml --goal "Improve held-out task success" +carl train --prepared-plan Eprep_YOUR_PLAN_ID --config carl.yaml --dry-run +carl train --prepared-plan Eprep_YOUR_PLAN_ID --config carl.yaml +``` + +MCP `prepare_training` returns `plan_id`. Pass that value as `prepared_plan_id` +to `start_training` or `submit_async_training` after authorization. +Python pipelines can use `prepare_training` and `submit_training`, or attach +their task rewards and evaluator directly to `CARLTrainer` and `EvalRunner`. + +The first goal integration uses local TRL SFT and GRPO. Preparation checks +source files, local data, split separation, callable declarations, dependencies, +step limits and adapter capabilities. It does not execute the grader or load +model weights. Other backend goal hooks require their own qualified integration. +Remote legacy configuration calls retain their existing route. + +Primary metric thresholds use the existing evaluation gate's [0, 1] scale. +Acceptance requires the absolute goal target, strictly positive improvement +beyond the declared margin, required policy results and complete full-logit +coherence measurements. Missing required measurements yield `inconclusive`. +Execution completion and candidate acceptance remain separate. + +## Executed checks + +```bash +HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 .venv/bin/python -m pytest tests/ packages/carl-core/tests/ -q --tb=short -p no:cacheprovider +``` + +Result: **4,378 passed, 29 skipped, 77 warnings**, 88.39 seconds. +Warnings include existing marker and dependency deprecations; the new +ExperimentManager cases also exercise its existing `datetime.utcnow` default. +Tests use mocked model/trainer boundaries for weight updates and generation. +Real owner persistence, evaluator logic, callbacks, executor cleanup, task tools +and MCP transport run inside those tests. + +```bash +.venv/bin/ruff check src/carl_studio/types/preparation.py src/carl_studio/training/preparation.py src/carl_studio/training/acceptance.py tests/test_prepared_training.py tests/test_pipeline.py tests/test_mcp_v2_wire.py +.venv/bin/pyright src/carl_studio/types/preparation.py src/carl_studio/training/preparation.py src/carl_studio/training/acceptance.py +git diff --check HEAD +``` + +Ruff passed on the new modules and selected test files. +Strict Pyright returned zero errors and zero warnings. +Whitespace checks passed. Across the complete changed Python set, comparison +with the baseline found zero introduced Ruff diagnostics; existing diagnostics +numbered 164 at baseline and 160 after this change. + +The installed TRL 1.14.2 `SFTConfig` and `GRPOConfig` constructors accepted the +actual trainer arguments with `max_steps=64`, publication disabled, `bf16=False` +and reporting disabled. This was a CPU configuration check without model loads. +An optional bitsandbytes CPU-kernel trust warning was emitted. No dependency or +trust setting changed. + +## Falsifiers and repair checks + +| Contract | Executable check | Result | +|---|---|---| +| Preparation needs real inputs | Missing data, absent verification, overlapping tasks, unsupported hooks and unbounded steps | Refused before trainer construction | +| Reviewed sources control execution | Model templates, model membership, helpers, package files and resume membership change | Stale preparation refused | +| Reward measures the declared task | Long wrong answer versus actual target; malformed count, NaN and infinity | Wrong answer scores zero; malformed rewards refused | +| Comparison requires improvement | Unchanged and regressed candidates; high coherence with wrong task; policy failure; missing coherence | Rejected or inconclusive as specified | +| Starting lineage survives training | Base, original adapter A, SFT adapter B, GRPO adapter C | Ordered loading retained | +| Selected tokenizer controls both evaluations | Real-owner loop and stage-gate configuration capture | Bound source propagated | +| Dry run preserves the execution boundary | Prepared CLI dry run with a submission spy | No submission | +| Cancellation retains custody | Registered stop/save callback, repeated cancellation, active pipeline stage and task-tool cleanup | Cleanup awaited; actual stage saved | +| Status queries remain responsive | Legacy and modern MCP requests during blocked fixture hashing | Query completes while preparation is pending | +| Errors and settings preserve privacy | Malformed YAML, module-import canary and literal credential passthrough canary | Constant errors; credentials refused before persistence | +| Replays avoid a second training effect | Repeated submission through real ExperimentManager | Prior result reused | +| Failed gates stop promotion | Single-stage and SFT/GRPO pipeline negatives | Publication not invoked | + +The original pipeline gate regressions failed before repair. +Lower-direction and repeated-cancellation regressions failed before repair. +Six final review regressions failed before the corresponding repairs. +The final suite includes each positive and negative case. + +## Build and native witnesses + +```bash +UV_CACHE_DIR=/tmp/carl-uv-cache uv build --offline --out-dir build/prepared-training +.venv/bin/python scripts/project_carl_plugin.py +HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 .venv/bin/python scripts/witness_native_harness.py codex prepare +HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 .venv/bin/python scripts/witness_native_harness.py claude prepare +HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 .venv/bin/python scripts/witness_native_harness.py opencode prepare +``` + +The source distribution and wheel built using the existing offline cache. +All 297 packaged Python files matched source bytes. An isolated wheel install +imported the public preparation APIs without loading torch, transformers or TRL. +The canonical skill generated its repository host projection. + +Each real native host completed the actual `prepare_training` tool loop with +a synthetic localhost provider. The witness requires the prepared result in +the next provider request; final text alone cannot satisfy it. Preparation, +MCP and harness source hashes were rechecked against each receipt. +These witnesses execute native protocol and permission handling, not training. +Receipt paths and artifact hashes are in +[the machine-readable record](carl-prepared-training-receipt.json). + +## Independent review and reductions + +Compound Engineering review completed with ten local persona outcomes and +an independent finding validator. The parent applied all eight justified +findings. A separate current-source closure check returned no residual findings. +External code review was not used; the local adversarial fallback ran. +The durable record retains the review and closure results. + +Three read-only simplification lenses covered reuse, quality and efficiency. + +| Candidate | Disposition | Preservation and check | +|---|---|---| +| Duplicate implementation-source lists | Shared helper | Same paths, authority, effects, failure state and stale checks; preparation suite passed | +| Duplicate goal-progress predicate | Shared pure predicate | Same threshold, direction and margin; acceptance and witness tests passed | +| Duplicate executor cancellation handling | Shared context-preserving worker helper | Same cleanup boundary; callback, repeated cancellation, gate and wire tests passed | +| Typed empty-list factories | Kept | Preserve strict type inference; Pyright passed | +| Parallel reward argument lists | Kept | Preserve the current injection surface and alignment checks; trainer tests passed | +| Hash checks at multiple boundaries | Kept | Detect edits before execution and after evaluation; stale-input checks passed | +| Full local-data parsing during preparation/evaluation | Kept | Preserve validation of the bound file and complete split checks | +| Stage and final candidate evaluation | Kept | Preserve the existing stage gate and separate policy/comparison evaluation | +| Per-sample allocator cleanup | Kept | No model-memory witness authorized for an allocation change | + +## Boundaries and rollback + +Actual weight updates, device execution, measured Tinny improvement, remote +provider spending, migrations, service changes and public publication were not +executed. They need their selected model/data inputs and resource authorization. +Caller Python graders run as trusted project code during authorized execution. +Running work without live worker custody remains in its recorded state until +reconciliation; an unowned cancellation cannot report a stopped worker. + +The exact reviewed Tin transcript was deleted after its SHA-256 was matched. +The plan retains the sanitized findings and source identity. No transcript or +credential is retained in these records. Other Tin files were preserved. + +Rollback reverts the explicit feature change on its branch. Existing experiment +records and checkpoints remain owner-readable artifacts. Host installation, +public release, push and deployment were not performed. diff --git a/docs/plans/2026-10-08-carl-prepared-training.md b/docs/plans/2026-10-08-carl-prepared-training.md new file mode 100644 index 0000000..3fb7f23 --- /dev/null +++ b/docs/plans/2026-10-08-carl-prepared-training.md @@ -0,0 +1,118 @@ +--- +last_updated: 2026-10-08 +author: Codex +applies_to: carl-studio 0.21.0 +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +execution: code +--- + +# CARL prepared training + +## Goal + +Turn a goal into a prepared experiment, a bounded training run through the +existing pipeline, a baseline comparison, and a reusable accepted artifact. + +The approved choices are model or adapter improvement, preservation of the +caller pipeline, held-out goal progress plus policy checks and a coherence +floor, and beginner automation in the existing agent. Tinny alone and Tinny +with TCG are the two reference cases. Actual Ansible provisioning, a new +consumer portal, and marketplace expansion are outside this change. + +## Construction contract + +Baseline: `5be38462a9c3dba089e24f1386a945dc2469b99c`. +Branch: `codex/carl-prepared-training`. +Pre-existing CARL dirty files: none. +Writer: parent agent; research and review agents are read-only. + +Owners: project discovery and environment setup; normalized data and kits; +ExperimentManager; training adapters, CARLTrainer and SendItPipeline; +EvalRunner and EvalGate; existing CLI, MCP task and plugin projection owners. + +Inputs: configuration, explicit goal and policy checks, selected backend, +source model and data identities, held-out split, reward/evaluator bindings, +resource limits, and artifact destination. +Outputs: typed preparation and readiness issues, an execution handle, +baseline/candidate measurements, acceptance reasons, and artifact references. + +Preparation must not submit training, install dependencies, contact a provider, +or publish. Missing data or graders yield an actionable readiness issue. +Training consumes matching prepared inputs. Execution completion is separate +from acceptance. Required missing measurements cannot satisfy acceptance. +Failed gates prevent promotion. Local artifacts and explicitly approved +private remote custody precede separately authorized public publication. + +Keep the existing experiment/run persistence and native permissions. Preserve +configuration-only calls. Cancellation acknowledges actual stopped execution. +Keep full-logit coherence and partial-logprob proxies distinct. Operational +traces contain metadata and references rather than training content. + +## Implementation + +1. Correct project display/routing, stage-specific evaluation, artifact binding, + SFT-to-GRPO continuation, final gates and publication handling. +2. Add typed preparation and goal bindings to existing config/experiment/data + owners. Validate datasets, splits, graders, reward hooks and bounded work. +3. Attach explicit task rewards and evaluators to the current training loop; + use TRL as the first reference integration and declare adapter capabilities. +4. Share preparation and submission between MCP and CLI. Extend the canonical + skill and generate host projections. Show beginner summaries with details + available; recover from recorded state and reuse prior authorization. +5. Retain comparisons and artifacts, test positive and negative cases, review + independently, build, and exercise native hosts with synthetic providers. + +## Verification + +Preparation must accept usable local inputs and reject absent data, overlapping +splits, absent graders, unsupported hooks, unbounded work and stale inputs +before trainer construction. A distinct task reward must reach GRPO training. +High coherence cannot rescue a wrong task result. An unchanged candidate cannot +satisfy positive improvement. Compare identical held-out cases and generation +budgets. Verify cancellation, retry deduplication, ownership, integrity, and +publication boundaries. Preserve legacy interfaces and import-light behavior. + +Initial offline checks: 76 passed for pipeline/eval/comparison/delegation/plugin; +95 passed and one pre-existing entry test failure caused by pytest arguments +entering the CLI router; the isolated normalized-argument case passed. Four +release artifact hashes matched. These checks did not execute model training. + +Live provider, paid training, GPU workload, dependency installation, publication, and +service changes require their concrete approved inputs. No current provider +spending authorization or training budget was supplied. + +## Sanitized case review + +Source: `tin/carl/codex-session-01a11d26-09d1-7762-bd63-cf788c6c4d22.md`. +Source SHA-256: `9defa22397d360565470d560f04f5bfa35e28504014d8dfc4eb507d32459d2fc`. +Observed size: 9,202 lines, 101 activity blocks, four user sections and four +assistant sections. Two requested modes produced three configurations. +The visible ending left dataset preparation and submission outstanding. + +The case exposed wrong interpreter/path assumptions, project-context friction, +separate reward declarations and pipeline construction, schema-only readiness, +and simulated reward shortcuts. Current pure counterexamples gave a long wrong +answer 1.0 without assertions and 0.0 with a failing assertion; a legal wrong +TCG move received 1.5 without a grader and -1.0 with an external rejection. +No raw conversation, credentials, internal reasoning, or training content is +retained in this note. The user requested deletion after review and scheduled +credential rotation independently. The exact file was deleted after saving +this review and rechecking the source hash. Its absence was verified. + +## Implementation verification + +Prepared training is implemented through the named owners. +The final offline suite passed: 4,378 passed, 29 skipped. +All eight independently reviewed defects were repaired and checked. +The bounded real TRL configuration constructors, offline build, installed-wheel +imports, generated projections and three native preparation witnesses passed. +Model training and public publication were not executed. +Commands, falsifiers and evidence boundaries are recorded in +[the verification record](../carl-prepared-training-verification.md). + +## Rollback + +Revert the explicit feature-owned diff on this branch. Preserve peer-owned Tin +files. Plugin rollback uses its existing owned-entry update/uninstall paths; +experiment artifacts and trained candidates remain owner-readable records. diff --git a/scripts/witness_native_harness.py b/scripts/witness_native_harness.py index cffd5e8..1536c61 100644 --- a/scripts/witness_native_harness.py +++ b/scripts/witness_native_harness.py @@ -60,6 +60,18 @@ def receive(self): n: hashlib.sha256((ROOT / "src/carl_studio/harness" / n).read_bytes()).hexdigest() for n in ("adapters.py", "runtime.py", "types.py") } +SOURCE_HASHES.update( + { + path: hashlib.sha256((ROOT / "src/carl_studio" / path).read_bytes()).hexdigest() + for path in ( + "mcp/server.py", + "mcp/output_schemas.py", + "mcp/protocol.py", + "training/preparation.py", + "types/preparation.py", + ) + } +) META = [] STARTED = threading.Event() MODE = sys.argv[2] if len(sys.argv) > 2 else "complete" @@ -110,18 +122,39 @@ def do_POST(self): ) STARTED.set() serialized = json.dumps(body) + META[-1]["prepared_result_present"] = "Eprep_" in serialized + desired_tool = "prepare_training" if MODE == "prepare" else "get_coherence_metrics" + tool_arguments = ( + { + "config_yaml": json.dumps( + { + "run_name": "native-preparation", + "base_model": "fixture/source", + "dataset_repo": "fixture/train", + "eval_dataset_repo": "fixture/eval", + "output_repo": "fixture/candidate", + "method": "sft", + "compute_target": "local", + "max_steps": 4, + "push_to_hub": False, + } + ) + } + if MODE == "prepare" + else {"logits_summary": '{"embedding_dim":3072}'} + ) META[-1]["metrics_result_present"] = all( k in serialized for k in ("kappa", "sigma", "t_star", "embedding_dim") ) - chosen = next((n for n in names if n and "get_coherence_metrics" in n), None) + chosen = next((n for n in names if n and desired_tool in n), None) chosen_scope = None if chosen is None: for t in tools: for x in t.get("tools", []): - if x.get("name") == "get_coherence_metrics": + if x.get("name") == desired_tool: chosen = x["name"] chosen_scope = t["name"] - call_tool = MODE == "metrics" and len(META) == 1 and chosen is not None + call_tool = MODE in {"metrics", "prepare"} and len(META) == 1 and chosen is not None self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Connection", "close") @@ -173,9 +206,7 @@ def event(kind, obj): "index": 0, "delta": { "type": "input_json_delta", - "partial_json": json.dumps( - {"logits_summary": '{"embedding_dim":3072}'} - ), + "partial_json": json.dumps(tool_arguments), }, }, ) @@ -269,7 +300,7 @@ def event(kind, obj): "id": "fc_carl", "call_id": "tool_carl", "name": chosen, - "arguments": json.dumps({"logits_summary": '{"embedding_dim":3072}'}), + "arguments": json.dumps(tool_arguments), "status": "completed", } if chosen_scope: @@ -400,7 +431,7 @@ async def main(): if not states or states[-1] != state: states.append(state) if value.get("pending_input"): - rt.reply(tid, value["pending_input"]["request_id"], MODE == "metrics") + rt.reply(tid, value["pending_input"]["request_id"], MODE in {"metrics", "prepare"}) answers += 1 if MODE == "cancel" and STARTED.is_set() and not cancelled: await asyncio.sleep(0.2) @@ -445,6 +476,13 @@ async def main(): and not META[0]["metrics_result_present"] and any(m["metrics_result_present"] for m in META[1:]) ), "Native CARL tool acceptance failed" + elif MODE == "prepare": + assert ( + receipt["status"] == "completed" + and receipt["result_match"] + and not META[0]["prepared_result_present"] + and any(item["prepared_result_present"] for item in META[1:]) + ), "Native preparation tool acceptance failed" elif MODE == "cancel": assert receipt["status"] == "cancelled" and not cancel_errors, ( "Native cancellation acceptance failed" diff --git a/skills/carl/SKILL.md b/skills/carl/SKILL.md index 9a13979..8248cb9 100644 --- a/skills/carl/SKILL.md +++ b/skills/carl/SKILL.md @@ -9,10 +9,43 @@ Use the installed CARL MCP tools when available. Use the CARL CLI otherwise. CARL means Coherence-Aware Reinforcement Learning. 1. Inspect the current project and the requested outcome. -2. Use `validate_config` before starting training. -3. Use observe and eval to measure the result the user requested. -4. Use `list_skills` and `run_skill` for existing CARL workflows. -5. Use `list_agent_harnesses` and `delegate_agent` when another native agent should do part of the work. +2. For a goal, use `prepare_training` before starting training. Schema validation + alone does not establish data, graders, reward hooks or resource readiness. +3. Present the prepared inputs, task checks, policies, model, limits and effects. + Pass the returned `plan_id` as `prepared_plan_id` to `start_training` or + `submit_async_training` only after the user has authorized that run. +4. Keep execution completion separate from candidate acceptance. Return the + checkpoint, baseline comparison, acceptance reasons and reuse instructions. +5. Use `list_skills` and `run_skill` for existing CARL workflows. +6. Use `list_agent_harnesses` and `delegate_agent` when another native agent should do part of the work. + +## Goal preparation + +Discover the project root, selected interpreter, existing pipeline and relevant +task fixtures. Preserve the model, backend and pipeline choices. Ask only for +information the project cannot supply, one question at a time. Offer the two +requested Tinny modes; keep universal-role ablation in advanced details. + +Prepare compatible local data with stable task IDs and separate held-out tasks. +Use existing data adapters, verification records and kits. Reuse executable +project checks and source references. Never substitute unrelated datasets, +invent a grader, infer task success from answer length or move legality, or +describe a simulated environment as an independent verdict. + +Resolve readiness issues before submitting. Missing model/data inputs need an +authorized preparation action. Missing dependencies need an explicit install +proposal. The first qualified goal integration uses local TRL; other backends +declare their capabilities. Python pipelines can attach explicit reward and +evaluator callables to the existing trainer and eval runner. + +Translate relevant user policies into explicit checks or existing execution +controls. State which rules are measured and which are enforced during tool +execution. A description in a prompt is not an executed policy check. + +Repeated prepared submission reuses its recorded result. Explicit resume uses +recorded checkpoints; unresolved execution custody needs reconciliation. +Retain metadata and approved artifacts. Do not use raw conversation exports, +credentials or hidden reasoning as automatic training data. Local plugin access is FREE. Scheduled autonomy, paid services, publication, and private-runtime operations retain their own authorization and entitlement checks. diff --git a/src/carl_studio/__init__.py b/src/carl_studio/__init__.py index 139166c..2a35e79 100644 --- a/src/carl_studio/__init__.py +++ b/src/carl_studio/__init__.py @@ -8,6 +8,8 @@ See also: carl.py (300-line seed crystal at the repo root) """ +from __future__ import annotations + __version__ = "0.21.0" from typing import TYPE_CHECKING @@ -27,6 +29,10 @@ "DEFECT_THRESHOLD", "CoherenceProbe", "TrainingConfig", + "TrainingGoal", + "TrainingPreparation", + "prepare_training", + "submit_training", "observe", "PhaseTransitionGate", # Lazy: CascadeRewardManager, CARLTrainer, Bundler, ComputeTarget, TrainingMethod @@ -62,6 +68,10 @@ def __getattr__(name: str): "ComputeTarget": "carl_studio.types.config", "TrainingMethod": "carl_studio.types.config", "CARLTrainer": "carl_studio.training.trainer", + "TrainingGoal": "carl_studio.types.preparation", + "TrainingPreparation": "carl_studio.types.preparation", + "prepare_training": "carl_studio.training.preparation", + "submit_training": "carl_studio.training.pipeline", "observe": "carl_studio.observe", "Bundler": "carl_studio.bundler", "EvalConfig": "carl_studio.eval.runner", diff --git a/src/carl_studio/adapters/protocol.py b/src/carl_studio/adapters/protocol.py index 4d76621..98af758 100644 --- a/src/carl_studio/adapters/protocol.py +++ b/src/carl_studio/adapters/protocol.py @@ -46,9 +46,7 @@ class BackendStatus: FAILED = "failed" CANCELED = "canceled" - _ALL: frozenset[str] = frozenset( - {PENDING, PROVISIONING, RUNNING, COMPLETED, FAILED, CANCELED} - ) + _ALL: frozenset[str] = frozenset({PENDING, PROVISIONING, RUNNING, COMPLETED, FAILED, CANCELED}) @classmethod def is_terminal(cls, status: str) -> bool: @@ -76,6 +74,8 @@ class BackendJob: logs_url: str | None = None raw: dict[str, Any] = field(default_factory=dict) + checkpoint: str | None = None + def to_dict(self) -> dict[str, Any]: """Return a JSON-safe representation.""" return { diff --git a/src/carl_studio/adapters/registry.py b/src/carl_studio/adapters/registry.py index 68c08d7..6b66b6b 100644 --- a/src/carl_studio/adapters/registry.py +++ b/src/carl_studio/adapters/registry.py @@ -75,3 +75,18 @@ def _unregister(name: str) -> None: def _clear() -> None: """Test-only hook: wipe the registry.""" _REGISTRY.clear() + + +def get_capabilities(name: str) -> dict[str, object]: + """Declare goal hooks separately from availability and job submission.""" + adapter = get_adapter(name) + declared = getattr(adapter, "training_capabilities", None) + if isinstance(declared, dict): + return dict(declared) + return { + "methods": [], + "task_rewards": False, + "artifacts": False, + "coherence": "unqualified", + "cancellation": "backend", + } diff --git a/src/carl_studio/adapters/trl_adapter.py b/src/carl_studio/adapters/trl_adapter.py index e1509e7..dda1049 100644 --- a/src/carl_studio/adapters/trl_adapter.py +++ b/src/carl_studio/adapters/trl_adapter.py @@ -8,7 +8,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, ClassVar from carl_core.connection import ( ConnectionDirection, @@ -162,3 +162,11 @@ def run_sync(self, carl_config: dict[str, Any]) -> BackendJob: state.raw["hub_job_id"] = getattr(run, "hub_job_id", None) save_state(state) return state.to_job() + + training_capabilities: ClassVar[dict[str, Any]] = { + "methods": ["sft", "grpo"], + "task_rewards": True, + "artifacts": True, + "coherence": "full_logits", + "cancellation": "cooperative", + } diff --git a/src/carl_studio/bundler.py b/src/carl_studio/bundler.py index c2f3322..490e07d 100644 --- a/src/carl_studio/bundler.py +++ b/src/carl_studio/bundler.py @@ -31,6 +31,10 @@ class Bundler: def generate(self, config: TrainingConfig) -> str: """Generate a self-contained training script.""" + if config.goal is not None: + raise ValueError( + "Prepared goals require a backend that carries their reward and evaluation bindings" + ) if config.method.value in ("grpo",): return self._generate_grpo(config) elif config.method.value == "sft": diff --git a/src/carl_studio/cli/entry.py b/src/carl_studio/cli/entry.py index 66e688f..6a55b77 100644 --- a/src/carl_studio/cli/entry.py +++ b/src/carl_studio/cli/entry.py @@ -138,8 +138,7 @@ def _default_show_help() -> None: ctx = typer.Context(typer.main.get_command(app)) typer.echo(ctx.get_help()) typer.echo( - "\nUse `carl chat` for an interactive session or " - '`carl ask ""` for one-shot.' + '\nUse `carl chat` for an interactive session or `carl ask ""` for one-shot.' ) @@ -198,10 +197,10 @@ def _resolve_default_handlers() -> tuple[ from carl_studio.cli.init import init_cmd def _chat(*, initial_message: str | None = None) -> None: - chat_cmd(initial_message=initial_message) + _invoke_command(chat_cmd, initial_message=initial_message) def _ask(prompt: str) -> None: - ask_cmd(prompt=prompt) + _invoke_command(ask_cmd, prompt=prompt) def _init() -> None: init_cmd( @@ -214,6 +213,22 @@ def _init() -> None: return _chat, _ask, _init, _default_show_help, _default_trust_precheck +def _invoke_command(function: Callable[..., object], **overrides: object) -> None: + """Resolve Typer metadata before invoking a command as a function.""" + import inspect + from typer.models import ParameterInfo + + arguments: dict[str, object] = {} + for name, parameter in inspect.signature(function).parameters.items(): + default = parameter.default + if isinstance(default, ParameterInfo): + arguments[name] = default.default + elif default is not inspect.Parameter.empty: + arguments[name] = default + arguments.update(overrides) + function(**arguments) + + def _looks_like_prompt(token: str) -> bool: if not token: return False @@ -300,15 +315,10 @@ def route( # non-interactive reply, not dropping into a long-lived session # bound to project context. Use ``carl trust acknowledge`` if you # want a project trusted ahead of any one-shot use. - bare_prompt_entry = ( - stdin_tty - and ( - not argv - or ( - len(argv) == 1 - and argv[0] not in REGISTERED_SUBCOMMANDS - and _looks_like_prompt(argv[0]) - ) + bare_prompt_entry = stdin_tty and ( + not argv + or ( + len(argv) == 1 and argv[0] not in REGISTERED_SUBCOMMANDS and _looks_like_prompt(argv[0]) ) ) if bare_prompt_entry: diff --git a/src/carl_studio/cli/project_data.py b/src/carl_studio/cli/project_data.py index 861e447..5e12317 100644 --- a/src/carl_studio/cli/project_data.py +++ b/src/carl_studio/cli/project_data.py @@ -266,7 +266,9 @@ def project_init( method = ui.select( "Training method", [ - ui.Choice(value="grpo", label="GRPO", badge="recommended", hint="RL with reward shaping"), + ui.Choice( + value="grpo", label="GRPO", badge="recommended", hint="RL with reward shaping" + ), ui.Choice(value="sft", label="SFT", hint="supervised fine-tuning"), ui.Choice(value="dpo", label="DPO", hint="direct preference optimization"), ], @@ -277,7 +279,9 @@ def project_init( compute = ui.select( "Compute target", [ - ui.Choice(value="local", label="local", badge="recommended", hint="your own hardware"), + ui.Choice( + value="local", label="local", badge="recommended", hint="your own hardware" + ), ui.Choice(value="l4x1", label="l4x1", hint="1× L4 GPU (cheap)"), ui.Choice(value="l40sx1", label="l40sx1", hint="1× L40S GPU"), ui.Choice(value="a100-largex8", label="a100-largex8", hint="8× A100 (big jobs)"), @@ -338,7 +342,7 @@ def project_show( ("Model", proj.base_model or "(not set)"), ("Adapter", proj.adapter or "(none)"), ("Method", proj.method), - ("Compute", f"{proj.compute_target} via {proj.backend}"), + ("Compute", f"{proj.compute_target} via {proj.compute_backend}"), ("Dataset", proj.dataset_repo or "(not set)"), ("Output", proj.output_repo or "(not set)"), ("CARL", "enabled" if proj.carl_enabled else "disabled"), @@ -617,4 +621,3 @@ def data_stats( table.add_row(role, f"{count:,}") c.print(table) c.blank() - diff --git a/src/carl_studio/cli/shared.py b/src/carl_studio/cli/shared.py index 8478d0c..0200388 100644 --- a/src/carl_studio/cli/shared.py +++ b/src/carl_studio/cli/shared.py @@ -483,6 +483,14 @@ def _persist_training_run(training_config: Any, run: Any, mode: str) -> None: "checkpoint_steps": list(getattr(run, "checkpoint_steps", [])), "output_repo": training_config.output_repo, "method": training_config.method.value, + "checkpoint": getattr(run, "checkpoint", None), + "artifacts": [ + artifact.model_dump(mode="json") for artifact in getattr(run, "artifacts", []) + ], + "acceptance": run.acceptance.model_dump(mode="json") + if getattr(run, "acceptance", None) is not None + else None, + "resource_usage": getattr(run, "resource_usage", {}), }, "started_at": existing.get("started_at") if existing and existing.get("started_at") diff --git a/src/carl_studio/cli/training.py b/src/carl_studio/cli/training.py index 84525bb..1abfa62 100644 --- a/src/carl_studio/cli/training.py +++ b/src/carl_studio/cli/training.py @@ -41,9 +41,7 @@ # Compute-substrate names that older carl.yaml files wrote into the `backend` # field. These are NOT training adapters and should NOT resolve as such — when # we see one in a legacy carl.yaml, fall through to the default adapter. -_LEGACY_COMPUTE_BACKEND_NAMES = frozenset( - {"hf_jobs", "runpod", "prime", "ssh", "local"} -) +_LEGACY_COMPUTE_BACKEND_NAMES = frozenset({"hf_jobs", "runpod", "prime", "ssh", "local"}) def _resolve_adapter_name(*, cli_override: str | None, raw: dict[str, Any]) -> str: @@ -71,6 +69,7 @@ def _resolve_adapter_name(*, cli_override: str | None, raw: dict[str, Any]) -> s return legacy return "trl" + # --------------------------------------------------------------------------- # carl run # --------------------------------------------------------------------------- @@ -487,6 +486,16 @@ def train( dry_run: bool = typer.Option( False, "--dry-run", help="Show what --send-it would do without executing" ), + prepare: bool = typer.Option( + False, "--prepare", help="Prepare a goal without starting training" + ), + prepared_plan_id: str | None = typer.Option( + None, "--prepared-plan", help="Reviewed prepared experiment ID" + ), + goal: str | None = typer.Option(None, "--goal", help="The result this model should improve"), + json_output: bool = typer.Option( + False, "--json", help="Return the prepared experiment or run as JSON" + ), skip_credits: bool = typer.Option( False, "--skip-credits", @@ -512,10 +521,7 @@ def train( trace_dir: Path | None = typer.Option( None, "--trace-dir", - help=( - "Directory to write coherence traces " - "(default: ~/.carl/runs//traces/)" - ), + help=("Directory to write coherence traces (default: ~/.carl/runs//traces/)"), ), report_to: str | None = typer.Option( None, @@ -573,7 +579,8 @@ def train( # Exit 2 with an actionable message when called outside any `.carl/`. from carl_studio.project_context import require as _require_project - _require_project("train") + if not prepare and prepared_plan_id is None: + _require_project("train") import yaml from pathlib import Path @@ -596,13 +603,9 @@ def train( c_fq = get_console() proposal = FeedbackEngine(LocalDB()).pending_proposal() if proposal is None: - c_fq.error( - "No pending training proposal -- enqueue one via `carl queue` first." - ) + c_fq.error("No pending training proposal -- enqueue one via `carl queue` first.") raise typer.Exit(2) - c_fq.info( - f"Applying proposal for note {proposal.note_id}: {proposal.gap_summary}" - ) + c_fq.info(f"Applying proposal for note {proposal.note_id}: {proposal.gap_summary}") for key, value in proposal.suggested_config.items(): raw.setdefault(key, value) # pyright: ignore[reportUnknownMemberType] @@ -615,7 +618,7 @@ def train( # --managed: route to carl.camp's POST /api/train/slime/submit instead # of the local SlimeAdapter. Requires PAID tier and the slime backend. - if managed: + if managed and not (prepare or prepared_plan_id or goal or raw.get("goal")): _maybe_dispatch_managed_slime( backend_name=backend_name, raw=raw, @@ -624,11 +627,8 @@ def train( idempotency_key=idempotency_key, ) - _maybe_dispatch_backend( - backend_name=backend_name, - raw=raw, - dry_run=dry_run, - ) + if not (prepare or prepared_plan_id or goal or raw.get("goal")): + _maybe_dispatch_backend(backend_name=backend_name, raw=raw, dry_run=dry_run) # CLI overrides if name: @@ -676,26 +676,115 @@ def train( c = get_console() raw = _normalize_training_config(raw) + raw["adapter"] = backend_name + if send_it: + raw["pipeline"] = True + if resume_from_checkpoint is not None and prepare: + raw["resume_from_checkpoint"] = resume_from_checkpoint + if goal is not None: + from carl_studio.types.preparation import TrainingGoal + + existing_goal = TrainingGoal.model_validate(raw.get("goal") or {}) + raw["goal"] = existing_goal.model_copy(update={"description": goal}).model_dump(mode="json") try: training_config = TrainingConfig(**raw) except ValidationError as exc: _render_training_config_error(c, raw, exc) raise typer.Exit(1) + if prepare: + from carl_studio.training.preparation import ( + prepare_training, + render_preparation, + project_root_for_config, + ) + + prepared = prepare_training( + training_config, project_root=project_root_for_config(config_path) + ) + if json_output: + result = prepared.model_dump(mode="json") + result["ready"] = prepared.ready + typer.echo(json.dumps(result)) + else: + c.info(render_preparation(prepared)) + raise typer.Exit(0) + + if send_it: + from carl_studio.tier import check_tier, tier_message + + allowed, _, _ = check_tier("train.send_it") + if not allowed: + c.error_with_hint( + tier_message("train.send_it") or "--send-it requires CARL Paid.", + hint="Upgrade with: carl camp upgrade", + signup_url="https://carl.camp/pricing", + code="tier:train.send_it", + ) + raise typer.Exit(1) + + if prepared_plan_id is not None or training_config.goal is not None: + if dry_run: + from carl_studio.training.preparation import ( + load_preparation, + prepare_training, + project_root_for_config, + render_preparation, + validate_preparation, + ) + + prepared = ( + load_preparation(prepared_plan_id) + if prepared_plan_id is not None + else prepare_training( + training_config, project_root=project_root_for_config(config_path), persist=False + ) + ) + if prepared_plan_id is not None: + validate_preparation(prepared, training_config) + if json_output: + result = prepared.model_dump(mode="json") + result["ready"] = prepared.ready + typer.echo(json.dumps(result)) + else: + c.info(render_preparation(prepared)) + raise typer.Exit(0) + + from carl_studio.training.pipeline import submit_training + import anyio + from functools import partial + + try: + run = anyio.run( + partial( + submit_training, + training_config, + prepared_plan_id=prepared_plan_id, + skip_credits=skip_credits, + resume_from_checkpoint=resume_from_checkpoint, + ) + ) + except Exception as exc: + from carl_core.errors import CARLError + + c.error(exc.code if isinstance(exc, CARLError) else "Prepared training failed") + raise typer.Exit(1) from None + _persist_training_run(run.config, run, mode="goal-training") + if json_output: + typer.echo(run.model_dump_json()) + else: + c.kv("Run", run.id) + c.kv("Checkpoint", run.checkpoint or "unavailable") + c.kv("Acceptance", run.acceptance.status if run.acceptance else "not evaluated") + if run.acceptance: + c.kv("Goal change", run.acceptance.goal_delta) + for reason in run.acceptance.reasons: + c.info(reason) + accepted = run.acceptance is not None and run.acceptance.status == "accepted" + raise typer.Exit(0 if accepted else 1) + # --send-it / --dry-run: full pipeline mode if send_it or dry_run: - if send_it: - from carl_studio.tier import check_tier, tier_message - - allowed, _, _ = check_tier("train.send_it") - if not allowed: - c.error_with_hint( - tier_message("train.send_it") or "--send-it requires CARL Paid.", - hint="Upgrade with: carl camp upgrade", - signup_url="https://carl.camp/pricing", - code="tier:train.send_it", - ) - raise typer.Exit(1) from carl_studio.training.pipeline import SendItPipeline c.banner(f"v{__version__}") diff --git a/src/carl_studio/cli/wiring.py b/src/carl_studio/cli/wiring.py index c306a3f..8b13fde 100644 --- a/src/carl_studio/cli/wiring.py +++ b/src/carl_studio/cli/wiring.py @@ -297,9 +297,7 @@ def _stub() -> None: # noqa: D401 camp_app.add_typer(contract_app, name="contract") except ImportError: - _make_stub( - camp_app, "contract", doc="Contract commands require the full carl-studio package." - ) + _make_stub(camp_app, "contract", doc="Contract commands require the full carl-studio package.") # --------------------------------------------------------------------------- @@ -455,14 +453,8 @@ def _route_bare_carl( # pyright: ignore[reportUnusedFunction] # router is not needed (e.g. on ``carl ``). from carl_studio.cli.entry import route - # The callback fires with ``sys.argv`` already stripped of the - # program name and of any consumed group-level options. The - # router inspects the full slice so it can reach the positional - # prompt / ``-p`` shapes that Typer's callback context does not - # expose directly. - import sys as _sys - - handled = route(list(_sys.argv[1:])) + # Click's arguments belong to this invocation, unlike process argv. + handled = route(list(ctx.args)) if handled: return @@ -471,8 +463,7 @@ def _route_bare_carl( # pyright: ignore[reportUnusedFunction] # to v0.17.x for operators who rely on the existing output. typer.echo(ctx.get_help()) typer.echo( - "\nUse `carl chat` for an interactive session or " - "`carl ask \"\"` for one-shot." + '\nUse `carl chat` for an interactive session or `carl ask ""` for one-shot.' ) except ImportError: diff --git a/src/carl_studio/eval/runner.py b/src/carl_studio/eval/runner.py index 372613b..aa5b96b 100644 --- a/src/carl_studio/eval/runner.py +++ b/src/carl_studio/eval/runner.py @@ -19,7 +19,8 @@ import sys import tempfile import time -from typing import Any +from collections.abc import Callable +from typing import Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator @@ -61,6 +62,7 @@ class EvalConfig(BaseModel): threshold: float = Field( default=0.5, ge=0.0, le=1.0, description="Pass threshold for primary metric" ) + metric_direction: Literal["higher", "lower"] = "higher" max_samples: int | None = Field(default=None, ge=1, description="Cap number of eval samples") batch_size: int = Field(default=1, ge=1, description="Inference batch size") device: str = Field(default="auto", description="Device: 'auto', 'cpu', 'cuda', 'cuda:0', etc.") @@ -76,6 +78,8 @@ class EvalConfig(BaseModel): default=None, description="SFT adapter to merge before GRPO adapter", ) + starting_adapters: list[str] = Field(default_factory=list) + tokenizer_source: str | None = None # Coherence gate: CARL's reward/eval isomorphism. The eval gate MUST check # the same coherence field the training rewards optimize for — otherwise a @@ -220,11 +224,13 @@ def check(self, report: EvalReport) -> bool: Side effect: writes a human-readable ``gate_reason`` back onto the report so callers/logs can see which dimension failed. """ - primary_ok = report.primary_value >= self.threshold - - require_coherence = ( - self.config is not None and self.config.require_coherence_gate + lower = self.config is not None and self.config.metric_direction == "lower" + primary_ok = ( + report.primary_value <= self.threshold if lower else report.primary_value >= self.threshold ) + comparator = "<=" if lower else ">=" + + require_coherence = self.config is not None and self.config.require_coherence_gate if not require_coherence: report.gate_reason = ( @@ -252,18 +258,14 @@ def check(self, report: EvalReport) -> bool: has_coherence = False phi_ok = phi >= self.config.coherence_phi_floor - disc_ok = ( - self.config.discontinuity_min - <= disc - <= self.config.discontinuity_max - ) + disc_ok = self.config.discontinuity_min <= disc <= self.config.discontinuity_max coherence_ok = has_coherence and phi_ok and disc_ok passed = primary_ok and coherence_ok if passed: report.gate_reason = ( - f"PASS primary_value={report.primary_value:.3f}>={self.threshold:.3f} " + f"PASS primary_value={report.primary_value:.3f}{comparator}{self.threshold:.3f} " f"phi={phi:.3f}>={self.config.coherence_phi_floor:.3f} " f"disc={disc:.3f} in " f"[{self.config.discontinuity_min:.2f},{self.config.discontinuity_max:.2f}]" @@ -477,8 +479,7 @@ def execute_tool(self, name: str, args: dict[str, Any]) -> str: if any(m in cmd_str for m in _SHELL_METACHARS): self.tool_failures += 1 return ( - "Error: shell metacharacters not permitted; " - "use execute_code for pipelines" + "Error: shell metacharacters not permitted; use execute_code for pipelines" ) try: @@ -804,10 +805,16 @@ def __init__( config: EvalConfig, *, interaction_chain: InteractionChain | None = None, + evaluator: Callable[[list[str], list[dict[str, Any]]], dict[str, float]] | None = None, + primary_metric: str | None = None, ) -> None: self.config = config self.phase = config.phase if config.phase != "auto" else _detect_phase(config.checkpoint) self.chain: InteractionChain | None = interaction_chain + self.evaluator = evaluator + self.primary_metric = primary_metric + self.last_completions: list[str] = [] + self.last_samples: list[dict[str, Any]] = [] # ------------------------------------------------------------------ # InteractionChain helpers @@ -883,12 +890,20 @@ def run(self) -> EvalReport: def _run_single_turn_phase(self) -> EvalReport: """Phase 1 / Phase 2 single-turn generation path.""" samples = self._load_dataset() + if not samples: + raise ValueError("Evaluation needs a nonempty held-out population") model, tokenizer = self._load_model_simple() completions = self._generate_single_turn(model, tokenizer, samples) + self.last_completions = completions + self.last_samples = samples metrics = self._compute_metrics(completions, samples) coherence = self._compute_coherence(model, tokenizer, completions) - primary_metric = _PRIMARY_METRIC.get(self.phase, "chain_completion_rate") + primary_metric = self.primary_metric or _PRIMARY_METRIC.get( + self.phase, "chain_completion_rate" + ) + if self.evaluator is not None and primary_metric not in metrics: + raise ValueError("Evaluator did not report the declared primary metric") primary_value = metrics.get(primary_metric, 0.0) return EvalReport( @@ -899,7 +914,11 @@ def _run_single_turn_phase(self) -> EvalReport: primary_metric=primary_metric, primary_value=primary_value, threshold=self.config.threshold, - passed=primary_value >= self.config.threshold, + passed=( + primary_value <= self.config.threshold + if self.config.metric_direction == "lower" + else primary_value >= self.config.threshold + ), coherence=coherence, ) @@ -1202,7 +1221,7 @@ def _load_model_with_adapters(self) -> tuple[Any, Any]: model.generation_config.enable_thinking = False # Load tokenizer from adapter repo (has Qwen 3.5 chat template) - tokenizer_source = checkpoint + tokenizer_source = self.config.tokenizer_source or checkpoint processor = AutoProcessor.from_pretrained( tokenizer_source, token=hf_token, @@ -1215,6 +1234,8 @@ def _load_model_with_adapters(self) -> tuple[Any, Any]: not getattr(tokenizer, "chat_template", None) or "tool_call" not in tokenizer.chat_template ): + if self.config.tokenizer_source: + raise ValueError("The selected tokenizer needs a tool-call template") logger.warning( "Tokenizer from %s missing tool template, trying Qwen/Qwen3.5-9B", tokenizer_source ) @@ -1237,7 +1258,7 @@ def _load_model_with_adapters(self) -> tuple[Any, Any]: return model, tokenizer def _load_model_simple(self) -> tuple[Any, Any]: - """Load model and tokenizer for Phase 1/2 (no adapter stacking).""" + """Load the model, ordered adapters and tokenizer for Phase 1/2.""" try: import torch except ImportError as exc: @@ -1276,17 +1297,33 @@ def _load_model_simple(self) -> tuple[Any, Any]: from transformers import AutoModelForCausalLM model = AutoModelForCausalLM.from_pretrained( - self.config.checkpoint, + self.config.base_model or self.config.checkpoint, torch_dtype=torch.bfloat16 if device != "cpu" else torch.float32, device_map=device if device.startswith("cuda") else None, ) + for adapter in self.config.starting_adapters: + from peft import PeftModel + + model = PeftModel.from_pretrained(model, adapter).merge_and_unload() + if self.config.sft_adapter and self.config.checkpoint == self.config.base_model: + from peft import PeftModel + + model = PeftModel.from_pretrained(model, self.config.sft_adapter).merge_and_unload() + elif self.config.base_model and self.config.checkpoint != self.config.base_model: + from peft import PeftModel + + if self.config.sft_adapter: + model = PeftModel.from_pretrained( + model, self.config.sft_adapter + ).merge_and_unload() + model = PeftModel.from_pretrained(model, self.config.checkpoint).merge_and_unload() if not device.startswith("cuda") and model.device.type != device: model = model.to(device) model.eval() - tokenizer = AutoTokenizer.from_pretrained(self.config.checkpoint) + tokenizer = AutoTokenizer.from_pretrained(self.config.tokenizer_source or self.config.checkpoint) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token @@ -1302,12 +1339,11 @@ def _load_dataset(self) -> list[dict[str, Any]]: # Local JSONL/JSON file if os.path.isfile(path) and path.endswith((".jsonl", ".json")): - samples: list[dict[str, Any]] = [] - with open(path, encoding="utf-8") as f: - for line in f: - line = line.strip() - if line: - samples.append(json.loads(line)) + from pathlib import Path + + from carl_studio.training.preparation import read_samples + + samples = read_samples(Path(path)) if self.config.max_samples is not None: samples = samples[: self.config.max_samples] return samples @@ -1350,7 +1386,13 @@ def _generate_single_turn( for i in range(0, len(samples), self.config.batch_size): batch = samples[i : i + self.config.batch_size] for sample in batch: - prompt = sample.get("prompt") or sample.get("query") or sample.get("text", "") + prompt = ( + sample.get("prompt") + or sample.get("query") + or sample.get("question") + or sample.get("problem_statement") + or sample.get("text", "") + ) if isinstance(prompt, list): if hasattr(tokenizer, "apply_chat_template"): text = tokenizer.apply_chat_template( @@ -1376,7 +1418,7 @@ def _generate_single_turn( with torch.no_grad(): output_ids = model.generate( **inputs, - max_new_tokens=512, + max_new_tokens=self.config.max_new_tokens, do_sample=False, pad_token_id=tokenizer.pad_token_id, ) @@ -1404,6 +1446,23 @@ def _compute_metrics( samples: list[dict[str, Any]], ) -> dict[str, float]: """Dispatch to phase-specific metric computation.""" + if self.evaluator is not None: + import math + + try: + metrics = self.evaluator(completions, samples) + except Exception: + raise ValueError("Evaluator execution failed") from None + if ( + not isinstance(metrics, dict) + or not metrics + or any( + not isinstance(value, (float, int)) or not math.isfinite(value) + for value in metrics.values() + ) + ): + raise ValueError("Evaluator returned invalid measurements") + return {key: float(value) for key, value in metrics.items()} if self.phase == "1": return _compute_phase1_metrics(completions, samples) elif self.phase == "2": @@ -1421,8 +1480,6 @@ def _compute_coherence( try: import torch - if not torch.cuda.is_available(): - return None except ImportError: return None @@ -1436,9 +1493,9 @@ def _compute_coherence( cloud_values: list[float] = [] disc_values: list[float] = [] - subset = completions[:20] + subset = completions for text in subset: - if not text.strip() or len(text) < 10: + if not text.strip(): continue try: inputs = tokenizer( @@ -1465,12 +1522,13 @@ def _compute_coherence( disc_values.append(snapshot.discontinuity_score) del outputs, logits, inputs - torch.cuda.empty_cache() + if torch.cuda.is_available(): + torch.cuda.empty_cache() except Exception as e: - logger.warning("Coherence probe failed on sample: %s", e) - continue + logger.warning("Coherence probe failed: %s", type(e).__name__) + return None - if not phi_values: + if not phi_values or len(phi_values) != len(completions): return None return { @@ -1479,7 +1537,7 @@ def _compute_coherence( "discontinuity_score": round(statistics.mean(disc_values), 4), } except Exception as e: - logger.warning("Coherence computation skipped: %s", e) + logger.warning("Coherence computation unavailable: %s", type(e).__name__) return None diff --git a/src/carl_studio/experiment/manager.py b/src/carl_studio/experiment/manager.py index ea7adf2..3567a27 100644 --- a/src/carl_studio/experiment/manager.py +++ b/src/carl_studio/experiment/manager.py @@ -20,6 +20,9 @@ from __future__ import annotations import json +import os +import re +import uuid from pathlib import Path from typing import Any @@ -68,9 +71,7 @@ def create(self, hypothesis: Hypothesis, tags: list[str] | None = None) -> Exper (exp_dir / "artifacts").mkdir(exist_ok=True) # Save hypothesis - (exp_dir / "hypothesis.json").write_text( - hypothesis.model_dump_json(indent=2) - ) + (exp_dir / "hypothesis.json").write_text(hypothesis.model_dump_json(indent=2)) # Save experiment state self._save(exp) @@ -149,18 +150,115 @@ def list_experiments(self) -> list[dict[str, str]]: state = d / "experiment.json" if state.exists(): exp = Experiment.model_validate_json(state.read_text()) - results.append({ - "id": exp.id, - "title": exp.hypothesis.title, - "status": exp.status.value, - "verdict": exp.judgment.verdict.value if exp.judgment else "-", - }) + results.append( + { + "id": exp.id, + "title": exp.hypothesis.title, + "status": exp.status.value, + "verdict": exp.judgment.verdict.value if exp.judgment else "-", + } + ) return results + def save_preparation(self, preparation: Any) -> None: + """Retain prepared inputs as an artifact of the existing experiment.""" + exp_dir = self.base_dir / preparation.plan_id + exp_dir.mkdir(mode=0o700, exist_ok=True) + os.chmod(exp_dir, 0o700) + payload = preparation.model_dump_json(indent=2) + target = exp_dir / "preparation.json" + if target.exists(): + existing = self.load_preparation(preparation.plan_id) + if existing.config != preparation.config or existing.sources != preparation.sources: + raise ValueError("Prepared experiment identity conflict") + if not (exp_dir / "experiment.json").is_file(): + self._save( + Experiment( + id=existing.plan_id, + hypothesis=existing.hypothesis, + config=existing.config, + tags=["prepared-training"], + ) + ) + return + temporary = exp_dir / ("preparation." + uuid.uuid4().hex + ".tmp") + with temporary.open("x") as stream: + os.chmod(temporary, 0o600) + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(target) + exp = Experiment( + id=preparation.plan_id, + hypothesis=preparation.hypothesis, + config=preparation.config, + tags=["prepared-training"], + ) + self._save(exp) + self._update_index() + + def load_preparation(self, plan_id: str) -> Any: + """Read the typed preparation artifact without loading training code.""" + from carl_studio.types.preparation import TrainingPreparation + + if not re.fullmatch(r"Eprep_[0-9a-f]{24}", plan_id): + raise ValueError("Invalid prepared experiment ID") + return TrainingPreparation.model_validate_json( + (self.base_dir / plan_id / "preparation.json").read_text() + ) + + def claim_training(self, plan_id: str, *, resume: bool = False) -> None: + """Claim a prepared experiment once before starting its effects.""" + self.load_preparation(plan_id) + exp_dir = self.base_dir / plan_id + admission = exp_dir / "execution.admission" + descriptor = os.open(admission, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + os.close(descriptor) + exp = self.load(plan_id) + if exp.config.get("execution_active"): + raise ValueError("Execution custody requires reconciliation before resume") + claim = exp_dir / "execution.claim" + if resume and claim.exists(): + run = self.load_training_result(plan_id) + if run is None or run.phase.value not in {"paused", "failed"}: + raise ValueError("Execution custody requires reconciliation before resume") + claim.rename(claim.with_name("execution." + uuid.uuid4().hex + ".claim")) + with claim.open("x") as stream: + os.chmod(claim, 0o600) + stream.write(json.dumps({"pid": os.getpid(), "plan_id": plan_id})) + exp.config["execution_active"] = True + self._save(exp) + finally: + admission.unlink() + + def save_training_result(self, plan_id: str, run: Any) -> None: + """Retain the training result at its original experiment owner.""" + exp = self.load(plan_id) + exp.run_id = run.id + exp.artifacts = list(run.artifacts) + exp.config["training_result"] = run.model_dump(mode="json") + exp.config["execution_active"] = False + self._save(exp) + + def load_training_result(self, plan_id: str) -> Any: + """Return an existing result instead of repeating a training effect.""" + from carl_studio.types.run import TrainingRun + + exp = self.load(plan_id) + data = exp.config.get("training_result") + return TrainingRun.model_validate(data) if data is not None else None + def _save(self, exp: Experiment) -> None: exp_dir = self.base_dir / exp.id exp_dir.mkdir(exist_ok=True) - (exp_dir / "experiment.json").write_text(exp.model_dump_json(indent=2)) + temporary = exp_dir / ("experiment." + uuid.uuid4().hex + ".tmp") + with temporary.open("x") as stream: + os.chmod(temporary, 0o600) + stream.write(exp.model_dump_json(indent=2)) + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(exp_dir / "experiment.json") def _write_readme(self, exp: Experiment) -> None: """Auto-generate a human-readable experiment summary.""" @@ -187,14 +285,16 @@ def _write_readme(self, exp: Experiment) -> None: lines.append(f"- **{p.id}** [{icon}]: {p.claim}") if exp.judgment: - lines.extend([ - "", - "## Judgment", - "", - f"**Verdict:** {exp.judgment.verdict.value}", - f"**Confidence:** {exp.judgment.confidence:.2f}", - f"**Notes:** {exp.judgment.notes}", - ]) + lines.extend( + [ + "", + "## Judgment", + "", + f"**Verdict:** {exp.judgment.verdict.value}", + f"**Confidence:** {exp.judgment.confidence:.2f}", + f"**Notes:** {exp.judgment.notes}", + ] + ) exp_dir = self.base_dir / exp.id (exp_dir / "README.md").write_text("\n".join(lines)) diff --git a/src/carl_studio/mcp/output_schemas.py b/src/carl_studio/mcp/output_schemas.py index 41ca75e..b76dd86 100644 --- a/src/carl_studio/mcp/output_schemas.py +++ b/src/carl_studio/mcp/output_schemas.py @@ -58,6 +58,8 @@ def _one_of_tier_error(happy: dict[str, Any]) -> dict[str, Any]: "hub_job_id": {"type": ["string", "null"]}, "model": {"type": "string"}, "method": {"type": "string"}, + "checkpoint": {"type": ["string", "null"]}, + "acceptance": {"type": ["object", "null"]}, }, "additionalProperties": False, } @@ -413,6 +415,17 @@ def _one_of_tier_error(happy: dict[str, Any]) -> dict[str, Any]: "stop_training": _STOP_TRAINING, "list_backends": _LIST_BACKENDS, "validate_config": _VALIDATE_CONFIG, + "prepare_training": { + "type": "object", + "required": ["plan_id", "ready", "goal", "issues"], + "properties": { + "plan_id": {"type": "string"}, + "ready": {"type": "boolean"}, + "goal": {"type": "object"}, + "issues": {"type": "array", "items": {"type": "object"}}, + }, + "additionalProperties": True, + }, "generate_bundle": _GENERATE_BUNDLE, # Auth + tier + skills + a2a + sync "authenticate": _AUTHENTICATE, diff --git a/src/carl_studio/mcp/protocol.py b/src/carl_studio/mcp/protocol.py index 7e82875..01f57ad 100644 --- a/src/carl_studio/mcp/protocol.py +++ b/src/carl_studio/mcp/protocol.py @@ -15,6 +15,7 @@ CHILD_TOOLS = frozenset( { "validate_config", + "prepare_training", "get_coherence_metrics", "list_backends", "list_skills", diff --git a/src/carl_studio/mcp/server.py b/src/carl_studio/mcp/server.py index 1f91c98..00e4d3f 100644 --- a/src/carl_studio/mcp/server.py +++ b/src/carl_studio/mcp/server.py @@ -53,8 +53,7 @@ mcp = CARLMCPServer("carl-studio", version=__version__) logger.info( - "carl-studio MCP server loaded: per-request session state via " - "MCPServerConnection (H2, v0.7.1)." + "carl-studio MCP server loaded: per-request session state via MCPServerConnection (H2, v0.7.1)." ) # --------------------------------------------------------------------------- @@ -136,6 +135,7 @@ async def _run_tool( """ if tool_name in {"authenticate", "sync_data"}: from carl_studio.consent import consent_gate + consent_gate("telemetry") start = time.monotonic() try: @@ -153,6 +153,7 @@ async def _run_tool( _record_tool_event(tool_name, success=True, duration_ms=duration_ms) return result + # --------------------------------------------------------------------------- # Skills availability — graceful degradation if optional deps missing # --------------------------------------------------------------------------- @@ -261,34 +262,18 @@ def _tier_error(feature: str, ctx: "Context | None" = None) -> str: def _build_skill_runner() -> "SkillRunner": from carl_studio.skills.runner import build_skill_runner + return build_skill_runner() @mcp.tool() -async def start_training(config_yaml: str) -> str: +async def start_training(config_yaml: str, prepared_plan_id: str | None = None) -> str: """Start a CARL training run from a YAML config string. Returns JSON with run_id and initial status. """ async def _body() -> str: - import yaml - from carl_studio.types.config import TrainingConfig - from carl_studio.training.trainer import CARLTrainer - - raw = yaml.safe_load(config_yaml) or {} - config = TrainingConfig(**raw) - trainer = CARLTrainer(config) - run = await trainer.train() - - return json.dumps( - { - "run_id": run.id, - "phase": run.phase.value, - "hub_job_id": run.hub_job_id, - "model": config.base_model, - "method": config.method.value, - } - ) + return json.dumps(await _start_training_body(config_yaml, prepared_plan_id)) return await _run_tool("start_training", _body) @@ -432,6 +417,44 @@ async def _body() -> str: return await _run_tool("validate_config", _body) +@mcp.tool() +async def prepare_training( + config_yaml: str, + project_root: str | None = None, + goal: str | None = None, +) -> str: + """Prepare an experiment without training, provider access or publication.""" + + async def _body() -> str: + import yaml + from functools import partial + from pathlib import Path + from carl_studio.training.preparation import prepare_training as prepare_config + from carl_studio.training.preparation import run_in_worker + from carl_studio.types.config import TrainingConfig + from carl_studio.types.preparation import TrainingGoal + + try: + raw = yaml.safe_load(config_yaml) or {} + config = TrainingConfig.model_validate(raw) + except (ValueError, yaml.YAMLError): + from carl_core.errors import ValidationError + + raise ValidationError( + "Invalid training configuration", code="carl.preparation.config" + ) from None + if goal is not None: + config.goal = (config.goal or TrainingGoal()).model_copy(update={"description": goal}) + prepared = await run_in_worker( + partial(prepare_config, config, project_root=Path(project_root) if project_root else None) + ) + result = prepared.model_dump(mode="json") + result["ready"] = prepared.ready + return json.dumps(result) + + return await _run_tool("prepare_training", _body) + + @mcp.tool() async def generate_bundle(config_yaml: str) -> str: """Generate a self-contained training script for HF Jobs from a config. @@ -473,9 +496,7 @@ async def authenticate(jwt: str, ctx: Context | None = None) -> str: async def _body() -> str: if not jwt or not isinstance(jwt, str): - return json.dumps( - {"error": "jwt must be a non-empty string", "authenticated": False} - ) + return json.dumps({"error": "jwt must be a non-empty string", "authenticated": False}) try: # Server-side JWT verification via Supabase Edge Function @@ -502,9 +523,7 @@ async def _body() -> str: jwt=jwt, tier=tier, user_id=user_id, - authenticated_at=datetime.fromtimestamp( - int(time.time()), tz=timezone.utc - ), + authenticated_at=datetime.fromtimestamp(int(time.time()), tz=timezone.utc), ) _set_session(new_session) @@ -529,8 +548,7 @@ async def _body() -> str: "error": f"Server verification failed: {e}", "authenticated": False, "hint": ( - "Ensure carl.camp is reachable, or check your JWT " - "with 'carl camp account'" + "Ensure carl.camp is reachable, or check your JWT with 'carl camp account'" ), } ) @@ -659,9 +677,7 @@ async def run_skill( async def _body() -> str: if not _skills_available: - return json.dumps( - {"error": "Skills not installed. pip install carl-studio[skills]"} - ) + return json.dumps({"error": "Skills not installed. pip install carl-studio[skills]"}) try: inputs = json.loads(inputs_json) if inputs_json else {} @@ -692,9 +708,7 @@ async def _body() -> str: return result.model_dump_json() except Exception as e: runner.close() - return json.dumps( - {"error": str(e), "skill_name": skill_name, "success": False} - ) + return json.dumps({"error": str(e), "skill_name": skill_name, "success": False}) return await _run_tool("run_skill", _body) @@ -713,9 +727,7 @@ async def list_skills() -> str: async def _body() -> str: if not _skills_available: - return json.dumps( - {"error": "Skills not installed. pip install carl-studio[skills]"} - ) + return json.dumps({"error": "Skills not installed. pip install carl-studio[skills]"}) runner = _build_skill_runner() skills_list = [ @@ -840,9 +852,7 @@ async def _body() -> str: return _tier_error("sync.cloud", ctx) if direction not in ("push", "pull"): - return json.dumps( - {"error": f"Invalid direction '{direction}'. Use 'push' or 'pull'."} - ) + return json.dumps({"error": f"Invalid direction '{direction}'. Use 'push' or 'pull'."}) # Parse entity_types — comma-separated or single types_list = [t.strip() for t in entity_types.split(",") if t.strip()] or ["runs"] @@ -905,28 +915,48 @@ async def _body() -> str: from carl_studio.mcp.output_schemas import register_output_schemas # noqa: E402 -async def _start_training_body(config_yaml: str) -> dict[str, object]: +async def _start_training_body( + config_yaml: str, prepared_plan_id: str | None = None +) -> dict[str, object]: """Shared training body — reused by sync ``start_training`` and the async version.""" import yaml from carl_studio.types.config import TrainingConfig - from carl_studio.training.trainer import CARLTrainer + from carl_studio.training.pipeline import submit_training - raw: dict[str, object] = yaml.safe_load(config_yaml) or {} - config = TrainingConfig(**raw) # type: ignore[arg-type] - trainer = CARLTrainer(config) - run = await trainer.train() + try: + raw = yaml.safe_load(config_yaml) or {} + config = TrainingConfig.model_validate(raw) + except (ValueError, yaml.YAMLError): + from carl_core.errors import ValidationError + + raise ValidationError( + "Invalid training configuration", code="carl.preparation.config" + ) from None + from carl_studio.harness.runtime import get_runtime + from carl_core.errors import PermissionError as CARLPermissionError + + context = get_runtime().context + if context.depth > 0 and not context.allow_write: + raise CARLPermissionError( + "Training requires an authorized write scope", code="carl.training.permission" + ) + run = await submit_training(config, prepared_plan_id=prepared_plan_id) return { "run_id": run.id, "phase": run.phase.value, "hub_job_id": run.hub_job_id, "model": config.base_model, "method": config.method.value, + "checkpoint": run.checkpoint, + "acceptance": run.acceptance.model_dump(mode="json") if run.acceptance else None, } @mcp.tool() @async_task("submit_async_training") -async def submit_async_training(config_yaml: str) -> dict[str, object]: +async def submit_async_training( + config_yaml: str, prepared_plan_id: str | None = None +) -> dict[str, object]: """Submit a CARL training run asynchronously. Returns a task handle immediately. Poll ``tasks_get(task_id)`` to @@ -934,7 +964,7 @@ async def submit_async_training(config_yaml: str) -> dict[str, object]: ``start_training`` shape (``run_id``, ``phase``, ``hub_job_id``, ``model``, ``method``). """ - return await _start_training_body(config_yaml) + return await _start_training_body(config_yaml, prepared_plan_id) @mcp.tool(name="carl.presence.self") diff --git a/src/carl_studio/mcp/tasks.py b/src/carl_studio/mcp/tasks.py index 2622058..73460d5 100644 --- a/src/carl_studio/mcp/tasks.py +++ b/src/carl_studio/mcp/tasks.py @@ -16,10 +16,9 @@ * ``status`` is a plain string (``pending | running | completed | failed | cancelled``) rather than an enum, to keep the write-path ``str``-based and serialization-cheap. -* Cancellation is cooperative: :meth:`MCPTaskStore.cancel` flips the row - to ``cancelled`` and the wrapped body polls a ``anyio.Event`` between - steps. If the body has already completed we still mark it cancelled but - return ``False`` to signal "no-op" to the caller. +* The task tool cancels an owned worker and waits for cleanup before recording + ``cancelled``. Running tasks without live worker custody require reconciliation. + Store-only cancellation remains available for pending work. * ``params_hash`` uses :func:`carl_core.hashing.content_hash` over a canonical JSON representation so retries with the same params are trivially deduplicatable by the orchestrator (we don't enforce dedup @@ -29,6 +28,7 @@ from __future__ import annotations import json +import asyncio import logging import os import sqlite3 @@ -626,6 +626,7 @@ def __exit__(self, *_: object) -> None: _default_store: MCPTaskStore | None = None +_live_tasks: dict[str, Any] = {} def get_default_store() -> MCPTaskStore: @@ -651,8 +652,14 @@ async def _run_in_background( ) -> None: """Drive the wrapped body, writing result/error into the store.""" store.mark_running(task_id) + current = store.get(task_id) + if current is None or current.status != "running": + return try: result = await body(*args, **kwargs) + except asyncio.CancelledError: + store.cancel(task_id) + raise except CARLError as exc: store.mark_failed(task_id, exc) return @@ -703,7 +710,13 @@ async def _wrapper(*args: Any, **kwargs: Any) -> dict[str, Any]: task = resolved_store.create(tool_name, params) async def _bg() -> None: - await _run_in_background(resolved_store, task.task_id, body, args, kwargs) + import asyncio + + _live_tasks[task.task_id] = asyncio.current_task() + try: + await _run_in_background(resolved_store, task.task_id, body, args, kwargs) + finally: + _live_tasks.pop(task.task_id, None) # Spawn detached: caller returns immediately with the handle. async with anyio.create_task_group() as tg: @@ -813,6 +826,31 @@ async def _tasks_cancel(task_id: str) -> dict[str, Any]: "task_id": task_id, "status": existing.status, } + worker = _live_tasks.get(task_id) + if worker is not None: + if not worker.cancelling(): + worker.cancel() + try: + await worker + except asyncio.CancelledError: + pass + stopped = store.get(task_id) + if stopped is not None and stopped.status == "cancelled": + return {"cancelled": True, "task_id": task_id} + if stopped is not None and stopped.status == "failed": + return { + "cancelled": False, + "task_id": task_id, + "reason": "execution_not_confirmed", + "status": stopped.status, + } + elif existing.status == "running": + return { + "cancelled": False, + "task_id": task_id, + "reason": "execution_not_owned", + "status": existing.status, + } changed = store.cancel(task_id) return {"cancelled": bool(changed), "task_id": task_id} diff --git a/src/carl_studio/run_diff.py b/src/carl_studio/run_diff.py index 9528cdf..f6993db 100644 --- a/src/carl_studio/run_diff.py +++ b/src/carl_studio/run_diff.py @@ -36,6 +36,11 @@ class RunSummary(BaseModel): crystallization_count: int | None = None step_count: int = 0 status: str | None = None + goal_metric: str | None = None + goal_value: float | None = None + acceptance_status: str | None = None + policy_passed: bool | None = None + elapsed_seconds: float | None = None class RunDiffReport(BaseModel): @@ -54,6 +59,7 @@ class RunDiffReport(BaseModel): sample_size_delta: int | None = None contraction_holds_change: str | None = None divergence_step: int | None = None + goal_value_delta: float | None = None step_rows: list[dict[str, Any]] = Field(default_factory=list) # type: ignore[arg-type] @@ -147,6 +153,9 @@ def summarize_run(run: dict[str, Any], metrics: list[dict[str, Any]]) -> RunSumm status = run.get("status") status_str = str(status) if status is not None else None + acceptance = _coerce_result(result.get("acceptance")) + candidate = _coerce_result(acceptance.get("candidate")) + usage = _coerce_result(result.get("resource_usage")) return RunSummary( run_id=run_id, phi_mean=_as_float(result.get("phi_mean")), @@ -156,6 +165,11 @@ def summarize_run(run: dict[str, Any], metrics: list[dict[str, Any]]) -> RunSumm crystallization_count=_crystallization_count(result), step_count=len(metrics) if metrics else 0, status=status_str, + goal_metric=str(candidate["primary_metric"]) if candidate.get("primary_metric") else None, + goal_value=_as_float(candidate.get("primary_value")), + acceptance_status=str(acceptance["status"]) if acceptance.get("status") else None, + policy_passed=_as_bool(acceptance.get("policy_passed")), + elapsed_seconds=_as_float(usage.get("elapsed_seconds")), ) @@ -240,6 +254,9 @@ def compute_diff( sample_size_delta=_delta_int(a.sample_size, b.sample_size), contraction_holds_change=_contraction_change(a.contraction_holds, b.contraction_holds), divergence_step=_first_divergence(metrics_a, metrics_b, divergence_threshold), + goal_value_delta=_delta_float(a.goal_value, b.goal_value) + if a.goal_metric is not None and a.goal_metric == b.goal_metric + else None, ) if steps: @@ -298,6 +315,22 @@ def render_diff(report: RunDiffReport, console: Any | None = None) -> None: console.blank() table = console.make_table("Metric", "A", "B", "Delta", title="Aggregate") + if a.goal_metric or b.goal_metric: + table.add_row( + a.goal_metric + if a.goal_metric == b.goal_metric + else "goal metric (different definitions)", + _fmt_float(a.goal_value), + _fmt_float(b.goal_value), + _fmt_delta_float(report.goal_value_delta), + ) + table.add_row( + "candidate acceptance", a.acceptance_status or "-", b.acceptance_status or "-", "-" + ) + table.add_row("policy checks", _fmt_bool(a.policy_passed), _fmt_bool(b.policy_passed), "-") + table.add_row( + "elapsed seconds", _fmt_float(a.elapsed_seconds), _fmt_float(b.elapsed_seconds), "-" + ) table.add_row( "phi_mean", _fmt_float(a.phi_mean), diff --git a/src/carl_studio/training/acceptance.py b/src/carl_studio/training/acceptance.py new file mode 100644 index 0000000..5cd6d38 --- /dev/null +++ b/src/carl_studio/training/acceptance.py @@ -0,0 +1,82 @@ +"""Compare a trained candidate with its bound baseline and declared goal.""" + +from __future__ import annotations + +from carl_studio.types.preparation import ( + EvaluationMeasurement, + TrainingAcceptance, + TrainingGoal, +) + + +def goal_progress( + goal: TrainingGoal, baseline_value: float, candidate_value: float +) -> tuple[float, bool, bool]: + """Return directional progress, target satisfaction and improvement.""" + delta = candidate_value - baseline_value + progress = delta if goal.direction == "higher" else -delta + target = ( + candidate_value >= goal.threshold + if goal.direction == "higher" + else candidate_value <= goal.threshold + ) + return progress, target, progress > goal.min_delta + + +def compare_candidate( + goal: TrainingGoal, + baseline: EvaluationMeasurement, + candidate: EvaluationMeasurement, +) -> TrainingAcceptance: + """Accept only comparable goal progress, policies and measured coherence.""" + reasons: list[str] = [] + comparable = ( + bool(baseline.sample_ids) + and len(set(baseline.sample_ids)) == len(baseline.sample_ids) + and baseline.sample_ids == candidate.sample_ids + and baseline.dataset_sha256 == candidate.dataset_sha256 + and baseline.generation == candidate.generation + and bool(baseline.generation) + and baseline.primary_metric == candidate.primary_metric == goal.primary_metric + ) + if not comparable: + reasons.append("Evaluation populations or settings differ") + delta = candidate.primary_value - baseline.primary_value + _, target, improved = goal_progress(goal, baseline.primary_value, candidate.primary_value) + if not target: + reasons.append("The declared task target failed") + if not improved: + reasons.append("The candidate did not improve beyond the declared margin") + missing_policies = [ + policy.id for policy in goal.policies if policy.id not in candidate.policy_results + ] + policy_passed = not missing_policies and all( + candidate.policy_results[policy.id] >= policy.threshold for policy in goal.policies + ) + if not policy_passed: + reasons.append("A required policy check failed or is unavailable") + coherence = candidate.coherence or {} + has_coherence = candidate.coherence_source == "full_logits" and all( + metric in coherence for metric in ("phi_mean", "discontinuity_score") + ) + coherence_passed = has_coherence and ( + coherence["phi_mean"] >= goal.coherence_phi_floor + and goal.discontinuity_min <= coherence["discontinuity_score"] <= goal.discontinuity_max + ) + if not coherence_passed: + reasons.append("The required full-logit coherence check failed or is unavailable") + if not comparable or not has_coherence or missing_policies: + status = "inconclusive" + elif target and improved and policy_passed and coherence_passed: + status = "accepted" + else: + status = "rejected" + return TrainingAcceptance( + status=status, + reasons=reasons or ["Goal progress, policy checks and coherence passed"], + baseline=baseline, + candidate=candidate, + goal_delta=delta, + policy_passed=policy_passed, + coherence_passed=coherence_passed, + ) diff --git a/src/carl_studio/training/pipeline.py b/src/carl_studio/training/pipeline.py index a231bc6..9e96bbc 100644 --- a/src/carl_studio/training/pipeline.py +++ b/src/carl_studio/training/pipeline.py @@ -78,6 +78,11 @@ def __init__( *, skip_credits: bool = False, resume_from_checkpoint: bool | str | None = None, + task_reward_funcs: list[Any] | None = None, + task_reward_weights: list[float] | None = None, + task_reward_stages: list[str] | None = None, + evaluator: Any = None, + primary_metric: str | None = None, ) -> None: self.config = config self._on_event = on_event or (lambda e: None) @@ -86,6 +91,18 @@ def __init__( self.resume_from_checkpoint = resume_from_checkpoint self._sft_job_id: str | None = None self._grpo_job_id: str | None = None + self._trainer_bindings = ( + { + "task_reward_funcs": task_reward_funcs, + "task_reward_weights": task_reward_weights, + "task_reward_stages": task_reward_stages, + } + if task_reward_funcs is not None + else {} + ) + self._evaluator = evaluator + self._primary_metric = primary_metric + self.active_run: TrainingRun | None = None def _emit( self, stage: PipelineStage, message: str, progress: float = 0.0, **detail: Any @@ -121,7 +138,8 @@ def plan(self) -> PipelinePlan: ) stages.append(("eval_gate", "Final eval gate")) - stages.append(("push", f"Push to Hub: {c.output_repo or '(auto)'}")) + if c.push_to_hub: + stages.append(("push", f"Push to Hub after evaluation: {c.output_repo}")) config_summary = { "Model": c.base_model, @@ -174,13 +192,22 @@ async def _run_full_pipeline(self) -> TrainingRun: sft_config = copy.deepcopy(self.config) sft_config.method = TrainingMethod.SFT + sft_config.push_to_hub = ( + self.config.compute_target != ComputeTarget.LOCAL and self.config.push_to_hub + ) + sft_config.hub_private = True + if self.config.output_dir is not None: + sft_config.output_dir = self.config.output_dir / "sft" sft_trainer = CARLTrainer( sft_config, skip_credits=self.skip_credits, resume_from_checkpoint=self.resume_from_checkpoint, + **self._trainer_bindings, ) + self.active_run = sft_trainer.run sft_run = await sft_trainer.train() + self.active_run = sft_run if sft_run.phase == RunPhase.FAILED: self._emit(PipelineStage.FAILED, f"SFT failed: {sft_run.error_message}") @@ -201,7 +228,7 @@ async def _run_full_pipeline(self) -> TrainingRun: # Stage 3: SFT Gate self._emit(PipelineStage.SFT_GATE, "Running SFT eval gate...", 0.0) - gate_passed = self._check_gate(sft_run) + gate_passed = await self._evaluate_gate(sft_run) if not gate_passed: self._emit(PipelineStage.FAILED, "SFT gate failed — model not ready for GRPO") sft_run.phase = RunPhase.FAILED @@ -213,12 +240,28 @@ async def _run_full_pipeline(self) -> TrainingRun: # Stage 4: GRPO self._emit(PipelineStage.GRPO, "Starting GRPO...", 0.0) + grpo_config = self.config.model_copy(deep=True) + grpo_config.starting_adapters = [ + *self.config.starting_adapters, + *([self.config.sft_adapter] if self.config.sft_adapter else []), + ] + grpo_config.sft_adapter = sft_run.checkpoint + grpo_config.push_to_hub = ( + self.config.compute_target != ComputeTarget.LOCAL and self.config.push_to_hub + ) + grpo_config.hub_private = True + if self.config.output_dir is not None: + grpo_config.output_dir = self.config.output_dir / "grpo" grpo_trainer = CARLTrainer( - self.config, + grpo_config, skip_credits=self.skip_credits, resume_from_checkpoint=self.resume_from_checkpoint, + **self._trainer_bindings, ) + self.active_run = grpo_trainer.run grpo_run = await grpo_trainer.train() + self.active_run = grpo_run + grpo_run.artifacts = [*sft_run.artifacts, *grpo_run.artifacts] if grpo_run.phase == RunPhase.FAILED: self._emit(PipelineStage.FAILED, f"GRPO failed: {grpo_run.error_message}") @@ -239,17 +282,20 @@ async def _run_full_pipeline(self) -> TrainingRun: # Stage 5: GRPO Gate self._emit(PipelineStage.GRPO_GATE, "Running final eval gate...", 0.0) - gate_passed = self._check_gate(grpo_run) + gate_passed = await self._evaluate_gate(grpo_run) self._emit( PipelineStage.GRPO_GATE, - "Final gate PASSED" if gate_passed else "Final gate FAILED (pushing anyway)", + "Final gate PASSED" if gate_passed else "Final gate FAILED", 1.0, ) + if not gate_passed: + grpo_run.phase = RunPhase.FAILED + grpo_run.error_message = "Final eval gate failed" + return grpo_run # Stage 6: Push - self._emit(PipelineStage.PUSH, "Pushing to Hub...", 0.0) - await self._push_model(grpo_run) - self._emit(PipelineStage.PUSH, "Pushed", 1.0) + if not await self._publish_accepted(grpo_run): + return grpo_run self._emit(PipelineStage.DONE, "Pipeline complete", 1.0) grpo_run.phase = RunPhase.COMPLETE @@ -262,12 +308,20 @@ async def _run_single_stage(self) -> TrainingRun: stage = PipelineStage.SFT # Use SFT stage for any single-stage self._emit(stage, f"Starting {self.config.method.value.upper()}...", 0.0) + stage_config = self.config.model_copy(deep=True) + stage_config.push_to_hub = ( + self.config.compute_target != ComputeTarget.LOCAL and self.config.push_to_hub + ) + stage_config.hub_private = True trainer = CARLTrainer( - self.config, + stage_config, skip_credits=self.skip_credits, resume_from_checkpoint=self.resume_from_checkpoint, + **self._trainer_bindings, ) + self.active_run = trainer.run run = await trainer.train_and_watch(poll_interval=self._poll_interval) + self.active_run = run if run.phase == RunPhase.FAILED: self._emit(PipelineStage.FAILED, f"Training failed: {run.error_message}") @@ -275,10 +329,15 @@ async def _run_single_stage(self) -> TrainingRun: self._emit(stage, "Training complete", 1.0) + if not await self._evaluate_gate(run): + run.phase = RunPhase.FAILED + run.error_message = "Final eval gate failed" + self._emit(PipelineStage.FAILED, run.error_message) + return run + # Push - self._emit(PipelineStage.PUSH, "Pushing to Hub...", 0.0) - await self._push_model(run) - self._emit(PipelineStage.PUSH, "Pushed", 1.0) + if not await self._publish_accepted(run): + return run self._emit(PipelineStage.DONE, "Pipeline complete", 1.0) run.phase = RunPhase.COMPLETE @@ -318,40 +377,71 @@ def _validate(self) -> list[str]: return issues + async def _evaluate_gate(self, run: TrainingRun) -> bool: + from carl_studio.training.preparation import run_in_worker + + return await run_in_worker(lambda: self._check_gate(run)) + def _check_gate(self, run: TrainingRun) -> bool: """Check if a training run passes the eval gate. For GRPO runs, uses Phase 2' eval (multi-turn sandbox, threshold 0.30). For SFT runs, uses Phase 1 eval (tool-call format, threshold 0.50). - Falls back to completion-check if eval deps aren't available. + Missing evaluation or artifacts cannot satisfy the gate. """ if run.phase == RunPhase.FAILED: return False - output_repo = self.config.output_repo - if not output_repo: - logger.warning("No output_repo -- gate defaults to completion check") - return run.phase == RunPhase.COMPLETE + checkpoint = run.checkpoint + if not checkpoint: + logger.warning("No stage checkpoint available for evaluation") + return False # Determine phase and threshold based on training method - is_grpo = self.config.method == TrainingMethod.GRPO - eval_phase = "2prime" if is_grpo else "auto" + is_grpo = run.config.method == TrainingMethod.GRPO + eval_phase = "1" if self._evaluator is not None else ("2prime" if is_grpo else "auto") default_threshold = 0.30 if is_grpo else 0.50 try: from carl_studio.eval.runner import EvalConfig, EvalRunner, EvalGate eval_config = EvalConfig( - checkpoint=output_repo, - dataset=self.config.dataset_repo, - data_files="eval.jsonl" if is_grpo else None, + checkpoint=checkpoint, + dataset=run.config.eval_dataset_repo or run.config.dataset_repo, + dataset_split=run.config.eval_split, phase=eval_phase, - threshold=getattr(self.config, "eval_threshold", default_threshold), - base_model=self.config.base_model if is_grpo else None, - sft_adapter=getattr(self.config, "sft_adapter", None) if is_grpo else None, - max_samples=getattr(self.config, "eval_samples", 100), + threshold=( + run.config.goal.threshold + if run.config.goal + else getattr(self.config, "eval_threshold", default_threshold) + ), + metric_direction=run.config.goal.direction if run.config.goal else "higher", + base_model=run.config.base_model, + sft_adapter=run.config.sft_adapter, + starting_adapters=run.config.starting_adapters, + tokenizer_source=run.config.tokenizer_source, + max_samples=( + run.config.goal.max_eval_samples + if run.config.goal + else getattr(self.config, "eval_samples", 100) + ), + max_new_tokens=min(max(run.config.max_completion_length, 64), 512), + **( + { + "coherence_phi_floor": run.config.goal.coherence_phi_floor, + "discontinuity_min": run.config.goal.discontinuity_min, + "discontinuity_max": run.config.goal.discontinuity_max, + } + if run.config.goal + else {} + ), + ) + runner_args = ( + {"evaluator": self._evaluator, "primary_metric": self._primary_metric} + if self._evaluator is not None + else {} ) - report = EvalRunner(eval_config).run() + report = EvalRunner(eval_config, **runner_args).run() gate = EvalGate( threshold=eval_config.threshold, phase=eval_phase, @@ -370,29 +460,270 @@ def _check_gate(self, run: TrainingRun) -> bool: return gate_result except ImportError: logger.warning( - "EvalRunner not available (install carl-studio[training]). Falling back to completion check." + "EvalRunner not available (install carl-studio[training]); evaluation failed" ) - return run.phase == RunPhase.COMPLETE + return False except Exception as e: - logger.warning("Eval gate failed: %s. Treating as FAIL.", e) + logger.warning( + "Eval gate failed: %s. Treating as FAIL.", + type(e).__name__ if run.config.goal is not None else e, + ) return False async def _push_model(self, run: TrainingRun) -> None: """Push model to Hub if output_repo is configured.""" output_repo = self.config.output_repo - if not output_repo: + if not self.config.push_to_hub or not output_repo: logger.info("No output_repo configured — skipping push") return + from carl_studio.hub.models import push_with_metadata + + if not run.checkpoint: + raise ValueError("Publication requires a completed checkpoint") + if self.config.compute_target != ComputeTarget.LOCAL: + raise ValueError( + "Remote candidate publication requires explicit artifact reconciliation" + ) + await push_with_metadata( + model_path=run.checkpoint, + repo_id=output_repo, + base_model=self.config.base_model, + method=self.config.method.value, + dataset=self.config.dataset_repo or "", + ) + + async def _publish_accepted(self, run: TrainingRun) -> bool: + if not self.config.push_to_hub: + return True + if self.config.compute_target != ComputeTarget.LOCAL: + self._emit( + PipelineStage.PUSH, + "Evaluated private remote checkpoint retained; public publication is separate", + 1.0, + ) + return True + self._emit(PipelineStage.PUSH, "Publishing evaluated artifact", 0.0) try: - from carl_studio.hub.models import push_with_metadata - - await push_with_metadata( - model_path=output_repo, # For remote jobs, model is already on Hub - repo_id=output_repo, - base_model=self.config.base_model, - method=self.config.method.value, - dataset=self.config.dataset_repo or "", + await self._push_model(run) + except Exception: + run.phase = RunPhase.FAILED + run.error_message = "Artifact publication failed" + self._emit(PipelineStage.FAILED, run.error_message) + return False + self._emit(PipelineStage.PUSH, "Published", 1.0) + return True + + +async def submit_training( + config: TrainingConfig, + *, + prepared_plan_id: str | None = None, + manager: Any = None, + skip_credits: bool = False, + resume_from_checkpoint: bool | str | None = None, +) -> TrainingRun: + """Shared CLI/MCP submission through the current trainer and adapter owners.""" + from carl_studio.training.trainer import CARLTrainer + + if prepared_plan_id is None: + if config.goal is not None: + from carl_core.errors import ValidationError + + raise ValidationError( + "Prepare and review the goal before training", code="carl.preparation.required" + ) + if config.adapter != "trl": + from carl_studio.adapters.registry import get_adapter + + job = get_adapter(config.adapter).submit(config.model_dump(mode="json")) + return TrainingRun(id=job.run_id, config=config, phase=RunPhase.PROVISIONING) + if config.pipeline: + return await SendItPipeline( + config, skip_credits=skip_credits, resume_from_checkpoint=resume_from_checkpoint + ).run() + return await CARLTrainer( + config, skip_credits=skip_credits, resume_from_checkpoint=resume_from_checkpoint + ).train() + + import asyncio + import time + from functools import partial + from pathlib import Path + + from carl_core.errors import ValidationError + + from carl_studio.eval.runner import EvalConfig, EvalRunner + from carl_studio.experiment.types import Witness + from carl_studio.training.acceptance import compare_candidate, goal_progress + from carl_studio.training.preparation import ( + default_manager, + load_preparation, + resolve_callable, + run_in_worker, + validate_preparation, + ) + from carl_studio.types.preparation import EvaluationMeasurement + + owner = manager or default_manager() + prepared = load_preparation(prepared_plan_id, owner) + await run_in_worker(partial(validate_preparation, prepared, config)) + existing = owner.load_training_result(prepared.plan_id) + if existing is not None and not ( + resume_from_checkpoint and existing.phase in {RunPhase.PAUSED, RunPhase.FAILED} + ): + return existing + config = TrainingConfig.model_validate(prepared.config) + if resume_from_checkpoint is not None: + supplied_resume = str(Path(resume_from_checkpoint).resolve()) + if supplied_resume != config.resume_from_checkpoint: + raise ValidationError( + "Resume checkpoint does not match prepared inputs", code="carl.preparation.resume" ) - except Exception as exc: - logger.warning("Push failed (non-fatal): %s", exc) + root = Path(prepared.project_root) + eval_source = next(source for source in prepared.sources if source.kind == "eval_data") + owner.claim_training(prepared.plan_id, resume=bool(resume_from_checkpoint)) + started = time.monotonic() + run = TrainingRun(id=prepared.plan_id, config=config) + pipeline: SendItPipeline | None = None + + async def read_measurement( + checkpoint: str, *, candidate: bool, stage_config: TrainingConfig | None = None + ) -> EvaluationMeasurement: + measured_config = stage_config or config + evaluation = EvalConfig( + checkpoint=checkpoint, + base_model=config.base_model if candidate or config.sft_adapter else None, + sft_adapter=measured_config.sft_adapter, + starting_adapters=measured_config.starting_adapters, + tokenizer_source=measured_config.tokenizer_source, + dataset=config.eval_dataset_repo or config.dataset_repo, + dataset_split=config.eval_split, + phase="1", + threshold=prepared.goal.threshold, + metric_direction=prepared.goal.direction, + max_samples=prepared.goal.max_eval_samples, + max_new_tokens=min(max(config.max_completion_length, 64), 512), + coherence_phi_floor=prepared.goal.coherence_phi_floor, + discontinuity_min=prepared.goal.discontinuity_min, + discontinuity_max=prepared.goal.discontinuity_max, + ) + runner = EvalRunner( + evaluation, evaluator=evaluator, primary_metric=prepared.goal.primary_metric + ) + report = await run_in_worker(runner.run) + values = {} + for policy in prepared.goal.policies: + result = policies[policy.id](runner.last_completions, runner.last_samples) + if not isinstance(result, dict) or policy.id not in result: + raise ValidationError( + "A policy did not report its declared metric", code="carl.preparation.policy" + ) + values[policy.id] = float(result[policy.id]) + if report.n_samples != len(prepared.eval_sample_ids): + raise ValidationError( + "The evaluation population changed", code="carl.preparation.population" + ) + return EvaluationMeasurement( + checkpoint=checkpoint, + dataset_sha256=eval_source.sha256, + sample_ids=prepared.eval_sample_ids, + primary_metric=report.primary_metric, + primary_value=report.primary_value, + metrics=report.metrics, + coherence=report.coherence, + coherence_source="full_logits" if report.coherence else "unavailable", + policy_results=values, + generation={ + "max_new_tokens": evaluation.max_new_tokens, + "seed": config.seed, + "sampling": "greedy", + "phase": evaluation.phase, + }, + ) + + try: + evaluator = resolve_callable(prepared.goal.evaluator, root) + rewards = [ + resolve_callable(binding.reference, root, reward=True) for binding in prepared.goal.rewards + ] + policies = { + policy.id: resolve_callable(policy.reference, root) for policy in prepared.goal.policies + } + baseline = await read_measurement(config.base_model, candidate=False) + await run_in_worker(partial(validate_preparation, prepared)) + trainer_args = { + "skip_credits": skip_credits, + "resume_from_checkpoint": resume_from_checkpoint or config.resume_from_checkpoint, + "task_reward_funcs": rewards, + "task_reward_weights": [binding.weight for binding in prepared.goal.rewards], + "task_reward_stages": [binding.stage for binding in prepared.goal.rewards], + } + if config.pipeline: + pipeline = SendItPipeline( + config, + evaluator=evaluator, + primary_metric=prepared.goal.primary_metric, + **trainer_args, + ) + owner.start(prepared.plan_id, run.id) + run = await pipeline.run() + else: + trainer = CARLTrainer(config, **trainer_args) + run = trainer.run + owner.start(prepared.plan_id, run.id) + run = await trainer.train() + if run.phase != RunPhase.COMPLETE or not run.checkpoint: + return run + candidate = await read_measurement(run.checkpoint, candidate=True, stage_config=run.config) + await run_in_worker(partial(validate_preparation, prepared)) + run.acceptance = compare_candidate(prepared.goal, baseline, candidate) + progress, target, improved = goal_progress( + prepared.goal, baseline.primary_value, candidate.primary_value + ) + goal_passed = target and improved + owner.add_witness( + prepared.plan_id, + Witness( + prediction_id="P_goal", + observed_value=progress, + observed_at=run.id, + supports=goal_passed, + detail={"baseline": baseline.primary_value, "candidate": candidate.primary_value}, + ), + ) + if candidate.coherence is not None and "phi_mean" in candidate.coherence: + owner.add_witness( + prepared.plan_id, + Witness( + prediction_id="P_coherence", + observed_value=candidate.coherence["phi_mean"], + observed_at=run.id, + supports=run.acceptance.coherence_passed, + ), + ) + for policy in prepared.goal.policies: + if policy.id in candidate.policy_results: + owner.add_witness( + prepared.plan_id, + Witness( + prediction_id=f"P_policy_{policy.id}", + observed_value=candidate.policy_results[policy.id], + observed_at=run.id, + supports=candidate.policy_results[policy.id] >= policy.threshold, + ), + ) + owner.judge(prepared.plan_id) + return run + except asyncio.CancelledError: + if pipeline is not None and pipeline.active_run is not None: + run = pipeline.active_run + run.phase = RunPhase.PAUSED + raise + except Exception: # noqa: BLE001 - declared callables share this sanitized boundary + run.phase = RunPhase.FAILED + run.error_message = "Prepared training or evaluation failed" + raise ValidationError(run.error_message, code="carl.preparation.execution") from None + finally: + run.resource_usage["elapsed_seconds"] = time.monotonic() - started + owner.save_training_result(prepared.plan_id, run) diff --git a/src/carl_studio/training/preparation.py b/src/carl_studio/training/preparation.py new file mode 100644 index 0000000..fb9186a --- /dev/null +++ b/src/carl_studio/training/preparation.py @@ -0,0 +1,821 @@ +"""Prepare and bind experiments through existing data and experiment owners.""" + +from __future__ import annotations + +import ast +import hashlib +import importlib +import importlib.util +import json +import math +import re +import sys +import types +from collections.abc import Callable +from pathlib import Path +from threading import RLock +from typing import Any, Literal, cast + +from carl_core.errors import CARLError, ValidationError +from carl_core.hashing import content_hash +from carl_core.safepath import safe_resolve + +from carl_studio.data.types import Domain, Modality, UnifiedSample, Verification +from carl_studio.experiment.manager import ExperimentManager +from carl_studio.experiment.types import Hypothesis, Prediction, PredictionComparator +from carl_studio.types.config import ComputeTarget, TrainingConfig, TrainingMethod +from carl_studio.types.preparation import ( + ReadinessIssue, + SourceBinding, + TrainingGoal, + TrainingPreparation, +) + +RewardCallable = Callable[..., list[float]] +EvaluatorCallable = Callable[[list[str], list[dict[str, Any]]], dict[str, float]] +_REFERENCE = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_]\w*$") +_PLAN_ID = re.compile(r"^Eprep_[0-9a-f]{24}$") +_SECRET_FILES = {".env", "credentials", "token", "id_rsa", "id_ed25519"} +_IMPORT_LOCK = RLock() +_CREDENTIAL_FIELDS = frozenset( + { + "api_key", + "hf_token", + "huggingface_token", + "hugging_face_hub_token", + "hub_token", + "hub_api_token", + "access_token", + "authorization", + "password", + "client_secret", + "token", + "secret", + "bearer_token", + "private_key", + "credentials", + } +) + + +async def run_in_worker[ResultT]( + operation: Callable[[], ResultT], *, on_cancel: Callable[[], None] | None = None +) -> ResultT: + """Run blocking work with caller context and wait for cancellation cleanup.""" + import asyncio + from contextvars import copy_context + + future = asyncio.get_running_loop().run_in_executor(None, copy_context().run, operation) + try: + return await asyncio.shield(future) + except asyncio.CancelledError: + if on_cancel is not None: + on_cancel() + while not future.done(): + try: + await asyncio.shield(future) + except asyncio.CancelledError: + continue + future.result() + raise + + +def _implementation_sources() -> tuple[Path, ...]: + library = Path(__file__).parent + return tuple( + path.resolve() + for path in ( + library / "preparation.py", + library / "pipeline.py", + library / "trainer.py", + library / "acceptance.py", + library.parent / "eval" / "runner.py", + ) + ) + + +def model_sources(config: TrainingConfig, root: Path) -> list[Path]: + paths: set[Path] = set() + for value in ( + config.base_model, + config.tokenizer_source, + config.sft_adapter, + *config.starting_adapters, + ): + if not value: + continue + directory = Path(value) + directory = directory if directory.is_absolute() else root / directory + if directory.is_dir(): + paths.update(path.absolute() for path in directory.rglob("*") if path.is_file()) + return sorted(paths) + + +def callable_sources(reference: str, root: Path) -> list[Path]: + """Bind local helper and package files, including their membership.""" + source = callable_source(reference, root) + if reference == "verification": + return [source] if source is not None else [] + package = reference.split(":")[0].split(".")[0] + pending = [source] if source is not None else [] + paths: set[Path] = set(root.glob("*.py")) + if (root / package).is_dir(): + paths.update((root / package).rglob("*.py")) + pending.extend(paths) + walked: set[Path] = set() + while pending: + path = pending.pop() + if path in walked: + continue + walked.add(path) + if len(walked) > 256: + raise ValidationError( + "Binding source closure exceeds its budget", code="carl.preparation.binding" + ) + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + names = ( + [alias.name for alias in node.names] + if isinstance(node, ast.Import) + else [node.module or ""] + if isinstance(node, ast.ImportFrom) + else [] + ) + for name in names: + top = name.split(".")[0] + if (root / top).is_dir(): + pending.extend((root / top).rglob("*.py")) + elif (root / (top + ".py")).is_file(): + pending.append(root / (top + ".py")) + return sorted(path.absolute() for path in walked) + + +def file_hash(path: Path) -> str: + """Hash source bytes without loading a checkpoint into memory.""" + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(4 * 1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def default_manager() -> ExperimentManager: + """Use CARL's existing private experiment directory.""" + return ExperimentManager(Path.home() / ".carl" / "experiments") + + +def _target(sample: dict[str, Any]) -> str | None: + verification = sample.get("verification") + if isinstance(verification, dict): + typed_verification = cast(dict[str, Any], verification) + if typed_verification.get("expected_output") is not None: + return str(typed_verification["expected_output"]) + for key in ("expected_output", "answer", "golden_solution", "completion"): + if sample.get(key) is not None: + return str(sample[key]) + return None + + +def _text(completion: Any) -> str: + if isinstance(completion, str): + return completion + if isinstance(completion, list) and completion: + messages = cast(list[dict[str, Any]], completion) + return str(messages[-1].get("content", "")) + raise ValidationError("Unsupported completion shape", code="carl.preparation.completion") + + +def verification_evaluator( + completions: list[str], samples: list[dict[str, Any]] +) -> dict[str, float]: + """Check exact declared targets; missing targets are not successful tasks.""" + if not samples or len(completions) != len(samples): + raise ValidationError("Evaluation population mismatch", code="carl.preparation.population") + targets = [_target(sample) for sample in samples] + if any(target is None for target in targets): + raise ValidationError("A verification target is required", code="carl.preparation.grader") + correct = sum(text.strip() == str(target).strip() for text, target in zip(completions, targets)) + return {"task_success_rate": correct / len(samples)} + + +def verification_reward(completions: list[Any], **kwargs: Any) -> list[float]: + """Use the dataset's verification targets as a TRL task reward.""" + targets = kwargs.get("verification") + if targets is not None: + rows = [{"verification": value} for value in targets] + else: + targets = kwargs.get("expected_output", kwargs.get("answer")) + if targets is None: + raise ValidationError("Task reward needs targets", code="carl.preparation.grader") + rows = [{"expected_output": value} for value in targets] + if len(rows) != len(completions) or any(_target(row) is None for row in cast(list[Any], rows)): + raise ValidationError("Task reward population mismatch", code="carl.preparation.population") + return [ + float(_text(text).strip() == str(_target(row)).strip()) + for text, row in zip(completions, rows) + ] + + +def checked_reward(function: RewardCallable) -> RewardCallable: + """Reject missing, nonfinite and misaligned task rewards.""" + if not callable(function): + raise ValidationError("Task reward must be callable", code="carl.preparation.reward") + + def score(completions: list[Any], **kwargs: Any) -> list[float]: + try: + values = [float(value) for value in function(completions=completions, **kwargs)] + except Exception as exc: + raise ValidationError("Task reward failed", code="carl.preparation.reward") from exc + if len(values) != len(completions) or any(not math.isfinite(value) for value in values): + raise ValidationError("Invalid task reward result", code="carl.preparation.reward") + return values + + score.__name__ = getattr(function, "__name__", "task_reward") + return score + + +def callable_source(reference: str, root: Path) -> Path | None: + """Inspect a project callable declaration without executing its module.""" + if reference == "verification": + return Path(__file__).resolve() + if not _REFERENCE.fullmatch(reference): + raise ValidationError("Use module:function for a binding", code="carl.preparation.binding") + module, name = reference.split(":") + path = safe_resolve(module.replace(".", "/") + ".py", root) + if not path.is_file(): + raise ValidationError( + "Binding source is not in the project", code="carl.preparation.binding" + ) + tree = ast.parse(path.read_text()) + if not any(isinstance(node, ast.FunctionDef) and node.name == name for node in tree.body): + raise ValidationError("Binding function is not declared", code="carl.preparation.binding") + return path + + +def resolve_callable(reference: str, root: Path, *, reward: bool = False) -> Callable[..., Any]: + """Load a bound project function only at the authorized execution boundary.""" + if reference == "verification": + return verification_reward if reward else verification_evaluator + path = callable_source(reference, root) + assert path is not None + module_name, name = reference.split(":") + names = { + str(source.relative_to(root).with_suffix("")).replace("/", ".").removesuffix(".__init__") + for source in callable_sources(reference, root) + } + loaded: dict[str, types.ModuleType] = {} + + def in_project(operation: Callable[[], Any]) -> Any: + with _IMPORT_LOCK: + previous_path = list(sys.path) + previous = {key: sys.modules[key] for key in names if key in sys.modules} + for key in names: + sys.modules.pop(key, None) + sys.modules.update(loaded) + sys.path.insert(0, str(root)) + try: + return operation() + finally: + loaded.update({key: sys.modules[key] for key in names if key in sys.modules}) + for key in names: + sys.modules.pop(key, None) + sys.modules.update(previous) + sys.path[:] = previous_path + + function = in_project(lambda: getattr(importlib.import_module(module_name), name)) + if not callable(function): + raise ValidationError("Binding is not callable", code="carl.preparation.binding") + + def invoke(*args: Any, **kwargs: Any) -> Any: + return in_project(lambda: function(*args, **kwargs)) + + invoke.__name__ = name + return invoke + + +def read_samples(path: Path) -> list[dict[str, Any]]: + """Read bounded local JSON/JSONL data without a provider or model call.""" + if path.name in _SECRET_FILES or path.suffix not in {".json", ".jsonl"}: + raise ValidationError("Use a JSON or JSONL dataset", code="carl.preparation.dataset") + if path.stat().st_size > 64 * 1024 * 1024: + raise ValidationError("Prepare a bounded dataset shard", code="carl.preparation.dataset") + try: + text = path.read_text() + rows: Any = ( + json.loads(text) + if path.suffix == ".json" + else [json.loads(line) for line in text.splitlines() if line.strip()] + ) + except (OSError, ValueError) as exc: + raise ValidationError("Dataset cannot be parsed", code="carl.preparation.dataset") from exc + if ( + not isinstance(rows, list) + or not rows + or any(not isinstance(row, dict) for row in cast(list[Any], rows)) + ): + raise ValidationError( + "Dataset must contain nonempty records", code="carl.preparation.dataset" + ) + return cast(list[dict[str, Any]], rows) + + +def normalize_samples(rows: list[dict[str, Any]], source: str) -> list[UnifiedSample]: + """Keep task identity, verification and provenance together.""" + normalized: list[UnifiedSample] = [] + for row in rows: + prompt = row.get("prompt", row.get("problem_statement", row.get("question"))) + if isinstance(prompt, str): + prompt = [{"role": "user", "content": prompt}] + if not isinstance(prompt, list) or not prompt: + raise ValidationError("Dataset needs task prompts", code="carl.preparation.dataset") + verification = cast(dict[str, Any], row.get("verification") or {}) + target = _target(row) + if target is not None: + verification = {**verification, "expected_output": target} + normalized.append( + UnifiedSample( + id=str(row.get("id") or content_hash(prompt)), + prompt=cast(list[dict[str, Any]], prompt), + problem_statement=str(row.get("problem_statement") or row.get("question") or ""), + domain=row.get("domain", Domain.INSTRUCTION), + modality=row.get("modality", Modality.TEXT), + verification=Verification.model_validate(verification), + golden_solution=target, + source=str(row.get("source", source)), + metadata={ + key: value + for key, value in row.items() + if key not in {"prompt", "verification", "id", "source"} + }, + ) + ) + return normalized + + +def discover_variants(root: Path) -> dict[str, str]: + """Expose the two requested Tinny modes without promoting an ablation.""" + result: dict[str, str] = {} + for name, relative in ( + ("Tinny alone", "carl/configs/tinny-single-agent.yaml"), + ("Tinny with TCG", "carl/configs/tinny-tcg-lead.yaml"), + ): + if (root / relative).is_file(): + result[name] = relative + return result + + +def project_root_for_config(path: Path) -> Path: + """Use existing project discovery, then the nearest Git worktree.""" + from carl_studio.project_context import current + + directory = path.resolve().parent + context = current(directory) + if context is not None: + return context.root + for parent in (directory, *directory.parents): + if (parent / ".git").exists(): + return parent + return directory + + +def preparation_identity(preparation: TrainingPreparation) -> str: + """Bind configuration, source bytes, goals and the held-out population.""" + return ( + "Eprep_" + + content_hash( + { + "root": preparation.project_root, + "config": preparation.config, + "goal": preparation.goal.model_dump(mode="json"), + "sources": [source.model_dump() for source in preparation.sources], + "eval_ids": preparation.eval_sample_ids, + "capabilities": preparation.capabilities, + "issues": [issue.model_dump() for issue in preparation.issues], + "train_samples": preparation.train_samples, + "eval_samples": preparation.eval_samples, + "effects": preparation.effects, + } + )[:24] + ) + + +def prepare_training( + config: TrainingConfig, + *, + project_root: Path | None = None, + manager: ExperimentManager | None = None, + persist: bool = True, +) -> TrainingPreparation: + """Prepare local inputs and trusted bindings; never submit, install or publish.""" + if any( + name.lower().replace("-", "_") in _CREDENTIAL_FIELDS + or name.lower().endswith(("_api_key", "_password", "_client_secret")) + for name in config.extra_args + ): + raise ValidationError( + "Literal credentials are not training settings; use the configured credential owner", + code="carl.preparation.credentials", + ) + root = (project_root or Path.cwd()).resolve() + goal = config.goal or TrainingGoal() + config = config.model_copy(deep=True, update={"goal": goal, "push_to_hub": False}) + issues: list[ReadinessIssue] = [] + sources: list[SourceBinding] = [] + populations: dict[str, list[UnifiedSample]] = {} + + def issue(code: str, message: str, action: str) -> None: + issues.append(ReadinessIssue(code=code, message=message, action=action)) + + from carl_studio.adapters.registry import get_capabilities + + try: + capabilities = get_capabilities(config.adapter) + except CARLError: + capabilities = {} + issue( + "backend", + "The selected backend is not registered", + "Choose a registered training backend", + ) + if not capabilities.get("task_rewards") or not capabilities.get("artifacts"): + issue( + "backend_hooks", + "The selected backend has no qualified goal binding", + "Bind reward, evaluation and artifact hooks for this backend", + ) + if config.compute_target != ComputeTarget.LOCAL: + issue( + "remote_preparation", + "Remote goal inputs need approved artifact custody", + "Prepare local inputs or provide a qualified remote integration", + ) + if config.method not in {TrainingMethod.SFT, TrainingMethod.GRPO}: + issue( + "method", + "The selected method is not supported by this integration", + "Use a qualified training method", + ) + if not 0.0 <= goal.threshold <= 1.0: + issue( + "metric_scale", + "The current evaluation gate uses a metric in [0, 1]", + "Normalize the primary metric and its threshold to [0, 1]", + ) + if config.goal and { + "max_steps", + "output_dir", + "push_to_hub", + "hub_model_id", + "max_length", + "num_generations", + } & set(config.extra_args): + issue( + "runtime_overrides", + "Extra arguments override prepared resource or effect limits", + "Put those settings in their declared configuration fields", + ) + if config.max_steps < 1: + issue( + "step_budget", + "Training needs a finite step budget", + "Set max_steps for a bounded pilot", + ) + if config.method == TrainingMethod.GRPO and config.max_steps <= config.cascade.carl_start: + issue( + "reward_budget", + "The step budget ends before the coherence reward starts", + "Allow active coherence steps inside the pilot budget", + ) + for dependency in ("torch", "transformers", "trl", "datasets", "peft"): + try: + available = importlib.util.find_spec(dependency) is not None + except (ImportError, ValueError): + available = False + if not available: + issue( + "dependency", + "Training dependencies are incomplete", + "Install carl-studio[training] in the selected environment", + ) + break + + model = Path(config.base_model) + model = model if model.is_absolute() else root / model + if model.is_dir() and (model / "config.json").is_file(): + config.base_model = str(model.resolve()) + files = [ + p + for p in sorted(model.rglob("*")) + if p.is_file() + and p.suffix in {".json", ".safetensors", ".bin", ".model", ".txt", ".py"} + ] + if not any(p.suffix in {".safetensors", ".bin"} for p in files): + issue("model_weights", "The local model has no weights", "Supply the source checkpoint") + else: + issue( + "model_source", + "The model is not bound to local source bytes", + "Fetch the selected model revision with approval, then prepare its local checkpoint", + ) + if config.tokenizer_source: + tokenizer = Path(config.tokenizer_source) + tokenizer = tokenizer if tokenizer.is_absolute() else root / tokenizer + if not tokenizer.is_dir(): + issue( + "tokenizer", + "The tokenizer source is not bound locally", + "Prepare the selected tokenizer alongside the model", + ) + else: + config.tokenizer_source = str(tokenizer.resolve()) + if config.sft_adapter: + adapter = Path(config.sft_adapter) + adapter = adapter if adapter.is_absolute() else root / adapter + if not adapter.is_dir() or not (adapter / "adapter_config.json").is_file(): + issue( + "sft_adapter", + "The starting adapter is not bound locally", + "Prepare the selected SFT adapter before the run", + ) + else: + config.sft_adapter = str(adapter.resolve()) + for path in model_sources(config, root): + sources.append(SourceBinding(kind="model", path=str(path), sha256=file_hash(path))) + normalized_adapters: list[str] = [] + for value in config.starting_adapters: + adapter = Path(value) + adapter = adapter if adapter.is_absolute() else root / adapter + if not adapter.is_dir(): + issue( + "starting_adapter", + "A starting adapter is unavailable", + "Prepare each adapter in the declared order", + ) + normalized_adapters.append(str(adapter.resolve())) + config.starting_adapters = normalized_adapters + if config.output_dir is not None: + output = config.output_dir + output = (output if output.is_absolute() else root / output).resolve() + config.output_dir = output + input_directories = {path.parent.resolve() for path in model_sources(config, root)} + if any(output == source or source in output.parents for source in input_directories): + issue( + "output_collision", + "The candidate output overlaps the starting model", + "Choose a separate candidate directory", + ) + if config.resume_from_checkpoint: + resume = Path(config.resume_from_checkpoint) + resume = resume if resume.is_absolute() else root / resume + if not resume.is_dir() or not (resume / "trainer_state.json").is_file(): + issue( + "resume", + "Resume needs a recorded trainer checkpoint", + "Select the checkpoint from the previous run", + ) + else: + config.resume_from_checkpoint = str(resume.resolve()) + for path in sorted(resume.rglob("*")): + if path.is_file(): + sources.append( + SourceBinding( + kind="resume", path=str(path.absolute()), sha256=file_hash(path) + ) + ) + + for kind, name in ( + ("train_data", config.dataset_repo), + ("eval_data", config.eval_dataset_repo or ""), + ): + path = Path(name) + path = path if path.is_absolute() else root / path + if not name or not path.is_file(): + issue( + kind, + "A local dataset is required for this preparation", + "Provide task examples and a separate held-out JSONL file", + ) + continue + try: + samples = normalize_samples(read_samples(path), str(path.resolve())) + populations[kind] = samples + sources.append( + SourceBinding( + kind=cast(Literal["train_data", "eval_data"], kind), + path=str(path.resolve()), + sha256=file_hash(path), + ) + ) + if kind == "train_data": + config.dataset_repo = str(path.resolve()) + else: + config.eval_dataset_repo = str(path.resolve()) + if goal.evaluator == "verification" and any( + sample.verification.expected_output is None for sample in samples + ): + issue( + "verification", + "Task examples need executable grading or declared targets", + "Supply expected outputs or a project evaluator", + ) + except (ValueError, ValidationError, OSError): + issue( + kind, "Dataset preparation failed", "Check the JSON records and verification fields" + ) + + train = populations.get("train_data", []) + heldout = populations.get("eval_data", []) + for population in (train, heldout): + if len({sample.id for sample in population}) != len(population): + issue( + "duplicate_ids", "Sample identities are duplicated", "Assign stable unique task IDs" + ) + if {sample.id for sample in train} & {sample.id for sample in heldout} or { + content_hash(sample.prompt) for sample in train + } & {content_hash(sample.prompt) for sample in heldout}: + issue( + "split_overlap", + "Training and held-out tasks overlap", + "Split by task identity before preparation", + ) + + references = [ + goal.evaluator, + *[binding.reference for binding in goal.rewards], + *[policy.reference for policy in goal.policies], + ] + for path in _implementation_sources(): + sources.append( + SourceBinding(kind="callable", path=str(path.resolve()), sha256=file_hash(path)) + ) + for reference in dict.fromkeys(references): + try: + for path in callable_sources(reference, root): + sources.append( + SourceBinding(kind="callable", path=str(path), sha256=file_hash(path)) + ) + except (ValidationError, OSError, SyntaxError): + issue( + "binding", + "A required callable cannot be bound", + "Provide a declared project module:function binding", + ) + + comparison = PredictionComparator.GT + hypothesis = Hypothesis( + id="H_training_goal", + title="Prepared model improvement", + observation="Caller-declared training goal", + statement=goal.description, + predictions=[ + Prediction( + id="P_goal", + claim="Held-out goal progress", + metric=goal.primary_metric, + comparator=comparison, + threshold=goal.min_delta, + ), + Prediction( + id="P_coherence", + claim="Declared coherence floor", + metric="phi_mean", + comparator=PredictionComparator.GE, + threshold=goal.coherence_phi_floor, + ), + *[ + Prediction( + id=f"P_policy_{policy.id}", + claim="Declared policy check", + metric=policy.id, + comparator=PredictionComparator.GE, + threshold=policy.threshold, + ) + for policy in goal.policies + ], + ], + ) + preparation = TrainingPreparation( + plan_id="", + project_root=str(root), + config=config.model_dump(mode="json"), + goal=goal, + hypothesis=hypothesis, + sources=sources, + train_samples=len(train), + eval_samples=min(len(heldout), goal.max_eval_samples), + eval_sample_ids=[sample.id for sample in heldout[: goal.max_eval_samples]], + issues=issues, + variants=discover_variants(root), + capabilities=capabilities, + effects=[ + "Train the selected local model", + "Write owner-readable model and evaluation artifacts", + ], + ) + preparation = preparation.model_copy(update={"plan_id": preparation_identity(preparation)}) + if persist: + (manager or default_manager()).save_preparation(preparation) + return preparation + + +def load_preparation(plan_id: str, manager: ExperimentManager | None = None) -> TrainingPreparation: + """Load a matching owner-recorded preparation through ExperimentManager.""" + if not _PLAN_ID.fullmatch(plan_id): + raise ValidationError("Invalid prepared experiment ID", code="carl.preparation.id") + preparation = (manager or default_manager()).load_preparation(plan_id) + if preparation_identity(preparation) != preparation.plan_id: + raise ValidationError("Prepared inputs changed", code="carl.preparation.stale") + return preparation + + +def validate_preparation( + preparation: TrainingPreparation, config: TrainingConfig | None = None +) -> None: + """Refuse stale or incomplete inputs before constructing the trainer.""" + if not preparation.ready or preparation_identity(preparation) != preparation.plan_id: + raise ValidationError("The experiment is not ready", code="carl.preparation.not_ready") + bound = TrainingConfig.model_validate(preparation.config) + root = Path(preparation.project_root) + if {str(path) for path in model_sources(bound, root)} != { + source.path for source in preparation.sources if source.kind == "model" + }: + raise ValidationError("The model source manifest changed", code="carl.preparation.stale") + if bound.resume_from_checkpoint: + resume = Path(bound.resume_from_checkpoint) + if {str(path.absolute()) for path in resume.rglob("*") if path.is_file()} != { + source.path for source in preparation.sources if source.kind == "resume" + }: + raise ValidationError( + "The resume source manifest changed", code="carl.preparation.stale" + ) + expected_callables = {str(path) for path in _implementation_sources()} + for reference in [ + preparation.goal.evaluator, + *[binding.reference for binding in preparation.goal.rewards], + *[policy.reference for policy in preparation.goal.policies], + ]: + expected_callables.update(str(path) for path in callable_sources(reference, root)) + if expected_callables != { + source.path for source in preparation.sources if source.kind == "callable" + }: + raise ValidationError("The callable source manifest changed", code="carl.preparation.stale") + for source in preparation.sources: + path = Path(source.path) + if not path.is_file() or file_hash(path) != source.sha256: + raise ValidationError("A prepared source changed", code="carl.preparation.stale") + if config is not None: + expected = TrainingConfig.model_validate(preparation.config) + candidate = config.model_copy( + deep=True, update={"push_to_hub": False, "goal": config.goal or preparation.goal} + ) + for field in ( + "base_model", + "dataset_repo", + "eval_dataset_repo", + "tokenizer_source", + "sft_adapter", + "resume_from_checkpoint", + ): + value = getattr(candidate, field) + if value: + path = Path(value) + resolved = path if path.is_absolute() else Path(preparation.project_root) / path + if resolved.exists(): + setattr(candidate, field, str(resolved.resolve())) + candidate.starting_adapters = [ + str((Path(value) if Path(value).is_absolute() else root / value).resolve()) + for value in candidate.starting_adapters + ] + if candidate.output_dir is not None: + output = candidate.output_dir + candidate.output_dir = (output if output.is_absolute() else root / output).resolve() + if candidate.model_dump(mode="json") != expected.model_dump(mode="json"): + raise ValidationError( + "Configuration does not match the preparation", code="carl.preparation.mismatch" + ) + + +def render_preparation(preparation: TrainingPreparation) -> str: + """Show the next useful decision; leave training internals in details.""" + lines = [ + "Ready to review a bounded training run" + if preparation.ready + else "Preparation needs input", + f"Experiment: {preparation.plan_id}", + f"Goal: {preparation.goal.description}", + f"Data: {preparation.train_samples} training, {preparation.eval_samples} held-out tasks", + ] + config = TrainingConfig.model_validate(preparation.config) + stages = 2 if config.pipeline and config.method == TrainingMethod.GRPO else 1 + steps = config.max_steps * stages + lines.append(f"Budget: at most {steps} training steps across {stages} stage(s)") + for issue in preparation.issues: + lines.append(f"{issue.message}. {issue.action}.") + if preparation.ready: + lines.extend( + [ + "Execution requires approval of the selected inputs and resource limits.", + "Acceptance requires goal, policy and coherence checks; publication is separate.", + ] + ) + return "\n".join(lines) diff --git a/src/carl_studio/training/trainer.py b/src/carl_studio/training/trainer.py index 8533df5..2f94894 100644 --- a/src/carl_studio/training/trainer.py +++ b/src/carl_studio/training/trainer.py @@ -15,6 +15,8 @@ from __future__ import annotations import logging +import asyncio +import math import re import uuid from pathlib import Path @@ -139,10 +141,13 @@ def __init__( resume_from_checkpoint: bool | str | None = None, interaction_chain: InteractionChain | None = None, training_step_interval: int = 100, + task_reward_funcs: list[Any] | None = None, + task_reward_weights: list[float] | None = None, + task_reward_stages: list[str] | None = None, ) -> None: self.config = config self.skip_credits = bool(skip_credits) - self.resume_from_checkpoint = resume_from_checkpoint + self.resume_from_checkpoint = resume_from_checkpoint or config.resume_from_checkpoint self.run = TrainingRun( id=uuid.uuid4().hex[:12], config=config, @@ -152,15 +157,34 @@ def __init__( self._tokenizer: Any = None self._cascade_manager: Any = None self._reward_fns: list[Any] = [] + from threading import Event + + self._cancel_requested = Event() + if task_reward_funcs is not None: + from carl_studio.training.preparation import checked_reward + + if not task_reward_funcs: + raise ValueError("Task rewards must be nonempty") + self._task_reward_funcs = [checked_reward(function) for function in task_reward_funcs] + else: + self._task_reward_funcs = None + self._task_reward_weights = task_reward_weights or [1.0] * len(task_reward_funcs or []) + self._task_reward_stages = task_reward_stages or ["A"] * len(task_reward_funcs or []) + if len(self._task_reward_weights) != len(task_reward_funcs or []) or len( + self._task_reward_stages + ) != len(task_reward_funcs or []): + raise ValueError("Task reward bindings must align") + if any(weight <= 0 or not math.isfinite(weight) for weight in self._task_reward_weights): + raise ValueError("Task reward weights must be finite and positive") + if any(stage not in {"A", "B"} for stage in self._task_reward_stages): + raise ValueError("Task reward stage must be A or B") # --- InteractionChain plumbing ------------------------------------- # When a chain is provided, the trainer emits TRAINING_STEP (every # ``training_step_interval`` global steps), CHECKPOINT (on save) and # a bracketing ``training.start``/``training.complete`` step pair. self.chain: InteractionChain | None = interaction_chain if training_step_interval < 1: - raise ValueError( - f"training_step_interval must be >= 1, got {training_step_interval}" - ) + raise ValueError(f"training_step_interval must be >= 1, got {training_step_interval}") self._step_interval = int(training_step_interval) def _record( @@ -206,10 +230,27 @@ async def train(self) -> TrainingRun: return await self._train_remote() else: return await self._train_local() + except asyncio.CancelledError: + self._cancel_requested.set() + self.run.phase = RunPhase.PAUSED + if self.is_remote: + from carl_core.errors import CARLError + + raise CARLError( + "Remote submission requires reconciliation", code="carl.training.reconcile" + ) from None + raise except Exception as exc: self.run.phase = RunPhase.FAILED - self.run.error_message = f"{type(exc).__name__}: {exc}" - logger.error("Training failed: %s", exc, exc_info=True) + self.run.error_message = ( + f"{type(exc).__name__}: training failed" + if self.config.goal is not None + else f"{type(exc).__name__}: {exc}" + ) + if self.config.goal is not None: + logger.error("Training failed: %s", type(exc).__name__) + else: + logger.error("Training failed: %s", exc, exc_info=True) return self.run async def watch( @@ -771,6 +812,15 @@ def _load_model_and_tokenizer(self) -> None: ) logger.info("Model loaded via AutoModelForCausalLM (Unsloth not available)") + for adapter in cfg.starting_adapters: + from peft import PeftModel + + self._model = PeftModel.from_pretrained(self._model, adapter).merge_and_unload() + if cfg.sft_adapter: + from peft import PeftModel + + self._model = PeftModel.from_pretrained(self._model, cfg.sft_adapter).merge_and_unload() + # Suppress Qwen3.5 thinking mode if requested — model generates # ... prefix by default, burning completion budget. if cfg.disable_thinking and hasattr(self._model, "generation_config"): @@ -839,6 +889,7 @@ def _save_carl_checkpoint(trainer: Any, output_dir: str, exc: BaseException) -> raised — masking the caller's original exception would be worse than losing the crash-state snapshot. """ + def _safe_capture(label: str, fn: Any) -> Any: """Call fn() and return its result; log and return None on any failure.""" try: @@ -913,7 +964,6 @@ def _numpy_rng() -> Any: async def _run_sft(self) -> None: """Run SFT training with TRL SFTTrainer.""" try: - from datasets import load_dataset from peft import LoraConfig as PeftLoraConfig from trl import SFTConfig, SFTTrainer except ImportError as exc: @@ -922,11 +972,13 @@ async def _run_sft(self) -> None: cfg = self.config hf_token = self._get_hf_token() - dataset = load_dataset(cfg.dataset_repo, split=cfg.dataset_split, token=hf_token) + dataset = self._load_training_dataset(cfg.dataset_repo, cfg.dataset_split, hf_token) eval_dataset = None if cfg.eval_dataset_repo: - eval_dataset = load_dataset(cfg.eval_dataset_repo, split=cfg.eval_split, token=hf_token) + eval_dataset = self._load_training_dataset( + cfg.eval_dataset_repo, cfg.eval_split, hf_token + ) peft_config = PeftLoraConfig( r=cfg.lora.r, @@ -937,7 +989,7 @@ async def _run_sft(self) -> None: task_type="CAUSAL_LM", ) - output_dir = f"carl-sft-{self.run.id}" + output_dir = str(cfg.output_dir or (Path.home() / ".carl" / "runs" / self.run.id / "model")) self._announce_existing_checkpoint(output_dir) training_args = self._build_sft_training_args( @@ -955,11 +1007,8 @@ async def _run_sft(self) -> None: resume = self._resolve_resume_arg(output_dir) try: - if resume is None: - trainer.train() - else: - trainer.train(resume_from_checkpoint=resume) - except (Exception, KeyboardInterrupt) as exc: + await self._fit(trainer, resume) + except (Exception, KeyboardInterrupt, asyncio.CancelledError) as exc: self._save_carl_checkpoint(trainer, output_dir, exc) self._record( ActionType.CHECKPOINT, @@ -970,6 +1019,7 @@ async def _run_sft(self) -> None: ) raise + self._retain_checkpoint(trainer, output_dir) self._record( ActionType.CHECKPOINT, "sft.save", @@ -998,7 +1048,6 @@ async def _run_grpo(self) -> None: pass # Unsloth not installed; standard TRL path try: - from datasets import load_dataset from peft import LoraConfig as PeftLoraConfig from trl import GRPOConfig, GRPOTrainer except ImportError as exc: @@ -1007,11 +1056,13 @@ async def _run_grpo(self) -> None: cfg = self.config hf_token = self._get_hf_token() - dataset = load_dataset(cfg.dataset_repo, split=cfg.dataset_split, token=hf_token) + dataset = self._load_training_dataset(cfg.dataset_repo, cfg.dataset_split, hf_token) eval_dataset = None if cfg.eval_dataset_repo: - eval_dataset = load_dataset(cfg.eval_dataset_repo, split=cfg.eval_split, token=hf_token) + eval_dataset = self._load_training_dataset( + cfg.eval_dataset_repo, cfg.eval_split, hf_token + ) # Build reward chain with cascade wrapping self._reward_fns = self._build_rewards(self._model, self._tokenizer) @@ -1028,7 +1079,7 @@ async def _run_grpo(self) -> None: task_type="CAUSAL_LM", ) - output_dir = f"carl-grpo-{self.run.id}" + output_dir = str(cfg.output_dir or (Path.home() / ".carl" / "runs" / self.run.id / "model")) self._announce_existing_checkpoint(output_dir) training_args = self._build_grpo_training_args( @@ -1048,11 +1099,8 @@ async def _run_grpo(self) -> None: resume = self._resolve_resume_arg(output_dir) try: - if resume is None: - trainer.train() - else: - trainer.train(resume_from_checkpoint=resume) - except (Exception, KeyboardInterrupt) as exc: + await self._fit(trainer, resume) + except (Exception, KeyboardInterrupt, asyncio.CancelledError) as exc: self._save_carl_checkpoint(trainer, output_dir, exc) self._record( ActionType.CHECKPOINT, @@ -1063,6 +1111,7 @@ async def _run_grpo(self) -> None: ) raise + self._retain_checkpoint(trainer, output_dir) self._record( ActionType.CHECKPOINT, "grpo.save", @@ -1248,6 +1297,13 @@ def _build_rewards(self, model: Any, tokenizer: Any) -> list[Any]: (task_rewards["conciseness"], {"A", "B"}, 0.5), (carl_fn, {"B"}, 1.5), ] + if self._task_reward_funcs is not None: + reward_specs = [ + (function, {"A", "B"} if stage == "A" else {"B"}, weight) + for function, weight, stage in zip( + self._task_reward_funcs, self._task_reward_weights, self._task_reward_stages + ) + ] + [(carl_fn, {"B"}, 1.5)] wrapped: list[Any] = [] for fn, stages, weight in reward_specs: @@ -1257,6 +1313,86 @@ def _build_rewards(self, model: Any, tokenizer: Any) -> list[Any]: return wrapped + async def _fit(self, trainer: Any, resume: bool | str | None) -> None: + """Keep the event loop responsive and wait for cancellation cleanup.""" + from transformers import TrainerCallback + from carl_studio.training.preparation import run_in_worker + + stop = self._cancel_requested + + class StopCallback(TrainerCallback): + def on_step_end(self, args: Any, state: Any, control: Any, **kwargs: Any) -> Any: + if stop.is_set(): + control.should_training_stop = True + control.should_save = True + return control + + trainer.add_callback(StopCallback()) + + def fit() -> None: + if resume is None: + trainer.train() + else: + trainer.train(resume_from_checkpoint=resume) + + await run_in_worker(fit, on_cancel=stop.set) + if stop.is_set(): + raise asyncio.CancelledError() + + def _load_training_dataset(self, source: str, split: str, token: str | None) -> Any: + from datasets import Dataset, load_dataset + + path = Path(source) + if not path.is_file(): + return load_dataset(source, split=split, token=token) + from carl_studio.training.preparation import normalize_samples, read_samples + + samples = normalize_samples(read_samples(path), str(path.resolve())) + rows = [] + for sample in samples: + row = dict(sample.metadata) + row.update( + { + "prompt": sample.prompt, + "verification": sample.verification.model_dump(), + "id": sample.id, + } + ) + if sample.golden_solution is not None: + row["completion"] = [{"role": "assistant", "content": sample.golden_solution}] + rows.append(row) + return Dataset.from_list(rows) + + def _retain_checkpoint(self, trainer: Any, output_dir: str) -> None: + """Save the actual candidate before evaluation or publication.""" + from carl_core.hashing import content_hash + + from carl_studio.experiment.types import Artifact + from carl_studio.training.preparation import file_hash + + trainer.save_model(output_dir) + self._tokenizer.save_pretrained(output_dir) + root = Path(output_dir).resolve() + manifest = { + str(path.relative_to(root)): file_hash(path) + for path in sorted(root.rglob("*")) + if path.is_file() + } + if not manifest: + raise ValueError("Training did not produce a checkpoint") + self.run.checkpoint = str(root) + self.run.current_step = int(getattr(trainer.state, "global_step", 0)) + self.run.resource_usage["training_steps"] = float(self.run.current_step) + self.run.artifacts.append( + Artifact( + name="checkpoint", + path=str(root), + artifact_type="checkpoint", + produced_at=self.run.id, + checksum=content_hash(manifest), + ) + ) + def _load_task_rewards(self) -> dict[str, Any]: """Load task reward functions from carl_studio.training.rewards. diff --git a/src/carl_studio/types/config.py b/src/carl_studio/types/config.py index dba8e29..5664e92 100644 --- a/src/carl_studio/types/config.py +++ b/src/carl_studio/types/config.py @@ -10,7 +10,9 @@ from pathlib import Path from typing import Any, Dict, List, Literal, Optional -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from carl_studio.types.preparation import TrainingGoal class ComputeTarget(str, Enum): @@ -196,8 +198,18 @@ class ObservationConfig(BaseModel): class TrainingConfig(BaseModel): """Full training configuration for a carl-studio run.""" + model_config = ConfigDict(hide_input_in_errors=True) + # Identity run_name: str = Field(description="Human-readable run name") + adapter: str = Field(default="trl", description="Registered training adapter") + goal: TrainingGoal | None = None + output_dir: Path | None = None + base_model_revision: str | None = None + sft_adapter: str | None = None + starting_adapters: list[str] = Field(default_factory=list) + pipeline: bool = False + resume_from_checkpoint: str | None = None base_model: str = Field(description="HuggingFace model ID (e.g. 'your-org/your-model')") output_repo: str = Field( description="HuggingFace repo to push results (e.g. 'your-org/your-model-phase1')" diff --git a/src/carl_studio/types/preparation.py b/src/carl_studio/types/preparation.py new file mode 100644 index 0000000..17c9029 --- /dev/null +++ b/src/carl_studio/types/preparation.py @@ -0,0 +1,136 @@ +"""Prepared experiment inputs and independent model acceptance.""" + +from __future__ import annotations + +import math +from typing import Literal + +from carl_core.constants import SIGMA +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from carl_studio.experiment.types import Artifact, Hypothesis + + +class RewardBinding(BaseModel): + """A trusted reward callable and its explicit training weight.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + reference: str = "verification" + weight: float = Field(default=1.0, gt=0.0, allow_inf_nan=False) + stage: Literal["A", "B"] = "A" + + +class PolicyCheck(BaseModel): + """A measurable output policy, distinct from execution permissions.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + id: str = Field(min_length=1) + reference: str = Field(min_length=1) + threshold: float = Field(default=1.0, ge=0.0, le=1.0, allow_inf_nan=False) + + +class TrainingGoal(BaseModel): + """Task progress, policy checks and full-logit coherence acceptance.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + description: str = Field(default="Improve held-out task success", min_length=1) + primary_metric: str = "task_success_rate" + evaluator: str = "verification" + direction: Literal["higher", "lower"] = "higher" + threshold: float = Field(default=0.5, allow_inf_nan=False) + min_delta: float = Field(default=0.0, ge=0.0, allow_inf_nan=False) + rewards: list[RewardBinding] = Field(default_factory=lambda: [RewardBinding()]) + policies: list[PolicyCheck] = Field(default_factory=lambda: list[PolicyCheck]()) + coherence_phi_floor: float = Field(default=SIGMA, ge=0.0, le=1.0) + discontinuity_min: float = Field(default=0.3, ge=0.0, le=1.0) + discontinuity_max: float = Field(default=0.7, ge=0.0, le=1.0) + max_eval_samples: int = Field(default=100, ge=1) + + @model_validator(mode="after") + def validate_goal(self) -> TrainingGoal: + if not self.rewards: + raise ValueError("At least one task reward is required") + if self.discontinuity_min > self.discontinuity_max: + raise ValueError("Invalid coherence band") + if len({p.id for p in self.policies}) != len(self.policies): + raise ValueError("Policy IDs must be unique") + return self + + +class SourceBinding(BaseModel): + """Exact local source bytes used by a prepared experiment.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + kind: Literal["config", "model", "resume", "train_data", "eval_data", "callable"] + path: str + sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class ReadinessIssue(BaseModel): + """A concrete preparation requirement and its recovery action.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + code: str + message: str + action: str + + +class TrainingPreparation(BaseModel): + """Owner-readable prepared inputs, without starting a training job.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + schema_version: Literal[1] = 1 + plan_id: str + project_root: str + config: dict[str, object] + goal: TrainingGoal + hypothesis: Hypothesis + sources: list[SourceBinding] = Field(default_factory=lambda: list[SourceBinding]()) + artifacts: list[Artifact] = Field(default_factory=lambda: list[Artifact]()) + train_samples: int = 0 + eval_samples: int = 0 + eval_sample_ids: list[str] = Field(default_factory=list) + issues: list[ReadinessIssue] = Field(default_factory=lambda: list[ReadinessIssue]()) + variants: dict[str, str] = Field(default_factory=dict) + capabilities: dict[str, object] = Field(default_factory=dict) + effects: list[str] = Field(default_factory=list) + + @property + def ready(self) -> bool: + return not self.issues and self.train_samples > 0 and self.eval_samples > 0 + + +class EvaluationMeasurement(BaseModel): + """Comparable measurements over a bound held-out population.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + checkpoint: str + dataset_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + sample_ids: list[str] + primary_metric: str + primary_value: float = Field(allow_inf_nan=False) + metrics: dict[str, float] = Field(default_factory=dict) + coherence: dict[str, float] | None = None + coherence_source: Literal["full_logits", "unavailable"] = "unavailable" + policy_results: dict[str, float] = Field(default_factory=dict) + generation: dict[str, object] = Field(default_factory=dict) + + @field_validator("metrics", "coherence", "policy_results") + @classmethod + def finite_values(cls, values: dict[str, float] | None) -> dict[str, float] | None: + if values is not None and any(not math.isfinite(v) for v in values.values()): + raise ValueError("Measurements must be finite") + return values + + +class TrainingAcceptance(BaseModel): + """Candidate acceptance does not imply publication or execution success.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + status: Literal["accepted", "rejected", "inconclusive"] + reasons: list[str] + baseline: EvaluationMeasurement + candidate: EvaluationMeasurement + goal_delta: float + policy_passed: bool + coherence_passed: bool diff --git a/src/carl_studio/types/run.py b/src/carl_studio/types/run.py index 587972b..c185d7f 100644 --- a/src/carl_studio/types/run.py +++ b/src/carl_studio/types/run.py @@ -9,7 +9,9 @@ from pydantic import BaseModel, Field +from carl_studio.experiment.types import Artifact from carl_studio.types.config import TrainingConfig +from carl_studio.types.preparation import TrainingAcceptance class RunPhase(str, Enum): @@ -46,6 +48,14 @@ class TrainingRun(BaseModel): coherence_health: CoherenceHealth = CoherenceHealth.HEALTHY loss: float = 0.0 reward_mean: float = 0.0 - error_message: Optional[str] = Field(default=None, description="Error details if phase == FAILED") + error_message: Optional[str] = Field( + default=None, description="Error details if phase == FAILED" + ) hub_job_id: Optional[str] = Field(default=None, description="HuggingFace Jobs job ID") - checkpoint_steps: List[int] = Field(default_factory=list, description="Steps at which checkpoints were saved") + checkpoint_steps: List[int] = Field( + default_factory=list, description="Steps at which checkpoints were saved" + ) + checkpoint: str | None = None + artifacts: list[Artifact] = Field(default_factory=list) + acceptance: TrainingAcceptance | None = None + resource_usage: dict[str, float] = Field(default_factory=dict) diff --git a/tests/test_mcp_tasks.py b/tests/test_mcp_tasks.py index 3186a4f..fc332cb 100644 --- a/tests/test_mcp_tasks.py +++ b/tests/test_mcp_tasks.py @@ -368,6 +368,38 @@ async def test_tasks_cancel_terminal_reports_already_terminal(self) -> None: assert payload["cancelled"] is False assert payload["reason"] == "already_terminal" + async def test_tasks_cancel_waits_for_owned_worker_cleanup(self) -> None: + started = asyncio.Event() + ended = asyncio.Event() + + @async_task("owned", store=self.store) + async def body() -> None: + started.set() + try: + await asyncio.Event().wait() + finally: + await asyncio.sleep(0.03) + ended.set() + + handle = await body() + await asyncio.wait_for(started.wait(), timeout=2) + cancel_tool = build_tasks_cancel_tool(lambda: self.store) + cancellation = asyncio.create_task(cancel_tool(handle["task_id"])) + await asyncio.sleep(0.005) + assert self.store.get(handle["task_id"]).status == "running" + payload = await cancellation + assert ended.is_set() + assert payload["cancelled"] is True + assert self.store.get(handle["task_id"]).status == "cancelled" + + async def test_tasks_cancel_without_worker_custody_keeps_running_status(self) -> None: + task = self.store.create("orphan", {}) + self.store.mark_running(task.task_id) + payload = await build_tasks_cancel_tool(lambda: self.store)(task.task_id) + assert payload["cancelled"] is False + assert payload["reason"] == "execution_not_owned" + assert self.store.get(task.task_id).status == "running" + async def test_tasks_cancel_unknown(self) -> None: cancel_tool = build_tasks_cancel_tool(lambda: self.store) payload = await cancel_tool("nope") diff --git a/tests/test_mcp_v2_wire.py b/tests/test_mcp_v2_wire.py index 594f114..49cb332 100644 --- a/tests/test_mcp_v2_wire.py +++ b/tests/test_mcp_v2_wire.py @@ -11,6 +11,113 @@ from mcp.types import CreateMessageResult, ElicitResult, TextContent +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +def test_training_result_extensions_survive_actual_wire(tmp_path, monkeypatch, mode): + import json + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from carl_studio.harness.runtime import close_runtime + from carl_studio.mcp.server import mcp + from carl_studio.types.config import TrainingConfig + from carl_studio.types.run import RunPhase, TrainingRun + + config = TrainingConfig( + run_name="wire", + base_model="fixture", + dataset_repo="fixture", + output_repo="fixture/output", + method="sft", + ) + run = TrainingRun( + id="wire-run", config=config, phase=RunPhase.COMPLETE, checkpoint="fixture/checkpoint" + ) + monkeypatch.setattr( + "carl_studio.training.pipeline.submit_training", AsyncMock(return_value=run) + ) + monkeypatch.setattr( + "carl_studio.harness.runtime.get_runtime", + lambda *args: SimpleNamespace(context=SimpleNamespace(depth=0, allow_write=True)), + ) + + async def exercise(): + try: + async with Client(mcp, mode=mode) as client: + result = await client.call_tool( + "start_training", {"config_yaml": config.model_dump_json()} + ) + assert not result.is_error + assert result.structured_content["checkpoint"] == "fixture/checkpoint" + assert result.structured_content["acceptance"] is None + malformed = await client.call_tool( + "prepare_training", {"config_yaml": "CANARY_PRIVATE: [unterminated"} + ) + assert malformed.is_error + assert "CANARY_PRIVATE" not in json.dumps(malformed.model_dump()) + finally: + await close_runtime() + + asyncio.run(exercise()) + + +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +def test_preparation_hashing_leaves_mcp_queries_responsive(tmp_path, monkeypatch, mode): + import json + import threading + from types import SimpleNamespace + + from carl_studio.harness.runtime import close_runtime + from carl_studio.mcp.server import mcp + + started = threading.Event() + release = threading.Event() + + def prepare(*args, **kwargs): + started.set() + release.wait(timeout=2) + return SimpleNamespace( + ready=False, + model_dump=lambda **kwargs: {"plan_id": "Eprep_" + "a" * 24, "goal": {}, "issues": []}, + ) + + monkeypatch.setattr("carl_studio.training.preparation.prepare_training", prepare) + + async def exercise(): + try: + async with Client(mcp, mode=mode) as client: + preparation = asyncio.create_task( + client.call_tool( + "prepare_training", + { + "config_yaml": json.dumps( + { + "run_name": "responsive", + "base_model": "fixture", + "dataset_repo": "fixture", + "output_repo": "fixture/output", + "method": "sft", + } + ) + }, + ) + ) + while not started.is_set(): + await asyncio.sleep(0.005) + metrics = await asyncio.wait_for( + client.call_tool("get_coherence_metrics", {"logits_summary": "{}"}), timeout=1 + ) + assert not metrics.is_error + assert not preparation.done() + release.set() + result = await preparation + assert not result.is_error + finally: + release.set() + await close_runtime() + + asyncio.run(exercise()) + + @pytest.mark.parametrize("mode", ["legacy", "auto"]) def test_wire_schema_and_host_sampling(mode: str) -> None: from carl_studio.mcp.server import mcp diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index d85fdc8..f5b3692 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -71,8 +71,9 @@ def test_pipeline_validation_failure_missing_token() -> None: pipeline = SendItPipeline(_make_config(compute_target="l4x1")) # Force no HF token discoverable. - with patch("huggingface_hub.get_token", return_value=None), patch( - "os.environ.get", return_value=None + with ( + patch("huggingface_hub.get_token", return_value=None), + patch("os.environ.get", return_value=None), ): run = asyncio.run(pipeline.run()) @@ -111,8 +112,9 @@ def test_pipeline_emits_validate_events() -> None: ) # Force validation failure so we stop early. - with patch("huggingface_hub.get_token", return_value=None), patch( - "os.environ.get", return_value=None + with ( + patch("huggingface_hub.get_token", return_value=None), + patch("os.environ.get", return_value=None), ): asyncio.run(pipeline.run()) @@ -135,12 +137,11 @@ def test_skip_credits_propagates_to_trainer_single_stage() -> None: ) # Bypass validation + instantiate a mock trainer that records the args. - with patch.object(pipeline, "_validate", return_value=[]), patch( - "carl_studio.training.trainer.CARLTrainer" - ) as mock_trainer_cls: - fake_run = TrainingRun( - id="fake", config=pipeline.config, phase=RunPhase.COMPLETE - ) + with ( + patch.object(pipeline, "_validate", return_value=[]), + patch("carl_studio.training.trainer.CARLTrainer") as mock_trainer_cls, + ): + fake_run = TrainingRun(id="fake", config=pipeline.config, phase=RunPhase.COMPLETE) mock_trainer = MagicMock() mock_trainer.train_and_watch = AsyncMock(return_value=fake_run) mock_trainer.is_remote = True @@ -178,10 +179,11 @@ def fake_trainer_cls(cfg: Any, **kwargs: Any) -> Any: trainer.is_remote = False # Skip the watch() call. return trainer - with patch.object(pipeline, "_validate", return_value=[]), patch( - "carl_studio.training.trainer.CARLTrainer", side_effect=fake_trainer_cls - ), patch.object(pipeline, "_check_gate", return_value=True), patch.object( - pipeline, "_push_model", new=AsyncMock() + with ( + patch.object(pipeline, "_validate", return_value=[]), + patch("carl_studio.training.trainer.CARLTrainer", side_effect=fake_trainer_cls), + patch.object(pipeline, "_check_gate", return_value=True), + patch.object(pipeline, "_push_model", new=AsyncMock()), ): asyncio.run(pipeline.run()) @@ -217,8 +219,9 @@ def fake_trainer_cls(cfg: Any, **_kwargs: Any) -> Any: trainer.is_remote = False return trainer - with patch.object(pipeline, "_validate", return_value=[]), patch( - "carl_studio.training.trainer.CARLTrainer", side_effect=fake_trainer_cls + with ( + patch.object(pipeline, "_validate", return_value=[]), + patch("carl_studio.training.trainer.CARLTrainer", side_effect=fake_trainer_cls), ): run = asyncio.run(pipeline.run()) @@ -240,11 +243,111 @@ def fake_trainer_cls(cfg: Any, **_kwargs: Any) -> Any: trainer.is_remote = False return trainer - with patch.object(pipeline, "_validate", return_value=[]), patch( - "carl_studio.training.trainer.CARLTrainer", side_effect=fake_trainer_cls - ), patch.object(pipeline, "_check_gate", return_value=False): + with ( + patch.object(pipeline, "_validate", return_value=[]), + patch("carl_studio.training.trainer.CARLTrainer", side_effect=fake_trainer_cls), + patch.object(pipeline, "_check_gate", return_value=False), + ): run = asyncio.run(pipeline.run()) assert created == ["sft"], "GRPO should not run when SFT gate fails" assert run.phase == RunPhase.FAILED assert "SFT eval gate failed" in (run.error_message or "") + + +def test_final_gate_failure_prevents_push() -> None: + pipeline = SendItPipeline(_make_config(method="grpo")) + + def trainer(config: TrainingConfig, **_kwargs: Any) -> Any: + result = MagicMock() + result.is_remote = False + result.train = AsyncMock( + return_value=TrainingRun(id="stage", config=config, phase=RunPhase.COMPLETE) + ) + return result + + push = AsyncMock() + with ( + patch.object(pipeline, "_validate", return_value=[]), + patch("carl_studio.training.trainer.CARLTrainer", side_effect=trainer), + patch.object(pipeline, "_check_gate", side_effect=[True, False]), + patch.object(pipeline, "_push_model", push), + ): + run = asyncio.run(pipeline.run()) + assert run.phase == RunPhase.FAILED + push.assert_not_awaited() + + +def test_single_stage_gate_failure_prevents_push() -> None: + pipeline = SendItPipeline(_make_config()) + trainer = MagicMock() + trainer.train_and_watch = AsyncMock( + return_value=TrainingRun(id="stage", config=pipeline.config, phase=RunPhase.COMPLETE) + ) + push = AsyncMock() + with ( + patch.object(pipeline, "_validate", return_value=[]), + patch("carl_studio.training.trainer.CARLTrainer", return_value=trainer), + patch.object(pipeline, "_check_gate", return_value=False) as gate, + patch.object(pipeline, "_push_model", push), + ): + run = asyncio.run(pipeline.run()) + gate.assert_called_once() + assert run.phase == RunPhase.FAILED + push.assert_not_awaited() + + +def test_gate_uses_stage_artifact_and_heldout_data() -> None: + config = _make_config(method="grpo", eval_dataset_repo="heldout/tasks", eval_split="validation") + stage_config = config.model_copy(update={"method": "sft"}) + run = TrainingRun( + id="sft", config=stage_config, phase=RunPhase.COMPLETE, checkpoint="stage/checkpoint" + ) + with ( + patch("carl_studio.eval.runner.EvalRunner") as runner, + patch("carl_studio.eval.runner.EvalGate") as gate, + ): + gate.return_value.check.return_value = True + SendItPipeline(config)._check_gate(run) + observed = runner.call_args.args[0] + assert observed.checkpoint == "stage/checkpoint" + assert observed.dataset == "heldout/tasks" + assert observed.dataset_split == "validation" + assert observed.phase == "auto" + + +def test_stage_gate_keeps_declared_goal_and_starting_adapter() -> None: + from carl_studio.types.preparation import TrainingGoal + + config = _make_config( + method="sft", + sft_adapter="original-A", + tokenizer_source="selected-tokenizer", + max_completion_length=128, + goal=TrainingGoal( + primary_metric="error_rate", + direction="lower", + threshold=0.2, + coherence_phi_floor=0.4, + discontinuity_min=0.4, + discontinuity_max=0.6, + max_eval_samples=12, + ), + ) + run = TrainingRun(id="stage", config=config, phase=RunPhase.COMPLETE, checkpoint="SFT-B") + with ( + patch("carl_studio.eval.runner.EvalRunner") as runner, + patch("carl_studio.eval.runner.EvalGate") as gate, + ): + gate.return_value.check.return_value = True + SendItPipeline(config, evaluator=lambda *_: {})._check_gate(run) + observed = runner.call_args.args[0] + assert observed.sft_adapter == "original-A" + assert observed.tokenizer_source == "selected-tokenizer" + assert observed.threshold == 0.2 + assert observed.metric_direction == "lower" + assert observed.coherence_phi_floor == 0.4 + assert observed.discontinuity_min == 0.4 + assert observed.discontinuity_max == 0.6 + assert observed.max_samples == 12 + assert observed.max_new_tokens == 128 diff --git a/tests/test_prepared_training.py b/tests/test_prepared_training.py new file mode 100644 index 0000000..3e1fae6 --- /dev/null +++ b/tests/test_prepared_training.py @@ -0,0 +1,777 @@ +"""Prepared inputs, real experiment persistence and acceptance counterexamples.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from carl_core.errors import ValidationError + +from carl_studio.eval.runner import EvalRunner +from carl_studio.experiment.manager import ExperimentManager +from carl_studio.training.acceptance import compare_candidate +from carl_studio.training.pipeline import submit_training +from carl_studio.training.preparation import ( + checked_reward, + load_preparation, + prepare_training, + validate_preparation, + verification_evaluator, + verification_reward, +) +from carl_studio.types.config import TrainingConfig +from carl_studio.types.preparation import EvaluationMeasurement, PolicyCheck, TrainingGoal +from carl_studio.types.run import RunPhase, TrainingRun + + +@pytest.fixture +def inputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> tuple[TrainingConfig, ExperimentManager]: + import importlib.util + + original = importlib.util.find_spec + monkeypatch.setattr( + importlib.util, + "find_spec", + lambda name, *args: ( + SimpleNamespace() + if name in {"torch", "transformers", "trl", "datasets", "peft"} + else original(name, *args) + ), + ) + model = tmp_path / "model" + model.mkdir() + (model / "config.json").write_text('{"vocab_size":16}') + (model / "model.safetensors").write_bytes(b"source-fixture") + for name, prefix in (("train", "train"), ("eval", "heldout")): + (tmp_path / f"{name}.jsonl").write_text( + "\n".join( + json.dumps( + { + "id": f"{prefix}{i}", + "prompt": f"{prefix} task {i}", + "expected_output": answer, + } + ) + for i, answer in enumerate(("A", "B")) + ) + ) + config = TrainingConfig( + run_name="fixture", + base_model=str(model), + output_repo="fixture/output", + dataset_repo=str(tmp_path / "train.jsonl"), + eval_dataset_repo=str(tmp_path / "eval.jsonl"), + method="grpo", + compute_target="local", + max_steps=64, + cascade={"carl_start": 0}, + goal=TrainingGoal(), + ) + return config, ExperimentManager(tmp_path / "experiments") + + +def measurement(value: float, **overrides: object) -> EvaluationMeasurement: + return EvaluationMeasurement.model_validate( + { + "checkpoint": "candidate", + "dataset_sha256": "a" * 64, + "sample_ids": ["a", "b"], + "primary_metric": "task_success_rate", + "primary_value": value, + "metrics": {"task_success_rate": value}, + "coherence": {"phi_mean": 0.4, "discontinuity_score": 0.5}, + "coherence_source": "full_logits", + "generation": {"sampling": "greedy", "max_new_tokens": 128}, + **overrides, + } + ) + + +def test_preparation_is_repeatable_and_retains_owner_record(inputs, tmp_path): + config, owner = inputs + first = prepare_training(config, project_root=tmp_path, manager=owner) + second = prepare_training(config, project_root=tmp_path, manager=owner) + assert first.ready and second.plan_id == first.plan_id + assert first.train_samples == first.eval_samples == 2 + assert config.push_to_hub is True + assert first.config["push_to_hub"] is False + loaded = load_preparation(first.plan_id, owner) + validate_preparation(loaded, config) + assert owner.load(first.plan_id).hypothesis.predictions + assert (owner.base_dir / first.plan_id).stat().st_mode & 0o777 == 0o700 + + +@pytest.mark.parametrize( + "mutation,code", + [ + ("missing", "eval_data"), + ("overlap", "split_overlap"), + ("unbounded", "step_budget"), + ("no_grader", "verification"), + ("early_reward", "reward_budget"), + ], +) +def test_readiness_counterexamples(inputs, tmp_path, mutation, code): + config, owner = inputs + if mutation == "missing": + config.eval_dataset_repo = str(tmp_path / "absent.jsonl") + elif mutation == "overlap": + Path(config.eval_dataset_repo).write_bytes(Path(config.dataset_repo).read_bytes()) + elif mutation == "unbounded": + config.max_steps = -1 + elif mutation == "early_reward": + config.max_steps = 1 + config.cascade.carl_start = 50 + else: + Path(config.eval_dataset_repo).write_text('{"id":"new","prompt":"new task"}') + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + assert not prepared.ready + assert code in {issue.code for issue in prepared.issues} + + +def test_tinny_exposes_two_modes(inputs, tmp_path): + config, owner = inputs + folder = tmp_path / "carl" / "configs" + folder.mkdir(parents=True) + for name in ("single-agent", "tcg-lead", "tcg-universal"): + (folder / f"tinny-{name}.yaml").write_text("{}") + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + assert set(prepared.variants) == {"Tinny alone", "Tinny with TCG"} + + +def test_stale_input_refused_before_trainer(inputs, tmp_path, monkeypatch): + config, owner = inputs + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + Path(config.dataset_repo).write_text('{"prompt":"changed"}') + trainer = MagicMock() + monkeypatch.setattr("carl_studio.training.trainer.CARLTrainer", trainer) + with pytest.raises(ValidationError, match="source changed"): + asyncio.run(submit_training(config, prepared_plan_id=prepared.plan_id, manager=owner)) + trainer.assert_not_called() + + +def test_changed_config_refused(inputs, tmp_path): + config, owner = inputs + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + with pytest.raises(ValidationError, match="does not match"): + validate_preparation(prepared, config.model_copy(update={"max_steps": 65})) + + +def test_literal_credentials_refused_before_preparation_persistence(inputs, tmp_path): + config, owner = inputs + config.extra_args = {"api_key": "INVENTED_PRIVATE_CANARY"} + with pytest.raises(ValidationError) as error: + prepare_training(config, project_root=tmp_path, manager=owner) + assert "INVENTED_PRIVATE_CANARY" not in str(error.value) + assert not list(owner.base_dir.glob("*/preparation.json")) + config.extra_args = {"max_prompt_tokens": "1024"} + assert prepare_training(config, project_root=tmp_path, manager=owner).ready + + +def test_callable_import_error_is_sanitized_and_recorded(inputs, tmp_path, monkeypatch, caplog): + config, owner = inputs + (tmp_path / "rules.py").write_text( + "raise RuntimeError('INVENTED_IMPORT_CANARY')\n" + "def grade(completions, samples):\n return {'task_success_rate': 1.0}\n" + ) + config.goal = TrainingGoal(evaluator="rules:grade") + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + trainer = MagicMock() + monkeypatch.setattr("carl_studio.training.trainer.CARLTrainer", trainer) + with pytest.raises(ValidationError) as error: + asyncio.run(submit_training(config, prepared_plan_id=prepared.plan_id, manager=owner)) + assert "INVENTED_IMPORT_CANARY" not in str(error.value) + assert "INVENTED_IMPORT_CANARY" not in caplog.text + result = owner.load_training_result(prepared.plan_id) + assert result.phase == RunPhase.FAILED + assert "INVENTED_IMPORT_CANARY" not in result.model_dump_json() + trainer.assert_not_called() + + +def test_prepared_dry_run_does_not_execute_training(inputs, tmp_path, monkeypatch): + from typer.testing import CliRunner + + from carl_studio.cli import app + + config, owner = inputs + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + path = tmp_path / "carl.yaml" + path.write_text(config.model_dump_json()) + submit = AsyncMock( + return_value=TrainingRun(id="fixture", config=config, phase=RunPhase.COMPLETE) + ) + monkeypatch.setattr("carl_studio.training.pipeline.submit_training", submit) + monkeypatch.setattr("carl_studio.training.preparation.default_manager", lambda: owner) + result = CliRunner().invoke( + app, ["train", "--config", str(path), "--prepared-plan", prepared.plan_id, "--dry-run"] + ) + submit.assert_not_awaited() + assert result.exit_code == 0, result.output + assert prepared.plan_id in result.output + + +def test_prepared_send_it_keeps_existing_autonomy_gate(inputs, tmp_path, monkeypatch): + import importlib + + from typer.testing import CliRunner + + from carl_studio.cli import app + + config, owner = inputs + config.pipeline = True + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + path = tmp_path / "carl.yaml" + path.write_text(config.model_dump_json()) + submit = AsyncMock() + monkeypatch.setattr("carl_studio.training.pipeline.submit_training", submit) + tier = importlib.import_module("carl_studio.tier") + monkeypatch.setattr(tier, "check_tier", lambda _: (False, None, None)) + monkeypatch.setattr(tier, "tier_message", lambda _: "Autonomy requires CARL Paid") + result = CliRunner().invoke( + app, ["train", "--config", str(path), "--prepared-plan", prepared.plan_id, "--send-it"] + ) + submit.assert_not_awaited() + assert result.exit_code == 1 + assert "Autonomy requires" in result.output + + +def test_modified_ready_state_cannot_remove_a_hold(inputs, tmp_path): + config, owner = inputs + config.max_steps = -1 + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + with pytest.raises(ValidationError): + validate_preparation(prepared.model_copy(update={"issues": []})) + + +def test_task_reward_requires_actual_verification(): + long_wrong = "An incorrect answer with enough characters to trigger the former length shortcut" + assert verification_reward([long_wrong, "A"], expected_output=["A", "A"]) == [0.0, 1.0] + with pytest.raises(ValidationError): + verification_reward([long_wrong]) + assert ( + verification_evaluator(["wrong", "A"], [{"answer": "A"}, {"answer": "A"}])[ + "task_success_rate" + ] + == 0.5 + ) + + +@pytest.mark.parametrize("values", [[], [float("nan")], [float("inf")], [1.0, 2.0]]) +def test_malformed_reward_rejected(values): + with pytest.raises(ValidationError): + checked_reward(lambda **kwargs: values)(completions=["A"]) + + +@pytest.mark.parametrize("value,status", [(0.9, "accepted"), (0.5, "rejected"), (0.2, "rejected")]) +def test_goal_progress_and_unchanged_control(value, status): + assert compare_candidate(TrainingGoal(), measurement(0.5), measurement(value)).status == status + + +def test_high_coherence_does_not_accept_wrong_task(): + outcome = compare_candidate(TrainingGoal(), measurement(0.1), measurement(0.2)) + assert outcome.coherence_passed + assert outcome.status == "rejected" + + +def test_missing_coherence_and_changed_populations_are_inconclusive(): + base = measurement(0.5) + assert ( + compare_candidate( + TrainingGoal(), base, measurement(0.9, coherence=None, coherence_source="unavailable") + ).status + == "inconclusive" + ) + assert ( + compare_candidate(TrainingGoal(), base, measurement(0.9, sample_ids=["other"])).status + == "inconclusive" + ) + assert ( + compare_candidate(TrainingGoal(), base, measurement(0.9, dataset_sha256="b" * 64)).status + == "inconclusive" + ) + + +def test_policy_results_are_independent(): + goal = TrainingGoal(policies=[PolicyCheck(id="privacy", reference="rules:privacy")]) + assert compare_candidate(goal, measurement(0.5), measurement(0.9)).status == "inconclusive" + assert ( + compare_candidate( + goal, measurement(0.5), measurement(0.9, policy_results={"privacy": 0.0}) + ).status + == "rejected" + ) + assert ( + compare_candidate( + goal, measurement(0.5), measurement(0.9, policy_results={"privacy": 1.0}) + ).status + == "accepted" + ) + + +def test_real_owner_loop_and_replay_do_not_repeat_training(inputs, tmp_path, monkeypatch): + config, owner = inputs + tokenizer = tmp_path / "tokenizer" + tokenizer.mkdir() + (tokenizer / "tokenizer_config.json").write_text("{}") + config.tokenizer_source = str(tokenizer) + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + candidate_dir = tmp_path / "candidate" + candidate_dir.mkdir() + trainer = MagicMock() + trainer.run = TrainingRun( + id="executed-fixture", + config=TrainingConfig.model_validate(prepared.config), + phase=RunPhase.COMPLETE, + checkpoint=str(candidate_dir), + ) + trainer.train = AsyncMock(return_value=trainer.run) + factory = MagicMock(return_value=trainer) + monkeypatch.setattr("carl_studio.training.trainer.CARLTrainer", factory) + selected_tokenizers = [] + + def load_model(runner): + selected_tokenizers.append(runner.config.tokenizer_source) + return object(), object() + + monkeypatch.setattr(EvalRunner, "_load_model_simple", load_model) + monkeypatch.setattr( + EvalRunner, + "_generate_single_turn", + lambda self, model, tokenizer, samples: ( + ["wrong"] * len(samples) + if self.config.checkpoint == config.base_model + else [sample["expected_output"] for sample in samples] + ), + ) + monkeypatch.setattr( + EvalRunner, + "_compute_coherence", + lambda *args: {"phi_mean": 0.4, "discontinuity_score": 0.5}, + ) + run = asyncio.run(submit_training(config, prepared_plan_id=prepared.plan_id, manager=owner)) + assert run.acceptance.status == "accepted" + assert run.acceptance.baseline.primary_value == 0.0 + assert run.acceptance.candidate.primary_value == 1.0 + assert owner.load(prepared.plan_id).judgment.verdict.value == "realized" + repeated = asyncio.run( + submit_training(config, prepared_plan_id=prepared.plan_id, manager=owner) + ) + assert repeated.id == run.id + assert trainer.train.await_count == 1 + assert selected_tokenizers == [str(tokenizer), str(tokenizer)] + reward = factory.call_args.kwargs["task_reward_funcs"][0] + assert reward(["A", "wrong"], expected_output=["A", "A"]) == [1.0, 0.0] + + +def test_evaluator_missing_primary_and_nonfinite_metrics_fail(): + from carl_studio.eval.runner import EvalConfig + + runner = EvalRunner( + EvalConfig(checkpoint="fixture"), evaluator=lambda *_: {"score": float("nan")} + ) + with pytest.raises(ValueError, match="invalid measurements"): + runner._compute_metrics(["A"], [{"answer": "A"}]) + + +def test_added_model_file_invalidates_preparation(inputs, tmp_path): + config, owner = inputs + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + (Path(config.base_model) / "tokenizer_config.json").write_text("{}") + with pytest.raises(ValidationError, match="manifest changed"): + validate_preparation(prepared) + + +def test_chat_template_is_bound(inputs, tmp_path): + config, owner = inputs + template = Path(config.base_model) / "chat_template.jinja" + template.write_text("original") + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + template.write_text("changed") + with pytest.raises(ValidationError, match="source changed"): + validate_preparation(prepared) + + +def test_relative_adapter_and_resume_inputs_match(inputs, tmp_path): + config, owner = inputs + adapter = tmp_path / "adapter" + adapter.mkdir() + (adapter / "adapter_config.json").write_text("{}") + (adapter / "adapter_model.safetensors").write_bytes(b"fixture") + resume = tmp_path / "checkpoint" + resume.mkdir() + (resume / "trainer_state.json").write_text("{}") + config.sft_adapter = "adapter" + config.resume_from_checkpoint = "checkpoint" + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + validate_preparation(prepared, config) + + +def test_relative_output_uses_selected_project_and_protects_starting_adapter(inputs, tmp_path): + config, owner = inputs + adapter = tmp_path / "adapter" + adapter.mkdir() + (adapter / "adapter_config.json").write_text("{}") + config.sft_adapter = "adapter" + config.output_dir = Path("candidate") + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + assert prepared.config["output_dir"] == str(tmp_path / "candidate") + validate_preparation(prepared, config) + config.output_dir = Path("adapter") + assert "output_collision" in { + issue.code + for issue in prepare_training(config, project_root=tmp_path, manager=owner).issues + } + + +def test_readiness_declares_metric_scale_and_total_pipeline_budget(inputs, tmp_path): + from carl_studio.training.preparation import render_preparation + + config, owner = inputs + config.pipeline = True + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + assert "128 training steps across 2" in render_preparation(prepared) + config.goal = config.goal.model_copy(update={"threshold": 3.0}) + assert "metric_scale" in { + issue.code + for issue in prepare_training(config, project_root=tmp_path, manager=owner).issues + } + + +def test_unbound_resume_is_refused(inputs, tmp_path, monkeypatch): + config, owner = inputs + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + trainer = MagicMock() + monkeypatch.setattr("carl_studio.training.trainer.CARLTrainer", trainer) + with pytest.raises(ValidationError, match="Resume checkpoint"): + asyncio.run( + submit_training( + config, + prepared_plan_id=prepared.plan_id, + manager=owner, + resume_from_checkpoint=str(tmp_path / "other"), + ) + ) + trainer.assert_not_called() + + +def test_added_resume_file_invalidates_preparation(inputs, tmp_path): + config, owner = inputs + resume = tmp_path / "checkpoint" + resume.mkdir() + (resume / "trainer_state.json").write_text("{}") + config.resume_from_checkpoint = str(resume) + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + (resume / "optimizer.pt").write_bytes(b"changed state") + with pytest.raises(ValidationError, match="manifest changed"): + validate_preparation(prepared) + + +def test_evaluation_preserves_ordered_adapter_lineage(monkeypatch): + import peft + import transformers + + from carl_studio.eval.runner import EvalConfig + + events = [] + tokenizers = [] + model = SimpleNamespace(device=SimpleNamespace(type="cpu"), eval=lambda: None) + + def load_base(path, **kwargs): + events.append(path) + return model + + def load_adapter(current, path, **kwargs): + assert current is model + events.append(path) + return SimpleNamespace(merge_and_unload=lambda: model) + + def load_tokenizer(path): + tokenizers.append(path) + return SimpleNamespace(pad_token="pad") + + monkeypatch.setattr( + transformers, "AutoModelForCausalLM", SimpleNamespace(from_pretrained=load_base) + ) + monkeypatch.setattr( + transformers, + "AutoTokenizer", + SimpleNamespace(from_pretrained=load_tokenizer), + ) + monkeypatch.setattr(peft, "PeftModel", SimpleNamespace(from_pretrained=load_adapter)) + runner = EvalRunner( + EvalConfig( + checkpoint="GRPO-C", + base_model="base", + starting_adapters=["original-A"], + sft_adapter="SFT-B", + tokenizer_source="selected-tokenizer", + phase="1", + device="cpu", + ) + ) + runner._load_model_simple() + assert events == ["base", "original-A", "SFT-B", "GRPO-C"] + assert tokenizers == ["selected-tokenizer"] + + +@pytest.mark.asyncio +async def test_pipeline_cancellation_retains_actual_stage(inputs, tmp_path, monkeypatch): + config, owner = inputs + config.pipeline = True + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + started = asyncio.Event() + + class Trainer: + def __init__(self, stage, **kwargs): + self.run = TrainingRun( + id="actual-SFT", config=stage, current_step=7, checkpoint="checkpoint-7" + ) + self.is_remote = False + + async def train(self): + started.set() + await asyncio.Event().wait() + + monkeypatch.setattr("carl_studio.training.trainer.CARLTrainer", Trainer) + monkeypatch.setattr( + EvalRunner, + "run", + lambda *_: SimpleNamespace( + n_samples=2, + primary_metric="task_success_rate", + primary_value=0.0, + metrics={"task_success_rate": 0.0}, + coherence={"phi_mean": 0.4, "discontinuity_score": 0.5}, + ), + ) + task = asyncio.create_task( + submit_training(config, prepared_plan_id=prepared.plan_id, manager=owner) + ) + await asyncio.wait_for(started.wait(), timeout=2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + result = owner.load_training_result(prepared.plan_id) + assert result.id == "actual-SFT" + assert result.config.method.value == "sft" + assert result.current_step == 7 + assert result.checkpoint == "checkpoint-7" + assert result.phase == RunPhase.PAUSED + + +@pytest.mark.asyncio +async def test_stage_evaluation_keeps_event_loop_and_cancellation_responsive(inputs, monkeypatch): + import threading + + from carl_studio.training.pipeline import SendItPipeline + + config, _ = inputs + pipeline = SendItPipeline(config) + started = threading.Event() + release = threading.Event() + ended = threading.Event() + + def gate(_): + started.set() + release.wait(timeout=2) + ended.set() + return True + + monkeypatch.setattr(pipeline, "_check_gate", gate) + task = asyncio.create_task(pipeline._evaluate_gate(TrainingRun(id="stage", config=config))) + while not started.is_set(): + await asyncio.sleep(0.005) + task.cancel() + await asyncio.sleep(0.01) + assert not task.done() + release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert ended.is_set() + + +@pytest.mark.parametrize("value,expected", [(0.2, True), (0.8, False)]) +def test_lower_goal_gate_matches_acceptance(value, expected): + from carl_studio.eval.runner import EvalConfig, EvalGate, EvalReport + + config = EvalConfig(checkpoint="candidate", metric_direction="lower", threshold=0.3) + report = EvalReport( + checkpoint="candidate", + phase="1", + n_samples=2, + metrics={"error_rate": value}, + primary_metric="error_rate", + primary_value=value, + threshold=0.3, + passed=False, + coherence={"phi_mean": 0.4, "discontinuity_score": 0.5}, + ) + assert EvalGate(threshold=config.threshold, config=config).check(report) is expected + goal = TrainingGoal(primary_metric="error_rate", direction="lower", threshold=0.3) + baseline = measurement(0.5, primary_metric="error_rate") + candidate = measurement(value, primary_metric="error_rate") + assert (compare_candidate(goal, baseline, candidate).status == "accepted") is expected + + +def test_local_helper_and_package_membership_are_bound(inputs, tmp_path): + config, owner = inputs + (tmp_path / "rules.py").write_text( + "from helpers import score\ndef grade(completions, samples):\n return score(completions, samples)\n" + ) + (tmp_path / "helpers.py").write_text( + "def score(completions, samples):\n return {'task_success_rate': 0.0}\n" + ) + config.goal = TrainingGoal(evaluator="rules:grade") + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + assert prepared.ready + (tmp_path / "helpers.py").write_text( + "def score(completions, samples):\n return {'task_success_rate': 1.0}\n" + ) + with pytest.raises(ValidationError, match="source changed"): + validate_preparation(prepared) + + +def test_project_imports_work_outside_project_and_support_dataclasses(tmp_path, monkeypatch): + from carl_studio.training.preparation import resolve_callable + + package = tmp_path / "pkg" + package.mkdir() + (package / "__init__.py").write_text("") + (package / "helpers.py").write_text("VALUE = 0.75\n") + (package / "rules.py").write_text( + "from dataclasses import dataclass\nfrom .helpers import VALUE\n@dataclass\nclass Result:\n value: float = VALUE\ndef grade(completions, samples):\n return {'task_success_rate': Result().value}\n" + ) + monkeypatch.chdir(tmp_path.parent) + function = resolve_callable("pkg.rules:grade", tmp_path) + assert function(["A"], [{}]) == {"task_success_rate": 0.75} + + +def test_partial_preparation_creation_recovers(inputs, tmp_path, monkeypatch): + config, owner = inputs + original = owner._save + monkeypatch.setattr(owner, "_save", MagicMock(side_effect=RuntimeError("interrupted"))) + with pytest.raises(RuntimeError): + prepare_training(config, project_root=tmp_path, manager=owner) + monkeypatch.setattr(owner, "_save", original) + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + assert owner.load(prepared.plan_id).id == prepared.plan_id + + +def test_resume_claim_requires_stopped_execution(inputs, tmp_path): + config, owner = inputs + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + owner.claim_training(prepared.plan_id) + with pytest.raises(ValueError, match="reconciliation"): + owner.claim_training(prepared.plan_id, resume=True) + owner.save_training_result( + prepared.plan_id, TrainingRun(id="paused", config=config, phase=RunPhase.PAUSED) + ) + owner.claim_training(prepared.plan_id, resume=True) + assert len(list((owner.base_dir / prepared.plan_id).glob("execution.*.claim"))) == 1 + + +@pytest.mark.asyncio +async def test_trainer_cancellation_waits_for_worker_cleanup(inputs): + import threading + import time + + from carl_studio.training.trainer import CARLTrainer + + config, _ = inputs + started = threading.Event() + ended = threading.Event() + trainer = CARLTrainer(config) + + class Fit: + def add_callback(self, callback): + self.callback = callback + + def train(self): + started.set() + control = SimpleNamespace(should_training_stop=False, should_save=False) + while not control.should_training_stop: + self.callback.on_step_end(None, None, control) + time.sleep(0.005) + assert control.should_save + time.sleep(0.03) + ended.set() + + task = asyncio.create_task(trainer._fit(Fit(), None)) + while not started.is_set(): + await asyncio.sleep(0.005) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert ended.is_set() + + +@pytest.mark.asyncio +async def test_repeated_cancellation_waits_for_worker_cleanup(inputs): + import threading + import time + + from carl_studio.training.trainer import CARLTrainer + + config, _ = inputs + ended = threading.Event() + cleaning = threading.Event() + trainer = CARLTrainer(config) + + class Fit: + def add_callback(self, callback): + self.callback = callback + + def train(self): + control = SimpleNamespace(should_training_stop=False, should_save=False) + while not control.should_training_stop: + self.callback.on_step_end(None, None, control) + time.sleep(0.005) + assert control.should_save + cleaning.set() + time.sleep(0.05) + ended.set() + + task = asyncio.create_task(trainer._fit(Fit(), None)) + await asyncio.sleep(0.01) + task.cancel() + while not cleaning.is_set(): + await asyncio.sleep(0.005) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert ended.is_set() + + +def test_coherence_failure_after_twentieth_sample_is_not_hidden(monkeypatch): + import torch + + from carl_studio.eval.runner import EvalConfig + + calls = [] + + class Model: + config = SimpleNamespace(vocab_size=3) + device = torch.device("cpu") + training = False + + def eval(self): + return self + + def __call__(self, **kwargs): + calls.append(1) + if len(calls) == 21: + raise RuntimeError("private canary") + return SimpleNamespace(logits=torch.tensor([[[0.0, 1.0, 3.0], [3.0, 1.0, 0.0]]])) + + def tokenize(*args, **kwargs): + return {"input_ids": torch.tensor([[1, 2]])} + + runner = EvalRunner(EvalConfig(checkpoint="fixture")) + assert runner._compute_coherence(Model(), tokenize, ["answer"] * 21) is None + assert len(calls) == 21 diff --git a/tests/test_run_diff.py b/tests/test_run_diff.py index 535fea9..8b3d441 100644 --- a/tests/test_run_diff.py +++ b/tests/test_run_diff.py @@ -60,6 +60,29 @@ def test_summarize_run_handles_missing_result_fields(): assert summary.status == "training" +def test_goal_comparison_reads_persisted_acceptance_and_keeps_metrics_distinct(): + def row(run_id, value, metric="task_success_rate"): + return { + "id": run_id, + "status": "complete", + "result": { + "acceptance": { + "status": "accepted", + "candidate": {"primary_metric": metric, "primary_value": value}, + "policy_passed": True, + }, + "resource_usage": {"elapsed_seconds": 12.5}, + }, + } + + report = compute_diff(row("a", 0.2), [], row("b", 0.8), []) + assert report.goal_value_delta == pytest.approx(0.6) + assert report.b.acceptance_status == "accepted" + assert report.b.policy_passed is True + assert report.b.elapsed_seconds == 12.5 + assert compute_diff(row("a", 0.2), [], row("b", 0.8, "error_rate"), []).goal_value_delta is None + + def test_summarize_run_handles_json_string_result(): payload = { "phi_mean": 0.55, From cd3b34d54bbe1353d27a780080c76953ae9ddaa1 Mon Sep 17 00:00:00 2001 From: Tej Desai <29216465+wheattoast11@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:49:20 -0500 Subject: [PATCH 2/4] fix: prepare CARL 0.21.1 integration and plugin corrections --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 11 ++ docs/release-0.21.1.md | 65 ++++++++++ package.json | 4 +- plugin.json | 2 +- pyproject.toml | 2 +- skills/carl/references/workflows.md | 20 ++++ src/carl_studio/__init__.py | 2 +- src/carl_studio/cli/training.py | 9 +- src/carl_studio/compute/hf_jobs.py | 29 +++-- src/carl_studio/eval/runner.py | 61 +++++++++- src/carl_studio/plugin.py | 57 ++++++++- src/carl_studio/project.py | 7 +- src/carl_studio/training/preparation.py | 75 +++++++++++- src/carl_studio/training/rewards/privacy.py | 57 +++++++++ src/carl_studio/training/trainer.py | 8 +- tests/test_carl_plugin.py | 40 +++++++ tests/test_hf_jobs.py | 68 +++++++++-- tests/test_prepared_training.py | 59 +++++++++ tests/test_privacy_boundary.py | 126 ++++++++++++++++++++ tests/test_project_inspection.py | 28 +++++ uv.lock | 2 +- 23 files changed, 689 insertions(+), 47 deletions(-) create mode 100644 docs/release-0.21.1.md create mode 100644 src/carl_studio/training/rewards/privacy.py create mode 100644 tests/test_privacy_boundary.py create mode 100644 tests/test_project_inspection.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d9d6d59..5286578 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "carl", - "version": "0.21.0", + "version": "0.21.1", "description": "Coherence-Aware Reinforcement Learning and bidirectional local agent workflows.", "author": { "name": "Tej Desai" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 15e5caf..9c167c9 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "carl", - "version": "0.21.0", + "version": "0.21.1", "description": "Coherence-Aware Reinforcement Learning and bidirectional local agent workflows.", "author": { "name": "Tej Desai" diff --git a/CHANGELOG.md b/CHANGELOG.md index 92890ca..f9cc1ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.21.1] - 2026-10-08 + +- Authenticate private HF Jobs script fetches and keep submission tokens out of CARL logs and error messages. +- Remove all environment/secret key collisions before job submission. +- Accept legacy project `backend` fields as `adapter` while preserving explicit current fields. +- Resolve preparation datasets beside the config before the project root and bind that config for replay. +- Hash checkpoint files with bounded concurrency and cache SHA-256 digests by file identity and change timestamps. Execution still validates full source bytes. +- Apply terminal privacy penalties outside cascade masks and reward weights; reject public trajectories exposing private draft identifiers. +- Remove retired installer-owned skill references during plugin updates and preserve edited files. + + ## [0.21.0] - 2026-10-08 - CARL now installs as the `carl` plugin and skill for Codex, Claude Code, OpenCode, and portable MCP clients. diff --git a/docs/release-0.21.1.md b/docs/release-0.21.1.md new file mode 100644 index 0000000..84b22ac --- /dev/null +++ b/docs/release-0.21.1.md @@ -0,0 +1,65 @@ +--- +last_updated: 2026-10-08 +author: CARL project +applies_to: carl-studio 0.21.1 +--- + +# CARL 0.21.1 verification + +Source base: `e10b974f3e2ff05ad1daadb474802f117f5adf27` on +`codex/carl-prepared-training`. The existing HF Jobs edit was retained and +extended with token redaction and complete env/secret collision removal. +Public main was `5be38462a9c3dba089e24f1386a945dc2469b99c` at release preparation. + +## Changes + +- Private HF Jobs script URLs use URL-encoded token authentication on raw paths. + CARL logs repository and script identity without the token. Submission errors + redact raw and encoded token values and suppress the sensitive SDK traceback. +- Environment entries take precedence over secret mappings for every key. +- Legacy project `backend` loads as `adapter`; an explicit `adapter` wins. + Project inspection keeps compute orchestration distinct from the adapter. +- Relative preparation datasets resolve beside the config before the discovered + project root. The config is bound so prepared-plan validation uses the same rule. +- Source hashing uses at most four workers. Checkpoint digests are cached by + device, inode, size, mtime and ctime. The first binding reads all bytes; + execution validation continues to rehash every source. An index-only digest + was excluded because it would miss changes to shard contents. +- Training checks per-sample `private_keys` and optional `trajectory` metadata, + plus each completion. Leaks zero task and coherence contributions and receive + an unweighted `-3.0` terminal reward outside cascade masks. Evaluation can + enforce the same event boundary with `EvalConfig.private_keys`. +- Plugin updates remove retired owned references, including upgrades from the + previous ownership format. Edited files are refused and preserved. +- Canonical skill workflows document preparation and privacy metadata. + +## Executed checks + +- `.venv/bin/python -m pytest tests/ packages/carl-core/tests/ -q --tb=short`: + **4,411 passed, 29 skipped, 85 warnings**, 93.14 seconds. +- Strict Pyright for plugin, preparation, privacy rewards and project loading: + **0 errors**. +- Ruff for those modules and new plugin/privacy/project regressions: **passed**. +- Comparative checks for the other touched modules: Ruff **74 baseline and + 74 current diagnostics**; Pyright **248 baseline and 230 current diagnostics**; + **no introduced diagnostics**. Baselines were extracted from the source base. +- `uv lock --check --offline`: **passed**, 372 packages, no dependency upgrades. +- `python -m build --outdir dist/v0.21.1`: source archive and wheel **built**. +- Fresh isolated environment installed the wheel and existing core 0.3.0 wheel + offline. CLI version, module locations, legacy adapter loading, terminal privacy + penalties, malformed events, numeric identifiers, and lightweight imports + **passed**. Torch and Transformers were absent from the import path exercised. +- Installed Codex, Claude Code and OpenCode completed native metric witnesses + against synthetic localhost providers. All returned matching artifacts and + bound native sessions. All three cancellation witnesses stopped execution + without cancellation errors. +- Source archive contains canonical plugin manifests and skill references; + wheel contains the privacy reward module. Artifact hashes: `dist/v0.21.1/SHA256SUMS`. + +## Scope + +HF Jobs regressions use mocked SDK calls. No paid HF job or model training was +submitted. Native witnesses use synthetic localhost providers. The 55 GB +checkpoint from the integration report was not benchmarked. Cold checkpoint +binding still requires reading its bytes; repeated preparation uses the cache. +GitHub and PyPI publication are separate from these local checks. diff --git a/package.json b/package.json index 238a1c8..a35d752 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "carl-studio", - "version": "0.21.0", + "version": "0.21.1", "private": true, "terminals": { "concept": "carl", @@ -20,6 +20,6 @@ "progenitor": "tej", "emergent": "genesis" }, - "version": "0.21.0" + "version": "0.21.1" } } diff --git a/plugin.json b/plugin.json index 3cf8070..ab6825d 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "carl", - "version": "0.21.0", + "version": "0.21.1", "description": "Coherence-Aware Reinforcement Learning and bidirectional local agent workflows.", "author": { "name": "Tej Desai" diff --git a/pyproject.toml b/pyproject.toml index a0fe2ac..9dcf9c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "carl-studio" -version = "0.21.0" +version = "0.21.1" description = "CARL (Coherence-Aware Reinforcement Learning) — information-theoretic reward signals for LLM training via token-level probability distributions" readme = "README.md" license = "MIT" diff --git a/skills/carl/references/workflows.md b/skills/carl/references/workflows.md index c6f55dc..53aadd7 100644 --- a/skills/carl/references/workflows.md +++ b/skills/carl/references/workflows.md @@ -21,3 +21,23 @@ and the named evaluation commands requested by the user. CARL's delegation task IDs remain stable across its MCP and A2A observation surfaces. Resume input only through the original live execution owner. + +Prepare local training without starting a job: + +```bash +carl train --prepare --config path/to/training.yaml --json +``` + +Relative datasets resolve beside the configuration first, then at the discovered +project root. Inspect `ready` and the reported issues before executing the plan. +The first checkpoint binding reads every shard. Later preparation reuses cached +byte digests while file identity, size, mtime, and ctime match; execution still +rechecks source bytes. A checkpoint index alone does not bind shard contents. + +TCG reward datasets can supply `private_keys` as a list of private draft identifiers +per sample and `trajectory` as a list of events with `phase: private` or +`phase: public`. CARL checks both the recorded public events and the completion. +A leak zeroes task and coherence contributions and adds an unweighted terminal +reward of `-3.0`, outside cascade masks. Evaluation through `EvalConfig.private_keys` +checks public events in `EvalReport.detail` and refuses a leaking report. +Keep private draft identifiers and contents out of public logs and result artifacts. diff --git a/src/carl_studio/__init__.py b/src/carl_studio/__init__.py index 2a35e79..82598d1 100644 --- a/src/carl_studio/__init__.py +++ b/src/carl_studio/__init__.py @@ -10,7 +10,7 @@ from __future__ import annotations -__version__ = "0.21.0" +__version__ = "0.21.1" from typing import TYPE_CHECKING diff --git a/src/carl_studio/cli/training.py b/src/carl_studio/cli/training.py index 1abfa62..e0604b4 100644 --- a/src/carl_studio/cli/training.py +++ b/src/carl_studio/cli/training.py @@ -700,7 +700,9 @@ def train( ) prepared = prepare_training( - training_config, project_root=project_root_for_config(config_path) + training_config, + project_root=project_root_for_config(config_path), + config_path=config_path, ) if json_output: result = prepared.model_dump(mode="json") @@ -737,7 +739,10 @@ def train( load_preparation(prepared_plan_id) if prepared_plan_id is not None else prepare_training( - training_config, project_root=project_root_for_config(config_path), persist=False + training_config, + project_root=project_root_for_config(config_path), + config_path=config_path, + persist=False, ) ) if prepared_plan_id is not None: diff --git a/src/carl_studio/compute/hf_jobs.py b/src/carl_studio/compute/hf_jobs.py index a82c873..c1c1a1b 100644 --- a/src/carl_studio/compute/hf_jobs.py +++ b/src/carl_studio/compute/hf_jobs.py @@ -22,6 +22,7 @@ from dataclasses import dataclass from enum import Enum from typing import Any +from urllib.parse import quote, urlencode logger = logging.getLogger(__name__) @@ -201,6 +202,8 @@ async def execute(self, script: str, **kwargs: object) -> str: from huggingface_hub import HfApi token = self._get_token() + if not token: + raise PermissionError("HF token required for HF Jobs backend") api = HfApi(token=token) # Resolve flavor and timeout @@ -235,26 +238,25 @@ async def execute(self, script: str, **kwargs: object) -> str: commit_message=f"carl-studio: upload training script {script_hash}", ) except Exception as exc: - raise RuntimeError( - f"Failed to upload script to {upload_repo}: {exc}" - ) from exc + raise RuntimeError(f"Failed to upload script to {upload_repo}: {exc}") from exc # Build the hosted URL for the script script_url = ( - f"https://huggingface.co/datasets/{upload_repo}/" - f"resolve/{_SCRIPT_BRANCH}/{script_filename}" + f"https://huggingface.co/datasets/{upload_repo}/raw/" + f"{_SCRIPT_BRANCH}/{script_filename}?{urlencode({'token': token})}" ) - logger.info("HF Jobs: script URL = %s", script_url) - - # Build secrets dict - secrets = self._build_secrets(kwargs.get("secrets")) + logger.info("HF Jobs: script uploaded to %s/%s", upload_repo, script_filename) # Build env dict - env: dict[str, str] = {"PYTHONUNBUFFERED": "1"} + env: dict[str, str] = {"PYTHONUNBUFFERED": "1", "HF_TOKEN": token} extra_env = kwargs.get("env") if isinstance(extra_env, dict): env.update({str(k): str(v) for k, v in extra_env.items()}) + # Build secrets dict + secrets = self._build_secrets(kwargs.get("secrets")) + secrets = {key: value for key, value in secrets.items() if key not in env} + # Build labels labels: dict[str, str] = { "carl_studio": "true", @@ -276,10 +278,11 @@ async def execute(self, script: str, **kwargs: object) -> str: namespace=self._namespace, token=token, ) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - SDK failures can contain token-bearing URLs raise RuntimeError( - f"Failed to submit HF Job (flavor={flavor}): {exc}" - ) from exc + f"Failed to submit HF Job (flavor={flavor}): " + + str(exc).replace(token, "[REDACTED]").replace(quote(token, safe=""), "[REDACTED]") + ) from None job_id = job.id self._active_jobs[job_id] = script_hash diff --git a/src/carl_studio/eval/runner.py b/src/carl_studio/eval/runner.py index aa5b96b..8b45265 100644 --- a/src/carl_studio/eval/runner.py +++ b/src/carl_studio/eval/runner.py @@ -20,7 +20,7 @@ import tempfile import time from collections.abc import Callable -from typing import Any, Literal +from typing import Any, Literal, cast from pydantic import BaseModel, Field, field_validator, model_validator @@ -43,6 +43,49 @@ # --------------------------------------------------------------------------- +class PrivacyBoundaryGate: + """Check public event keys and values for private draft identifiers.""" + + penalty = -3.0 + + def audit_trajectory(self, trajectory: Any, private_keys: set[str]) -> bool: + if not isinstance(trajectory, list) or not trajectory or any(not key for key in private_keys): + return False + + def leaks(value: Any, depth: int = 0) -> bool: + if depth > 64: + return True + if isinstance(value, str): + return any(key in value for key in private_keys) + if isinstance(value, dict): + return any( + leaks(key, depth + 1) or leaks(item, depth + 1) + for key, item in cast(dict[str, Any], value).items() + ) + if isinstance(value, (list, tuple)): + return any(leaks(item, depth + 1) for item in cast(list[Any], value)) + if value is None: + return False + if isinstance(value, (int, float, bool)): + return leaks(json.dumps(value)) + return True + + for value in cast(list[Any], trajectory): + if not isinstance(value, dict): + return False + event = cast(dict[str, Any], value) + phase = event.get("phase") + if not isinstance(phase, str) or phase not in {"private", "public"}: + return False + if event["phase"] == "public" and leaks(event): + return False + return True + + def terminal_reward(self, trajectory: Any, private_keys: set[str]) -> float: + """Return an unweighted terminal penalty for a violated boundary.""" + return 0.0 if self.audit_trajectory(trajectory, private_keys) else self.penalty + + class EvalConfig(BaseModel): """Evaluation configuration.""" @@ -69,6 +112,8 @@ class EvalConfig(BaseModel): max_new_tokens: int = Field(default=2048, ge=64, description="Max tokens per generation turn") max_turns: int = Field(default=10, ge=1, description="Max multi-turn loops for Phase 2'") + private_keys: set[str] = Field(default_factory=set, exclude=True, repr=False) + # Adapter stacking for Phase 2' base_model: str | None = Field( default=None, @@ -224,9 +269,17 @@ def check(self, report: EvalReport) -> bool: Side effect: writes a human-readable ``gate_reason`` back onto the report so callers/logs can see which dimension failed. """ + if self.config is not None and self.config.private_keys: + if not PrivacyBoundaryGate().audit_trajectory(report.detail, self.config.private_keys): + report.metrics["privacy_terminal_reward"] = -3.0 + report.gate_reason = "FAIL privacy boundary" + return False + report.metrics["privacy_terminal_reward"] = 0.0 lower = self.config is not None and self.config.metric_direction == "lower" primary_ok = ( - report.primary_value <= self.threshold if lower else report.primary_value >= self.threshold + report.primary_value <= self.threshold + if lower + else report.primary_value >= self.threshold ) comparator = "<=" if lower else ">=" @@ -1323,7 +1376,9 @@ def _load_model_simple(self) -> tuple[Any, Any]: model.eval() - tokenizer = AutoTokenizer.from_pretrained(self.config.tokenizer_source or self.config.checkpoint) + tokenizer = AutoTokenizer.from_pretrained( + self.config.tokenizer_source or self.config.checkpoint + ) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token diff --git a/src/carl_studio/plugin.py b/src/carl_studio/plugin.py index cb3c553..0e940b7 100644 --- a/src/carl_studio/plugin.py +++ b/src/carl_studio/plugin.py @@ -60,11 +60,26 @@ def _atomic_write(path: Path, content: bytes) -> None: temporary.unlink(missing_ok=True) -def project(root: Path, output: Path, *, python: str = sys.executable) -> dict[str, Any]: +def project( + root: Path, + output: Path, + *, + python: str = sys.executable, + owned_files: dict[str, str] | None = None, +) -> dict[str, Any]: """Generate a small native marketplace from the portable source.""" for path in [output, *output.rglob("*")]: if path.is_symlink(): raise CARLError("Projection target is a symlink", code="carl.plugin.ownership") + previous_path = output / "projection.json" + previous: dict[str, Any] = ( + json.loads(previous_path.read_text()) if previous_path.is_file() else {} + ) + previous_files: dict[str, str] = previous.get("owned_files", owned_files or {}) + for name, expected in previous_files.items(): + target = output / name + if not target.resolve().is_relative_to(output.resolve()) or _identity(target) != expected: + raise CARLError("Edited projection entry", code="carl.plugin.ownership") digest = source_digest(root) manifest = json.loads((root / "plugin.json").read_text()) snapshot = output / "plugins/carl" @@ -104,7 +119,24 @@ def project(root: Path, output: Path, *, python: str = sys.executable) -> dict[s } _write_json(output / ".claude-plugin/marketplace.json", marketplace) _write_json(output / ".agents/plugins/marketplace.json", marketplace) + desired = { + "plugins/carl/plugin.json", + "plugins/carl/mcp.json", + "plugins/carl/.mcp.json", + "plugins/carl/.claude-plugin/plugin.json", + "plugins/carl/.codex-plugin/plugin.json", + ".claude-plugin/marketplace.json", + ".agents/plugins/marketplace.json", + } + desired.update( + "plugins/carl/skills/carl/" + str(path.relative_to(root / "skills/carl")) + for path in (root / "skills/carl").rglob("*") + if path.is_file() + ) + for name in previous_files.keys() - desired: + (output / name).unlink() record = { + "owned_files": {name: _identity(output / name) for name in sorted(desired)}, "schema_version": 1, "source": str(root), "source_digest": digest, @@ -201,7 +233,28 @@ def install(self, hosts: list[str] | None = None, *, native: bool = True) -> dic raise CARLError( "Foreign or edited carl entry", code="carl.plugin.ownership" ) - projection = project(self.root, self.state / "marketplace", python=self.python) + desired_targets = { + str(destination / path.relative_to(skill)) + for destination in destinations + for path in skill.rglob("*") + if path.is_file() + } + retired = set(record["files"]) - desired_targets + for name in retired: + target = Path(name) + self._owned_target(target) + if _identity(target) != record["files"][name]: + raise CARLError("Edited retired carl entry", code="carl.plugin.ownership") + marketplace = self.state / "marketplace" + legacy_files = { + str(Path(name).relative_to(marketplace)): expected + for name, expected in record.get("projection_files", {}).items() + if Path(name) != marketplace / "projection.json" + } + projection = project(self.root, marketplace, python=self.python, owned_files=legacy_files) + for name in retired: + Path(name).unlink() + del record["files"][name] for destination in destinations: for path in skill.rglob("*"): if path.is_file(): diff --git a/src/carl_studio/project.py b/src/carl_studio/project.py index 1495bb9..63f7b31 100644 --- a/src/carl_studio/project.py +++ b/src/carl_studio/project.py @@ -11,7 +11,7 @@ from typing import Any import yaml -from pydantic import BaseModel, Field +from pydantic import AliasChoices, BaseModel, Field @@ -42,6 +42,7 @@ class CARLProject(BaseModel): # Training framework (which adapter to route through) adapter: str = Field( default="trl", + validation_alias=AliasChoices("adapter", "backend"), description=( "Training framework adapter: trl, unsloth, axolotl, tinker, atropos, slime. " "This is the name registered in carl_studio.adapters — NOT a compute substrate." @@ -95,8 +96,8 @@ def load_project(path: Path | str = "carl.yaml") -> CARLProject: if not p.exists(): raise FileNotFoundError(f"Project file not found: {p}") with open(p) as f: - raw = yaml.safe_load(f) or {} - return CARLProject(**raw) + raw: dict[str, Any] = yaml.safe_load(f) or {} + return CARLProject.model_validate(raw) def save_project(project: CARLProject, path: Path | str = "carl.yaml") -> None: diff --git a/src/carl_studio/training/preparation.py b/src/carl_studio/training/preparation.py index fb9186a..d63954e 100644 --- a/src/carl_studio/training/preparation.py +++ b/src/carl_studio/training/preparation.py @@ -8,10 +8,13 @@ import importlib.util import json import math +import os import re import sys +import tempfile import types from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from threading import RLock from typing import Any, Literal, cast @@ -159,6 +162,41 @@ def file_hash(path: Path) -> str: return digest.hexdigest() +def checkpoint_hash(path: Path, cache_dir: Path) -> str: + """Reuse byte digests only while file identity and change timestamps match.""" + resolved = path.resolve() + stat = resolved.stat() + identity = [stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns] + entry = cache_dir / (content_hash(str(resolved)) + ".json") + try: + cached = json.loads(entry.read_text()) + value = cached.get("sha256", "") + if cached.get("identity") == identity and re.fullmatch(r"[0-9a-f]{64}", value): + return value + except (OSError, ValueError, AttributeError, TypeError): + pass + value = file_hash(resolved) + after = resolved.stat() + if identity != [ + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + after.st_ctime_ns, + ]: + raise ValidationError("Checkpoint changed during binding", code="carl.preparation.stale") + cache_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + fd, name = tempfile.mkstemp(dir=cache_dir, prefix=".hash-") + temporary = Path(name) + try: + with os.fdopen(fd, "w") as stream: + json.dump({"identity": identity, "sha256": value}, stream) + os.replace(temporary, entry) + finally: + temporary.unlink(missing_ok=True) + return value + + def default_manager() -> ExperimentManager: """Use CARL's existing private experiment directory.""" return ExperimentManager(Path.home() / ".carl" / "experiments") @@ -403,6 +441,7 @@ def prepare_training( config: TrainingConfig, *, project_root: Path | None = None, + config_path: Path | None = None, manager: ExperimentManager | None = None, persist: bool = True, ) -> TrainingPreparation: @@ -421,6 +460,11 @@ def prepare_training( config = config.model_copy(deep=True, update={"goal": goal, "push_to_hub": False}) issues: list[ReadinessIssue] = [] sources: list[SourceBinding] = [] + if config_path is not None: + config_path = config_path.resolve() + sources.append( + SourceBinding(kind="config", path=str(config_path), sha256=file_hash(config_path)) + ) populations: dict[str, list[UnifiedSample]] = {} def issue(code: str, message: str, action: str) -> None: @@ -539,8 +583,18 @@ def issue(code: str, message: str, action: str) -> None: ) else: config.sft_adapter = str(adapter.resolve()) - for path in model_sources(config, root): - sources.append(SourceBinding(kind="model", path=str(path), sha256=file_hash(path))) + cache_dir = (manager or default_manager()).base_dir / "source-hashes" + + def bind_model_source(path: Path) -> SourceBinding: + digest = ( + checkpoint_hash(path, cache_dir) + if persist and path.suffix in {".safetensors", ".bin"} + else file_hash(path) + ) + return SourceBinding(kind="model", path=str(path), sha256=digest) + + with ThreadPoolExecutor(max_workers=4, thread_name_prefix="carl-source") as workers: + sources.extend(workers.map(bind_model_source, model_sources(config, root))) normalized_adapters: list[str] = [] for value in config.starting_adapters: adapter = Path(value) @@ -588,7 +642,11 @@ def issue(code: str, message: str, action: str) -> None: ("eval_data", config.eval_dataset_repo or ""), ): path = Path(name) - path = path if path.is_absolute() else root / path + if not path.is_absolute(): + candidates = ([config_path.resolve().parent / path] if config_path else []) + [ + root / path + ] + path = next((candidate for candidate in candidates if candidate.is_file()), root / path) if not name or not path.is_file(): issue( kind, @@ -779,7 +837,16 @@ def validate_preparation( value = getattr(candidate, field) if value: path = Path(value) - resolved = path if path.is_absolute() else Path(preparation.project_root) / path + resolved = path if path.is_absolute() else root / path + if field in {"dataset_repo", "eval_dataset_repo"} and not path.is_absolute(): + configs = [ + Path(source.path).parent / path + for source in preparation.sources + if source.kind == "config" + ] + resolved = next( + (item for item in [*configs, root / path] if item.is_file()), resolved + ) if resolved.exists(): setattr(candidate, field, str(resolved.resolve())) candidate.starting_adapters = [ diff --git a/src/carl_studio/training/rewards/privacy.py b/src/carl_studio/training/rewards/privacy.py new file mode 100644 index 0000000..0569d33 --- /dev/null +++ b/src/carl_studio/training/rewards/privacy.py @@ -0,0 +1,57 @@ +"""Terminal privacy penalties outside cascade masks and reward weights.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, cast + +from carl_studio.eval.runner import PrivacyBoundaryGate +from carl_studio.training.rewards.base import extract_text + + +def privacy_rewards(completions: list[Any], **kwargs: Any) -> list[float]: + """Audit per-completion private identifiers and optional event trajectories.""" + keys = kwargs.get("private_keys") + if keys is None: + return [0.0] * len(completions) + if not isinstance(keys, list) or len(cast(list[Any], keys)) != len(completions): + raise ValueError("private_keys must have one identifier list per completion") + trajectories = kwargs.get("trajectory") + if trajectories is not None and ( + not isinstance(trajectories, list) or len(cast(list[Any], trajectories)) != len(completions) + ): + raise ValueError("trajectory must have one event list per completion") + key_rows = cast(list[Any], keys) + trajectory_rows = cast(list[Any] | None, trajectories) + gate = PrivacyBoundaryGate() + scores: list[float] = [] + for index, completion in enumerate(completions): + identifiers: Any = key_rows[index] + if not isinstance(identifiers, (list, set, tuple)) or any( + not isinstance(key, str) or not key for key in cast(list[Any], identifiers) + ): + raise ValueError("Private draft identifiers must be nonempty strings") + events: Any = trajectory_rows[index] if trajectory_rows is not None else [] + if not isinstance(events, list): + raise TypeError("A trajectory must be a list of events") + public = {"phase": "public", "completion": extract_text(completion)} + scores.append( + gate.terminal_reward( + [*cast(list[dict[str, Any]], events), public], set(cast(list[str], identifiers)) + ) + ) + return scores + + +def suppress_private_rewards(function: Callable[..., list[float]]) -> Callable[..., list[float]]: + """Zero task and coherence contributions for completions with privacy violations.""" + + def score(completions: list[Any], **kwargs: Any) -> list[float]: + penalties = privacy_rewards(completions, **kwargs) + values = function(completions=completions, **kwargs) + if len(values) != len(penalties): + raise ValueError("Reward population mismatch") + return [0.0 if penalty else value for value, penalty in zip(values, penalties)] + + score.__name__ = getattr(function, "__name__", "reward") + return score diff --git a/src/carl_studio/training/trainer.py b/src/carl_studio/training/trainer.py index 2f94894..5fb103a 100644 --- a/src/carl_studio/training/trainer.py +++ b/src/carl_studio/training/trainer.py @@ -1242,7 +1242,7 @@ def _build_grpo_training_args( # ------------------------------------------------------------------ def _build_rewards(self, model: Any, tokenizer: Any) -> list[Any]: - """Build 6 reward functions: 5 task + 1 CARL, all cascade-wrapped. + """Build cascade rewards plus an unmasked terminal privacy reward. Two-stage cascade (matches CascadeRewardManager API): Stage A: task rewards only (before carl_start) @@ -1255,6 +1255,7 @@ def _build_rewards(self, model: Any, tokenizer: Any) -> list[Any]: R4: neuralese_v2_reward (w=0.5, stages A/B) R5: conciseness_reward (w=0.5, stages A/B) R6: carl_composite_reward (w=1.5, stage B only) + R7: terminal privacy penalty (unweighted, outside cascade masks) """ from carl_studio.training.cascade import CascadeRewardManager from carl_studio.training.rewards.composite import make_carl_reward @@ -1305,11 +1306,14 @@ def _build_rewards(self, model: Any, tokenizer: Any) -> list[Any]: ) ] + [(carl_fn, {"B"}, 1.5)] + from carl_studio.training.rewards.privacy import privacy_rewards, suppress_private_rewards + wrapped: list[Any] = [] for fn, stages, weight in reward_specs: cascade_fn = cascade.wrap_reward(fn, active_in_stages=stages) weighted_fn = _apply_weight(cascade_fn, weight) - wrapped.append(weighted_fn) + wrapped.append(suppress_private_rewards(weighted_fn)) + wrapped.append(privacy_rewards) return wrapped diff --git a/tests/test_carl_plugin.py b/tests/test_carl_plugin.py index d5e5f53..687c4a6 100644 --- a/tests/test_carl_plugin.py +++ b/tests/test_carl_plugin.py @@ -140,3 +140,43 @@ def test_projected_reference_symlink_refuses_update(tmp_path: Path) -> None: with pytest.raises(CARLError): installer.install(["codex"], native=False) assert victim.read_text() == "unchanged" + + +@pytest.mark.parametrize("edited", [False, True]) +@pytest.mark.parametrize("legacy", [False, True]) +def test_update_removes_retired_references_and_preserves_edits(tmp_path, edited, legacy): + import shutil + + root = tmp_path / "source" + root.mkdir() + for name in ("plugin.json", "mcp.json", "pyproject.toml", "uv.lock"): + shutil.copyfile(ROOT / name, root / name) + shutil.copytree(ROOT / "skills", root / "skills") + home = tmp_path / "home" + installer = PluginInstaller(root, home=home) + installer.install(["codex"], native=False) + alias = home / ".agents/skills/carl/references/workflows.md" + snapshot = installer.state / "marketplace/plugins/carl/skills/carl/references/workflows.md" + if legacy: + import hashlib + + projection = installer.state / "marketplace/projection.json" + data = json.loads(projection.read_text()) + del data["owned_files"] + projection.write_text(json.dumps(data)) + record = json.loads(installer.marker.read_text()) + record["projection_files"][str(projection)] = hashlib.sha256( + projection.read_bytes() + ).hexdigest() + installer.marker.write_text(json.dumps(record)) + (root / "skills/carl/references/workflows.md").unlink() + if edited: + alias.write_text("operator edit") + with pytest.raises(CARLError, match="Edited retired"): + installer.install(["codex"], native=False) + assert alias.read_text() == "operator edit" + assert snapshot.is_file() + else: + assert installer.install(["codex"], native=False)["healthy"] + assert not alias.exists() + assert not snapshot.exists() diff --git a/tests/test_hf_jobs.py b/tests/test_hf_jobs.py index 841d89e..3a258cb 100644 --- a/tests/test_hf_jobs.py +++ b/tests/test_hf_jobs.py @@ -9,6 +9,8 @@ - Teardown cleanup """ +from __future__ import annotations + import hashlib from dataclasses import dataclass from enum import Enum @@ -199,8 +201,8 @@ async def test_execute_rejects_empty_script(backend, mock_api): @pytest.mark.asyncio -async def test_execute_includes_hf_token_in_secrets(backend, mock_api): - """execute() always includes HF_TOKEN in secrets.""" +async def test_execute_passes_hf_token_without_secret_collision(backend, mock_api): + """execute() passes the token once and retains unrelated secrets.""" mock_hf = MagicMock() mock_hf.whoami.return_value = {"name": "testuser"} mock_hf.upload_file.return_value = None @@ -210,7 +212,8 @@ async def test_execute_includes_hf_token_in_secrets(backend, mock_api): await backend.execute(script="print('test')", secrets={"WANDB_KEY": "WANDB_KEY"}) secrets = mock_hf.run_uv_job.call_args.kwargs["secrets"] - assert secrets["HF_TOKEN"] == "HF_TOKEN" + assert "HF_TOKEN" not in secrets + assert mock_hf.run_uv_job.call_args.kwargs["env"]["HF_TOKEN"] == "hf_test_token" assert secrets["WANDB_KEY"] == "WANDB_KEY" @@ -320,9 +323,7 @@ async def test_status_normalizes_stages(backend, mock_api, raw_stage, expected): async def test_status_detailed_includes_message(backend, mock_api): """status_detailed() returns message from the job.""" mock_hf = MagicMock() - mock_hf.inspect_job.return_value = _make_job_info( - stage="ERROR", message="OOM at step 68" - ) + mock_hf.inspect_job.return_value = _make_job_info(stage="ERROR", message="OOM at step 68") with patch("huggingface_hub.HfApi", return_value=mock_hf): result = await backend.status_detailed("job-test") @@ -559,7 +560,6 @@ def test_ignores_non_dict(self): class TestResolveScript: - def test_inline_content_returned_as_is(self): script = "# /// script\nprint('hello')\n" assert HFJobsBackend._resolve_script_content(script) == script @@ -603,9 +603,18 @@ def test_flavor_map_covers_all_compute_targets(): continue # The value should either be in the flavor map or handled by trainer assert target.value in _FLAVOR_MAP or target.value in ( - "l4x1", "l4x4", "a10g-large", "a10g-largex2", "a10g-largex4", - "a100-large", "a100-largex2", "a100-largex4", "a100-largex8", - "l40sx1", "l40sx4", "l40sx8", + "l4x1", + "l4x4", + "a10g-large", + "a10g-largex2", + "a10g-largex4", + "a100-large", + "a100-largex2", + "a100-largex4", + "a100-largex8", + "l40sx1", + "l40sx4", + "l40sx8", ), f"ComputeTarget.{target.name} ({target.value}) has no flavor mapping" @@ -653,3 +662,42 @@ def test_factory_returns_hf_jobs_backend(): backend = get_backend("hf_jobs") assert isinstance(backend, HFJobsBackend) assert backend.name == "hf_jobs" + + +@pytest.mark.asyncio +async def test_private_script_url_and_collision_redaction(backend, caplog): + from urllib.parse import parse_qs, urlparse + + token = "hf_fixture&+token" + mock_hf = MagicMock() + mock_hf.run_uv_job.return_value = _make_job_info() + with ( + patch.object(HFJobsBackend, "_get_token", return_value=token), + patch("huggingface_hub.HfApi", return_value=mock_hf), + caplog.at_level("INFO"), + ): + await backend.execute( + "print('test')", + env={"CUSTOM": "value"}, + secrets={"CUSTOM": "secret-name", "OTHER": "other-secret"}, + ) + submitted = mock_hf.run_uv_job.call_args.kwargs + url = urlparse(submitted["script"]) + assert "/raw/main/train_" in url.path + assert parse_qs(url.query) == {"token": [token]} + assert not set(submitted["env"]) & set(submitted["secrets"]) + assert submitted["secrets"] == {"OTHER": "other-secret"} + assert token not in caplog.text + + +@pytest.mark.asyncio +async def test_submission_error_redacts_authenticated_url(backend, mock_api): + mock_hf = MagicMock() + mock_hf.run_uv_job.side_effect = RuntimeError("failed ?token=hf_test_token") + with ( + patch("huggingface_hub.HfApi", return_value=mock_hf), + pytest.raises(RuntimeError) as error, + ): + await backend.execute("print('test')") + assert "hf_test_token" not in str(error.value) + assert error.value.__suppress_context__ diff --git a/tests/test_prepared_training.py b/tests/test_prepared_training.py index 3e1fae6..f0cc65d 100644 --- a/tests/test_prepared_training.py +++ b/tests/test_prepared_training.py @@ -775,3 +775,62 @@ def tokenize(*args, **kwargs): runner = EvalRunner(EvalConfig(checkpoint="fixture")) assert runner._compute_coherence(Model(), tokenize, ["answer"] * 21) is None assert len(calls) == 21 + + +@pytest.mark.parametrize("location", ["config", "root"]) +def test_dataset_resolution_from_nested_config(inputs, tmp_path, location, monkeypatch): + config, owner = inputs + folder = tmp_path / "configs" + folder.mkdir() + config_path = folder / "nested.yaml" + config_path.write_text("{}") + if location == "config": + for name in ("train", "eval"): + (folder / f"{name}.jsonl").write_bytes((tmp_path / f"{name}.jsonl").read_bytes()) + config.dataset_repo = "train.jsonl" + config.eval_dataset_repo = "eval.jsonl" + for cwd in (tmp_path, folder): + monkeypatch.chdir(cwd) + prepared = prepare_training( + config, project_root=tmp_path, config_path=config_path, manager=owner + ) + assert prepared.ready + expected = folder if location == "config" else tmp_path + assert prepared.config["dataset_repo"] == str(expected / "train.jsonl") + validate_preparation(prepared, config) + + +def test_checkpoint_hash_cache_reuses_bytes_and_detects_same_size_change(tmp_path, monkeypatch): + from carl_studio.training import preparation + + shard = tmp_path / "model.safetensors" + shard.write_bytes(b"first") + cache = tmp_path / "cache" + original = preparation.file_hash + calls = [] + + def counted(path): + calls.append(path) + return original(path) + + monkeypatch.setattr(preparation, "file_hash", counted) + first = preparation.checkpoint_hash(shard, cache) + assert preparation.checkpoint_hash(shard, cache) == first + assert len(calls) == 1 + before = shard.stat() + shard.write_bytes(b"other") + import os + + os.utime(shard, ns=(before.st_atime_ns, before.st_mtime_ns)) + assert preparation.checkpoint_hash(shard, cache) != first + assert len(calls) == 2 + + +def test_model_index_cannot_hide_shard_mutation(inputs, tmp_path): + config, owner = inputs + model = Path(config.base_model) + (model / "model.safetensors.index.json").write_text('{"weight_map":{}}') + prepared = prepare_training(config, project_root=tmp_path, manager=owner) + (model / "model.safetensors").write_bytes(b"changed-fixture") + with pytest.raises(ValidationError, match="source changed"): + validate_preparation(prepared) diff --git a/tests/test_privacy_boundary.py b/tests/test_privacy_boundary.py new file mode 100644 index 0000000..24bfdd7 --- /dev/null +++ b/tests/test_privacy_boundary.py @@ -0,0 +1,126 @@ +"""Private drafts remain private across public evaluation and training rewards.""" + +from __future__ import annotations + +import pytest + +from carl_studio.eval.runner import EvalConfig, EvalGate, EvalReport, PrivacyBoundaryGate +from carl_studio.training.rewards.privacy import privacy_rewards, suppress_private_rewards + + +@pytest.mark.parametrize( + "event", + [ + {"phase": "public", "text": "draft-123"}, + {"phase": "public", "payload": [{"parent": "draft-123"}]}, + {"phase": "public", "draft-123": "value"}, + {"phase": "public", "nested": {"phase": "private", "text": "draft-123"}}, + ], +) +def test_private_identifiers_in_public_events_fail(event): + gate = PrivacyBoundaryGate() + assert not gate.audit_trajectory([event], {"draft-123"}) + assert gate.terminal_reward([event], {"draft-123"}) == -3.0 + + +def test_private_events_and_clean_publication_pass(): + trajectory = [ + {"phase": "private", "text": "draft-123"}, + {"phase": "public", "text": "published answer"}, + ] + assert PrivacyBoundaryGate().audit_trajectory(trajectory, {"draft-123"}) + assert PrivacyBoundaryGate().terminal_reward(trajectory, {"draft-123"}) == 0.0 + + +@pytest.mark.parametrize( + "trajectory", [None, 1, [], [{}], [{"phase": "unknown"}], [{"phase": []}], [{"phase": {}}], [None]] +) +def test_malformed_trajectories_fail(trajectory): + assert not PrivacyBoundaryGate().audit_trajectory(trajectory, {"draft-123"}) + + +def test_eval_cannot_pass_with_a_private_identifier(): + report = EvalReport( + checkpoint="fixture", + phase="2prime", + n_samples=1, + primary_metric="success", + primary_value=1.0, + threshold=0.5, + passed=True, + detail=[{"phase": "public", "text": "draft-123"}], + ) + config = EvalConfig( + checkpoint="fixture", private_keys={"draft-123"}, require_coherence_gate=False + ) + assert not EvalGate(config=config).check(report) + assert report.metrics["privacy_terminal_reward"] == -3.0 + assert "draft-123" not in report.gate_reason + assert "private_keys" not in config.model_dump() + report.detail = [{"phase": "public", "text": "answer"}] + assert EvalGate(config=config).check(report) + assert report.metrics["privacy_terminal_reward"] == 0.0 + + +@pytest.mark.parametrize("active_reward", [0.0, 100.0]) +def test_terminal_penalty_is_not_masked_or_offset(active_reward): + completions = ["draft-123 leaked", "clean answer"] + metadata = {"private_keys": [["draft-123"], ["draft-123"]]} + weighted = suppress_private_rewards(lambda **kwargs: [active_reward, active_reward]) + rewards = weighted(completions, **metadata) + terminal = privacy_rewards(completions, **metadata) + assert [a + b for a, b in zip(rewards, terminal)] == [-3.0, active_reward] + + +def test_privacy_reward_requires_aligned_metadata(): + with pytest.raises(ValueError, match="one identifier list"): + privacy_rewards(["answer"], private_keys=[]) + with pytest.raises(ValueError, match="nonempty strings"): + privacy_rewards(["answer"], private_keys=[[""]]) + with pytest.raises(ValueError, match="one event list"): + privacy_rewards(["answer"], private_keys=[["draft"]], trajectory=[]) + assert privacy_rewards(["answer"]) == [0.0] + + +@pytest.mark.parametrize("stage", ["A", "B"]) +def test_trainer_attaches_terminal_penalty_outside_cascade(stage, monkeypatch): + import importlib + from types import SimpleNamespace + + from carl_studio.training.trainer import CARLTrainer + from carl_studio.types.config import TrainingConfig + + monkeypatch.setattr( + importlib.import_module("carl_studio.training.rewards.composite"), + "make_carl_reward", + lambda **kwargs: lambda **kwargs: [100.0, 100.0], + ) + trainer = CARLTrainer( + TrainingConfig( + run_name="privacy", + method="grpo", + dataset_repo="fixture/data", + base_model="fixture", + output_repo="fixture/output", + cascade={"carl_start": 2, "gate_mode": "metric"}, + ), + task_reward_funcs=[lambda **kwargs: [100.0, 100.0]], + task_reward_weights=[50.0], + ) + functions = trainer._build_rewards(SimpleNamespace(), SimpleNamespace(vocab_size=16)) + if stage == "B": + trainer._cascade_manager._step = 20 + assert trainer._cascade_manager.get_stage() == stage + completions = ["draft-123 leaked", "answer"] + kwargs = {"private_keys": [["draft-123"], ["draft-123"]]} + values = [function(completions=completions, **kwargs) for function in functions] + assert sum(row[0] for row in values) == -3.0 + assert sum(row[1] for row in values) > 0.0 + + +def test_numeric_identifiers_and_cyclic_payloads_fail(): + gate = PrivacyBoundaryGate() + assert gate.terminal_reward([{"phase": "public", "parent": 123}], {"123"}) == -3.0 + payload = {"phase": "public"} + payload["cycle"] = payload + assert gate.terminal_reward([payload], {"draft"}) == -3.0 diff --git a/tests/test_project_inspection.py b/tests/test_project_inspection.py new file mode 100644 index 0000000..045b529 --- /dev/null +++ b/tests/test_project_inspection.py @@ -0,0 +1,28 @@ +"""Project inspection accepts current and legacy adapter field names.""" + +from __future__ import annotations + +import pytest +from typer.testing import CliRunner + +from carl_studio.cli.project_data import project_app +from carl_studio.project import load_project + + +@pytest.mark.parametrize("field", ["adapter", "backend"]) +def test_project_show_resolves_adapter_schema(tmp_path, field): + config = tmp_path / "project.yaml" + config.write_text(f"{field}: unsloth\ncompute_target: l4x1\ncompute_backend: hf_jobs\n") + assert load_project(config).adapter == "unsloth" + result = CliRunner().invoke(project_app, ["show", "--config", str(config)]) + assert result.exit_code == 0, result.output + assert "unsloth" in result.output + assert "hf_jobs" in result.output + + +def test_current_adapter_wins_over_legacy_backend(tmp_path): + config = tmp_path / "project.yaml" + config.write_text("adapter: trl\nbackend: unsloth\n") + project = load_project(config) + assert project.adapter == "trl" + assert "backend" not in project.model_dump() diff --git a/uv.lock b/uv.lock index ac462f6..85d130f 100644 --- a/uv.lock +++ b/uv.lock @@ -889,7 +889,7 @@ requires-dist = [ [[package]] name = "carl-studio" -version = "0.21.0" +version = "0.21.1" source = { editable = "." } dependencies = [ { name = "anyio" }, From 1424f2ea6bd7421356ffe85894f3ac3d7604c0eb Mon Sep 17 00:00:00 2001 From: Tej Desai <29216465+wheattoast11@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:10:03 -0500 Subject: [PATCH 3/4] fix(plugin): preserve canonical Git marketplace during updates --- CHANGELOG.md | 1 + docs/release-0.21.1.md | 14 ++++++++++++- src/carl_studio/cli/plugin.py | 7 ++++++- src/carl_studio/plugin.py | 33 +++++++++++++++++++++++++++---- tests/test_carl_plugin.py | 37 +++++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9cc1ee..2a86a8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Hash checkpoint files with bounded concurrency and cache SHA-256 digests by file identity and change timestamps. Execution still validates full source bytes. - Apply terminal privacy penalties outside cascade masks and reward weights; reject public trajectories exposing private draft identifiers. - Remove retired installer-owned skill references during plugin updates and preserve edited files. +- Preserve an existing canonical Git-backed Codex marketplace during plugin updates instead of conflicting with its registration. ## [0.21.0] - 2026-10-08 diff --git a/docs/release-0.21.1.md b/docs/release-0.21.1.md index 84b22ac..ca6faf1 100644 --- a/docs/release-0.21.1.md +++ b/docs/release-0.21.1.md @@ -1,5 +1,5 @@ --- -last_updated: 2026-10-08 +last_updated: 2026-10-09 author: CARL project applies_to: carl-studio 0.21.1 --- @@ -63,3 +63,15 @@ submitted. Native witnesses use synthetic localhost providers. The 55 GB checkpoint from the integration report was not benchmarked. Cold checkpoint binding still requires reading its bytes; repeated preparation uses the cache. GitHub and PyPI publication are separate from these local checks. + +## Native installation continuation (October 9) + +The operator's Codex CARL marketplace was registered from the canonical Git +repository. Updating it as a local marketplace failed with a source collision. +The installer now preserves that Git registration and upgrades only the named +CARL marketplace. Foreign Git sources remain refused. Update errors use the +normal CLI error surface instead of a traceback. + +Plugin regression after this fix: **19 passed**; Ruff and strict Pyright for +plugin installation and its CLI: **passed**. Installed CARL doctor reported +all three hosts present, no source drift, and `healthy: true`. diff --git a/src/carl_studio/cli/plugin.py b/src/carl_studio/cli/plugin.py index 69bf7aa..6f80bdc 100644 --- a/src/carl_studio/cli/plugin.py +++ b/src/carl_studio/cli/plugin.py @@ -38,7 +38,12 @@ def install(host: Annotated[list[str] | None, typer.Option("--host")] = None) -> def update() -> None: """Rebuild the owned plugin projections from current source.""" installer = _installer() - typer.echo(json.dumps(installer.install(installer.installed_hosts() or None), indent=2)) + try: + result = installer.install(installer.installed_hosts() or None) + except CARLError as exc: + get_console().error(f"{exc.code}: {exc}") + raise typer.Exit(1) from exc + typer.echo(json.dumps(result, indent=2)) @plugin_app.command("doctor") diff --git a/src/carl_studio/plugin.py b/src/carl_studio/plugin.py index 0e940b7..a3ce8fe 100644 --- a/src/carl_studio/plugin.py +++ b/src/carl_studio/plugin.py @@ -9,6 +9,7 @@ import subprocess import sys import tempfile +import tomllib from pathlib import Path from typing import Any @@ -270,9 +271,7 @@ def install(self, hosts: list[str] | None = None, *, native: bool = True) -> dic if native: for host in selected: if host == "codex": - self._run( - [host, "plugin", "marketplace", "add", str(self.state / "marketplace")] - ) + self._register_codex_marketplace() self._run([host, "plugin", "add", "carl@carl-local"]) elif host == "claude": self._run( @@ -285,10 +284,36 @@ def install(self, hosts: list[str] | None = None, *, native: bool = True) -> dic _write_json(self.marker, record) return self.doctor() - def _run(self, argv: list[str]) -> None: + def _register_codex_marketplace(self) -> None: + listing = json.loads(self._run(["codex", "plugin", "marketplace", "list", "--json"])) + entries: list[dict[str, Any]] = listing.get("marketplaces", []) + existing = next((row for row in entries if row.get("name") == "carl-local"), None) + local = self.state / "marketplace" + if existing is None: + self._run(["codex", "plugin", "marketplace", "add", str(local)]) + return + source: dict[str, Any] = existing.get("marketplaceSource", {}) + location = source.get("source", existing.get("root", "")) + if ( + source.get("sourceType", "local") == "local" + and Path(location).resolve() == local.resolve() + ): + return + repository = tomllib.loads((self.root / "pyproject.toml").read_text())["project"]["urls"][ + "Repository" + ] + if source.get("sourceType") == "git" and location.removesuffix( + ".git" + ) == repository.removesuffix(".git"): + self._run(["codex", "plugin", "marketplace", "upgrade", "carl-local"]) + return + raise CARLError("Foreign Codex carl marketplace", code="carl.plugin.ownership") + + def _run(self, argv: list[str]) -> bytes: result = subprocess.run(argv, capture_output=True, timeout=60, check=False) if result.returncode: raise CARLError(f"{argv[0]} plugin command failed", code="carl.plugin.native") + return result.stdout def _opencode(self, record: dict[str, Any]) -> None: path = self.home / ".config/opencode/opencode.json" diff --git a/tests/test_carl_plugin.py b/tests/test_carl_plugin.py index 687c4a6..d863e35 100644 --- a/tests/test_carl_plugin.py +++ b/tests/test_carl_plugin.py @@ -180,3 +180,40 @@ def test_update_removes_retired_references_and_preserves_edits(tmp_path, edited, assert installer.install(["codex"], native=False)["healthy"] assert not alias.exists() assert not snapshot.exists() + + +@pytest.mark.parametrize("kind", ["absent", "local", "git", "foreign"]) +def test_codex_marketplace_registration_preserves_source(tmp_path, monkeypatch, kind): + installer = PluginInstaller(ROOT, home=tmp_path) + source = {"sourceType": "local", "source": str(installer.state / "marketplace")} + if kind == "git": + source = {"sourceType": "git", "source": "https://github.com/wheattoast11/carl.git"} + elif kind == "foreign": + source = {"sourceType": "git", "source": "https://example.com/foreign.git"} + entries = [] if kind == "absent" else [{"name": "carl-local", "marketplaceSource": source}] + calls = [] + + def run(argv): + calls.append(argv) + return json.dumps({"marketplaces": entries}).encode() + + monkeypatch.setattr(installer, "_run", run) + if kind == "foreign": + with pytest.raises(CARLError, match="Foreign Codex"): + installer._register_codex_marketplace() + assert len(calls) == 1 + else: + installer._register_codex_marketplace() + if kind == "absent": + assert calls[-1] == [ + "codex", + "plugin", + "marketplace", + "add", + str(installer.state / "marketplace"), + ] + elif kind == "git": + assert calls[-1] == ["codex", "plugin", "marketplace", "upgrade", "carl-local"] + else: + assert len(calls) == 1 + assert not any("remove" in call for call in calls) From fb508ed406ae0d0bb44b7f12dc84955f18cdf705 Mon Sep 17 00:00:00 2001 From: Tej Desai <29216465+wheattoast11@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:13:19 -0500 Subject: [PATCH 4/4] fix(plugin): format source discovery errors during updates --- docs/release-0.21.1.md | 2 +- src/carl_studio/cli/plugin.py | 2 +- tests/test_carl_plugin.py | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/release-0.21.1.md b/docs/release-0.21.1.md index ca6faf1..fc00a23 100644 --- a/docs/release-0.21.1.md +++ b/docs/release-0.21.1.md @@ -72,6 +72,6 @@ The installer now preserves that Git registration and upgrades only the named CARL marketplace. Foreign Git sources remain refused. Update errors use the normal CLI error surface instead of a traceback. -Plugin regression after this fix: **19 passed**; Ruff and strict Pyright for +Plugin regression after this fix: **20 passed**; Ruff and strict Pyright for plugin installation and its CLI: **passed**. Installed CARL doctor reported all three hosts present, no source drift, and `healthy: true`. diff --git a/src/carl_studio/cli/plugin.py b/src/carl_studio/cli/plugin.py index 6f80bdc..8607efe 100644 --- a/src/carl_studio/cli/plugin.py +++ b/src/carl_studio/cli/plugin.py @@ -37,8 +37,8 @@ def install(host: Annotated[list[str] | None, typer.Option("--host")] = None) -> @plugin_app.command("update") def update() -> None: """Rebuild the owned plugin projections from current source.""" - installer = _installer() try: + installer = _installer() result = installer.install(installer.installed_hosts() or None) except CARLError as exc: get_console().error(f"{exc.code}: {exc}") diff --git a/tests/test_carl_plugin.py b/tests/test_carl_plugin.py index d863e35..084b0db 100644 --- a/tests/test_carl_plugin.py +++ b/tests/test_carl_plugin.py @@ -217,3 +217,21 @@ def run(argv): else: assert len(calls) == 1 assert not any("remove" in call for call in calls) + + +def test_plugin_update_formats_source_errors(monkeypatch): + import importlib + + from typer.testing import CliRunner + + module = importlib.import_module("carl_studio.cli.plugin") + + def unavailable(): + raise CARLError("Run from a CARL checkout", code="carl.plugin.source") + + monkeypatch.setattr(module, "_installer", unavailable) + result = CliRunner().invoke(module.plugin_app, ["update"]) + assert result.exit_code == 1 + assert "carl.plugin.source" in result.output + assert "Traceback" not in result.output + assert isinstance(result.exception, SystemExit)