diff --git a/.agents/skills/hf-cli/.hf-skill-manifest.json b/.agents/skills/hf-cli/.hf-skill-manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..993fc3f78039ee644f3fd466308ff42ecffcae2a --- /dev/null +++ b/.agents/skills/hf-cli/.hf-skill-manifest.json @@ -0,0 +1,4 @@ +{ + "installed_revision": "7bf59b7f85b79c74207b10d5e425934514e8b089", + "schema_version": 1 +} diff --git a/.agents/skills/hf-cli/SKILL.md b/.agents/skills/hf-cli/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..e927eca6cb3ff84b6d86558366b2864c05dbd999 --- /dev/null +++ b/.agents/skills/hf-cli/SKILL.md @@ -0,0 +1,218 @@ +--- +name: hf-cli +description: "Hugging Face Hub CLI (`hf`) for downloading, uploading, and managing models, datasets, spaces, buckets, repos, papers, jobs, and more on the Hugging Face Hub. Use when: handling authentication; managing local cache; managing Hugging Face Buckets; running or scheduling jobs on Hugging Face infrastructure; managing Hugging Face repos; discussions and pull requests; browsing models, datasets and spaces; reading, searching, or browsing academic papers; managing collections; querying datasets; configuring spaces; setting up webhooks; or deploying and managing HF Inference Endpoints. Make sure to use this skill whenever the user mentions 'hf', 'huggingface', 'Hugging Face', 'huggingface-cli', or 'hugging face cli', or wants to do anything related to the Hugging Face ecosystem and to AI and ML in general. Also use for cloud storage needs like training checkpoints, data pipelines, or agent traces. Use even if the user doesn't explicitly ask for a CLI command. Replaces the deprecated `huggingface-cli`." +--- + +Install: `curl -LsSf https://hf.co/cli/install.sh | bash -s`. + +The Hugging Face Hub CLI tool `hf` is available. IMPORTANT: The `hf` command replaces the deprecated `huggingface-cli` command. + +Use `hf --help` to view available functions. Note that auth commands are now all under `hf auth` e.g. `hf auth whoami`. + +Generated with `huggingface_hub v1.19.0`. Run `hf skills add --force` to regenerate. + +## Commands + +- `hf cp SRC` — Copy files between local paths, repositories, and buckets. `[--format [auto|human|agent|json|quiet]]` +- `hf download REPO_ID` — Download files from the Hub. `[--type [model|dataset|space] --revision TEXT --include TEXT --exclude TEXT --cache-dir TEXT --local-dir TEXT --force-download --dry-run --max-workers INTEGER --format [auto|human|agent|json|quiet]]` +- `hf env` — Print information about the environment. `[--format [auto|human|agent|json|quiet]]` +- `hf sync` — Sync files between local directory and a bucket. `[--delete --ignore-times --ignore-sizes --plan TEXT --apply TEXT --dry-run --include TEXT --exclude TEXT --filter-from TEXT --existing --ignore-existing --verbose --format [auto|human|agent|json|quiet]]` +- `hf update` — Update the `hf` CLI to the latest version. `[--format [auto|human|agent|json|quiet]]` +- `hf upload REPO_ID` — Upload a file or a folder to the Hub. Recommended for single-commit uploads. `[--type [model|dataset|space] --revision TEXT --private --include TEXT --exclude TEXT --delete TEXT --commit-message TEXT --commit-description TEXT --create-pr --every FLOAT --format [auto|human|agent|json|quiet]]` +- `hf upload-large-folder REPO_ID LOCAL_PATH` — Upload a large folder to the Hub. Recommended for resumable uploads. `[--type [model|dataset|space] --revision TEXT --private --include TEXT --exclude TEXT --num-workers INTEGER --no-report --no-bars --format [auto|human|agent|json|quiet]]` +- `hf version` — Print information about the hf version. `[--format [auto|human|agent|json|quiet]]` + +### `hf auth` — Manage authentication (login, logout, etc.). + +- `hf auth list` — List all stored access tokens. `[--format [auto|human|agent|json|quiet]]` +- `hf auth login` — Login using a token from huggingface.co/settings/tokens. `[--add-to-git-credential --force --format [auto|human|agent|json|quiet]]` +- `hf auth logout` — Logout from a specific token. `[--token-name TEXT --format [auto|human|agent|json|quiet]]` +- `hf auth switch` — Switch between access tokens. `[--token-name TEXT --add-to-git-credential --format [auto|human|agent|json|quiet]]` +- `hf auth token` — Print the current access token to stdout. `[--format [auto|human|agent|json|quiet]]` +- `hf auth whoami` — Find out which huggingface.co account you are logged in as. `[--format [auto|human|agent|json|quiet]]` + +### `hf buckets` — Commands to interact with buckets. + +- `hf buckets cp SRC` — Copy files between local paths, repositories, and buckets. `[--format [auto|human|agent|json|quiet]]` +- `hf buckets create BUCKET_ID` — Create a new bucket. `[--private --region [us|eu] --exist-ok --format [auto|human|agent|json|quiet]]` +- `hf buckets delete BUCKET_ID` — Delete a bucket. `[--yes --missing-ok --format [auto|human|agent|json|quiet]]` +- `hf buckets info BUCKET_ID` — Get info about a bucket. `[--format [auto|human|agent|json|quiet]]` +- `hf buckets list` — List buckets or files in a bucket. `[--human-readable --tree --recursive --search TEXT --format [auto|human|agent|json|quiet]]` +- `hf buckets move FROM_ID TO_ID` — Move (rename) a bucket to a new name or namespace. `[--format [auto|human|agent|json|quiet]]` +- `hf buckets remove ARGUMENT` — Remove files from a bucket. `[--recursive --yes --dry-run --include TEXT --exclude TEXT --format [auto|human|agent|json|quiet]]` +- `hf buckets sync` — Sync files between local directory and a bucket. `[--delete --ignore-times --ignore-sizes --plan TEXT --apply TEXT --dry-run --include TEXT --exclude TEXT --filter-from TEXT --existing --ignore-existing --verbose --format [auto|human|agent|json|quiet]]` + +### `hf cache` — Manage local cache directory. + +- `hf cache list` — List cached repositories or revisions. `[--cache-dir TEXT --revisions --filter TEXT --sort [accessed|accessed:asc|accessed:desc|modified|modified:asc|modified:desc|name|name:asc|name:desc|size|size:asc|size:desc] --limit INTEGER --format [auto|human|agent|json|quiet]]` +- `hf cache prune` — Remove detached revisions from the cache. `[--cache-dir TEXT --yes --dry-run --format [auto|human|agent|json|quiet]]` +- `hf cache rm TARGETS` — Remove cached repositories or revisions. `[--cache-dir TEXT --yes --dry-run --format [auto|human|agent|json|quiet]]` +- `hf cache verify REPO_ID` — Verify checksums for a single repo revision from cache or a local directory. `[--type [model|dataset|space] --revision TEXT --cache-dir TEXT --local-dir TEXT --fail-on-missing-files --fail-on-extra-files --format [auto|human|agent|json|quiet]]` + +### `hf collections` — Interact with collections on the Hub. + +- `hf collections add-item COLLECTION_SLUG ITEM_ID ITEM_TYPE` — Add an item to a collection. `[--note TEXT --exists-ok --format [auto|human|agent|json|quiet]]` +- `hf collections create TITLE` — Create a new collection on the Hub. `[--namespace TEXT --description TEXT --private --exists-ok --format [auto|human|agent|json|quiet]]` +- `hf collections delete COLLECTION_SLUG` — Delete a collection from the Hub. `[--missing-ok --format [auto|human|agent|json|quiet]]` +- `hf collections delete-item COLLECTION_SLUG ITEM_OBJECT_ID` — Delete an item from a collection. `[--missing-ok --format [auto|human|agent|json|quiet]]` +- `hf collections info COLLECTION_SLUG` — Get info about a collection on the Hub. `[--format [auto|human|agent|json|quiet]]` +- `hf collections list` — List collections on the Hub. `[--owner TEXT --item TEXT --sort [lastModified|trending|upvotes] --limit INTEGER --format [auto|human|agent|json|quiet]]` +- `hf collections update COLLECTION_SLUG` — Update a collection's metadata on the Hub. `[--title TEXT --description TEXT --position INTEGER --private --theme TEXT --format [auto|human|agent|json|quiet]]` +- `hf collections update-item COLLECTION_SLUG ITEM_OBJECT_ID` — Update an item in a collection. `[--note TEXT --position INTEGER --format [auto|human|agent|json|quiet]]` + +### `hf datasets` — Interact with datasets on the Hub. + +- `hf datasets card DATASET_ID` — Get the dataset card (README) for a dataset on the Hub. `[--metadata --text --format [auto|human|agent|json|quiet]]` +- `hf datasets info DATASET_ID` — Get info about a dataset on the Hub. `[--revision TEXT --expand TEXT --format [auto|human|agent|json|quiet]]` +- `hf datasets leaderboard DATASET_ID` — List model scores from a dataset leaderboard. This command helps find the best models for a task or compare models by benchmark scores. Use 'hf datasets ls --filter benchmark:official' to list available leaderboards. `[--limit INTEGER --format [auto|human|agent|json|quiet]]` +- `hf datasets list` — List datasets on the Hub, or files in a dataset repo. `[--search TEXT --author TEXT --filter TEXT --sort [created_at|downloads|last_modified|likes|trending_score] --limit INTEGER --expand TEXT --human-readable --tree --recursive --revision TEXT --format [auto|human|agent|json|quiet]]` +- `hf datasets parquet DATASET_ID` — List parquet file URLs available for a dataset. `[--subset TEXT --split TEXT --format [auto|human|agent|json|quiet]]` +- `hf datasets sql SQL` — Execute a raw SQL query with DuckDB against dataset parquet URLs. `[--format [auto|human|agent|json|quiet]]` + +### `hf discussions` — Manage discussions and pull requests on the Hub. + +- `hf discussions close REPO_ID NUM` — Close a discussion or pull request. `[--comment TEXT --yes --type [model|dataset|space] --format [auto|human|agent|json|quiet]]` +- `hf discussions comment REPO_ID NUM` — Comment on a discussion or pull request. `[--body TEXT --body-file PATH --type [model|dataset|space] --format [auto|human|agent|json|quiet]]` +- `hf discussions create REPO_ID --title TEXT` — Create a new discussion or pull request on a repo. `[--body TEXT --body-file PATH --pull-request --type [model|dataset|space] --format [auto|human|agent|json|quiet]]` +- `hf discussions diff REPO_ID NUM` — Show the diff of a pull request. `[--type [model|dataset|space] --format [auto|human|agent|json|quiet]]` +- `hf discussions info REPO_ID NUM` — Get info about a discussion or pull request. `[--type [model|dataset|space] --format [auto|human|agent|json|quiet]]` +- `hf discussions list REPO_ID` — List discussions and pull requests on a repo. `[--status [open|closed|merged|draft|all] --kind [all|discussion|pull_request] --author TEXT --limit INTEGER --type [model|dataset|space] --format [auto|human|agent|json|quiet]]` +- `hf discussions merge REPO_ID NUM` — Merge a pull request. `[--comment TEXT --yes --type [model|dataset|space] --format [auto|human|agent|json|quiet]]` +- `hf discussions rename REPO_ID NUM NEW_TITLE` — Rename a discussion or pull request. `[--type [model|dataset|space] --format [auto|human|agent|json|quiet]]` +- `hf discussions reopen REPO_ID NUM` — Reopen a closed discussion or pull request. `[--comment TEXT --yes --type [model|dataset|space] --format [auto|human|agent|json|quiet]]` + +### `hf endpoints` — Manage Hugging Face Inference Endpoints. + +- `hf endpoints catalog deploy --repo TEXT` — Deploy an Inference Endpoint from the Model Catalog. `[--name TEXT --accelerator TEXT --namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf endpoints catalog list` — List available Catalog models. `[--format [auto|human|agent|json|quiet]]` +- `hf endpoints delete NAME` — Delete an Inference Endpoint permanently. `[--namespace TEXT --yes --format [auto|human|agent|json|quiet]]` +- `hf endpoints deploy NAME --repo TEXT --framework TEXT --accelerator TEXT --instance-size TEXT --instance-type TEXT --region TEXT --vendor TEXT` — Deploy an Inference Endpoint from a Hub repository. `[--namespace TEXT --task TEXT --min-replica INTEGER --max-replica INTEGER --scale-to-zero-timeout INTEGER --scaling-metric [pendingRequests|hardwareUsage] --scaling-threshold FLOAT --format [auto|human|agent|json|quiet]]` +- `hf endpoints describe NAME` — Get information about an existing endpoint. `[--namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf endpoints list` — Lists all Inference Endpoints for the given namespace. `[--namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf endpoints pause NAME` — Pause an Inference Endpoint. `[--namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf endpoints resume NAME` — Resume an Inference Endpoint. `[--namespace TEXT --fail-if-already-running --format [auto|human|agent|json|quiet]]` +- `hf endpoints scale-to-zero NAME` — Scale an Inference Endpoint to zero. `[--namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf endpoints update NAME` — Update an existing endpoint. `[--namespace TEXT --repo TEXT --accelerator TEXT --instance-size TEXT --instance-type TEXT --framework TEXT --revision TEXT --task TEXT --min-replica INTEGER --max-replica INTEGER --scale-to-zero-timeout INTEGER --scaling-metric [pendingRequests|hardwareUsage] --scaling-threshold FLOAT --format [auto|human|agent|json|quiet]]` + +### `hf extensions` — Manage hf CLI extensions. + +- `hf extensions exec NAME` — Execute an installed extension. +- `hf extensions install REPO_ID` — Install an extension from a public GitHub repository. `[--force --format [auto|human|agent|json|quiet]]` +- `hf extensions list` — List installed extension commands. `[--format [auto|human|agent|json|quiet]]` +- `hf extensions remove NAME` — Remove an installed extension. `[--format [auto|human|agent|json|quiet]]` +- `hf extensions search` — Search extensions available on GitHub (tagged with 'hf-extension' topic). `[--format [auto|human|agent|json|quiet]]` + +### `hf jobs` — Run and manage Jobs on the Hub. + +- `hf jobs cancel JOB_ID` — Cancel a Job `[--namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf jobs hardware` — List available hardware options for Jobs `[--format [auto|human|agent|json|quiet]]` +- `hf jobs inspect JOB_IDS` — Display detailed information on one or more Jobs `[--namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf jobs labels JOB_ID` — Update labels on a Job. Replaces all existing labels. `[--label TEXT --clear --namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf jobs logs JOB_ID` — Fetch the logs of a Job. `[--follow --tail INTEGER --namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf jobs ps` — List Jobs. `[--all --namespace TEXT --filter TEXT --format [auto|human|agent|json|quiet]]` +- `hf jobs run IMAGE COMMAND` — Run a Job. `[--env TEXT --secrets TEXT --label TEXT --volume TEXT --env-file TEXT --secrets-file TEXT --flavor [cpu-basic|cpu-upgrade|cpu-performance|cpu-xl|t4-small|t4-medium|l4x1|l4x4|l40sx1|l40sx4|l40sx8|a10g-small|a10g-large|a10g-largex2|a10g-largex4|a100-large|a100x4|a100x8|h200|h200x2|h200x4|h200x8|rtx-pro-6000|rtx-pro-6000x2|rtx-pro-6000x4|rtx-pro-6000x8] --timeout TEXT --detach --expose INTEGER --namespace TEXT]` +- `hf jobs scheduled delete SCHEDULED_JOB_ID` — Delete a scheduled Job. `[--namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf jobs scheduled inspect SCHEDULED_JOB_IDS` — Display detailed information on one or more scheduled Jobs `[--namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf jobs scheduled labels SCHEDULED_JOB_ID` — Update labels on a scheduled Job. Replaces all existing labels. `[--label TEXT --clear --namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf jobs scheduled ps` — List scheduled Jobs `[--all --namespace TEXT --filter TEXT --format [auto|human|agent|json|quiet]]` +- `hf jobs scheduled resume SCHEDULED_JOB_ID` — Resume (unpause) a scheduled Job. `[--namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf jobs scheduled run SCHEDULE IMAGE COMMAND` — Schedule a Job. `[--suspend --concurrency --env TEXT --secrets TEXT --label TEXT --volume TEXT --env-file TEXT --secrets-file TEXT --flavor [cpu-basic|cpu-upgrade|cpu-performance|cpu-xl|t4-small|t4-medium|l4x1|l4x4|l40sx1|l40sx4|l40sx8|a10g-small|a10g-large|a10g-largex2|a10g-largex4|a100-large|a100x4|a100x8|h200|h200x2|h200x4|h200x8|rtx-pro-6000|rtx-pro-6000x2|rtx-pro-6000x4|rtx-pro-6000x8] --timeout TEXT --expose INTEGER --namespace TEXT]` +- `hf jobs scheduled suspend SCHEDULED_JOB_ID` — Suspend (pause) a scheduled Job. `[--namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf jobs scheduled uv run SCHEDULE SCRIPT` — Run a UV script (local file or URL) on HF infrastructure `[--suspend --concurrency --image TEXT --flavor [cpu-basic|cpu-upgrade|cpu-performance|cpu-xl|t4-small|t4-medium|l4x1|l4x4|l40sx1|l40sx4|l40sx8|a10g-small|a10g-large|a10g-largex2|a10g-largex4|a100-large|a100x4|a100x8|h200|h200x2|h200x4|h200x8|rtx-pro-6000|rtx-pro-6000x2|rtx-pro-6000x4|rtx-pro-6000x8] --env TEXT --secrets TEXT --label TEXT --volume TEXT --env-file TEXT --secrets-file TEXT --timeout TEXT --expose INTEGER --namespace TEXT --with TEXT --python TEXT]` +- `hf jobs stats` — Fetch the resource usage statistics and metrics of Jobs `[--namespace TEXT --format [auto|human|agent|json|quiet]]` +- `hf jobs uv run SCRIPT` — Run a UV script (local file or URL) on HF infrastructure `[--image TEXT --flavor [cpu-basic|cpu-upgrade|cpu-performance|cpu-xl|t4-small|t4-medium|l4x1|l4x4|l40sx1|l40sx4|l40sx8|a10g-small|a10g-large|a10g-largex2|a10g-largex4|a100-large|a100x4|a100x8|h200|h200x2|h200x4|h200x8|rtx-pro-6000|rtx-pro-6000x2|rtx-pro-6000x4|rtx-pro-6000x8] --env TEXT --secrets TEXT --label TEXT --volume TEXT --env-file TEXT --secrets-file TEXT --timeout TEXT --detach --expose INTEGER --namespace TEXT --with TEXT --python TEXT]` + +### `hf models` — Interact with models on the Hub. + +- `hf models card MODEL_ID` — Get the model card (README) for a model on the Hub. `[--metadata --text --format [auto|human|agent|json|quiet]]` +- `hf models info MODEL_ID` — Get info about a model on the Hub. `[--revision TEXT --expand TEXT --format [auto|human|agent|json|quiet]]` +- `hf models list` — List models on the Hub, or files in a model repo. `[--search TEXT --author TEXT --filter TEXT --num-parameters TEXT --sort [created_at|downloads|last_modified|likes|trending_score] --limit INTEGER --expand TEXT --human-readable --tree --recursive --revision TEXT --format [auto|human|agent|json|quiet]]` + +### `hf papers` — Interact with papers on the Hub. + +- `hf papers info PAPER_ID` — Get info about a paper on the Hub. `[--format [auto|human|agent|json|quiet]]` +- `hf papers list` — List daily papers on the Hub. `[--date TEXT --week TEXT --month TEXT --submitter TEXT --sort [publishedAt|trending] --limit INTEGER --format [auto|human|agent|json|quiet]]` +- `hf papers read PAPER_ID` — Read a paper as markdown. `[--format [auto|human|agent|json|quiet]]` +- `hf papers search QUERY` — Search papers on the Hub. `[--limit INTEGER --format [auto|human|agent|json|quiet]]` + +### `hf repos` — Manage repos on the Hub. + +- `hf repos branch create REPO_ID BRANCH` — Create a new branch for a repo on the Hub. `[--revision TEXT --type [model|dataset|space] --exist-ok --format [auto|human|agent|json|quiet]]` +- `hf repos branch delete REPO_ID BRANCH` — Delete a branch from a repo on the Hub. `[--type [model|dataset|space] --format [auto|human|agent|json|quiet]]` +- `hf repos cp SRC` — Copy files between local paths, repositories, and buckets. `[--format [auto|human|agent|json|quiet]]` +- `hf repos create REPO_ID` — Create a new repo on the Hub. `[--type [model|dataset|space] --space-sdk TEXT --private --public --protected --exist-ok --resource-group-id TEXT --region [us|eu] --flavor [cpu-basic|cpu-upgrade|zero-a10g|t4-small|t4-medium|l4x1|l4x4|l40sx1|l40sx4|l40sx8|a10g-small|a10g-large|a10g-largex2|a10g-largex4|a100-large|a100x4|a100x8] --storage [small|medium|large] --sleep-time INTEGER --secrets TEXT --secrets-file TEXT --env TEXT --env-file TEXT --volume TEXT --format [auto|human|agent|json|quiet]]` +- `hf repos delete REPO_ID` — Delete a repo from the Hub. This is an irreversible operation. `[--type [model|dataset|space] --missing-ok --yes --format [auto|human|agent|json|quiet]]` +- `hf repos delete-files REPO_ID PATTERNS` — Delete files from a repo on the Hub. `[--type [model|dataset|space] --revision TEXT --commit-message TEXT --commit-description TEXT --create-pr --format [auto|human|agent|json|quiet]]` +- `hf repos duplicate FROM_ID` — Duplicate a repo on the Hub (model, dataset, or Space). `[--type [model|dataset|space] --private --public --protected --exist-ok --flavor [cpu-basic|cpu-upgrade|zero-a10g|t4-small|t4-medium|l4x1|l4x4|l40sx1|l40sx4|l40sx8|a10g-small|a10g-large|a10g-largex2|a10g-largex4|a100-large|a100x4|a100x8] --storage [small|medium|large] --sleep-time INTEGER --secrets TEXT --secrets-file TEXT --env TEXT --env-file TEXT --volume TEXT --format [auto|human|agent|json|quiet]]` +- `hf repos list` — List all repos (models, datasets, spaces, buckets) with storage info. `[--namespace TEXT --type [model|dataset|space|bucket] --search TEXT --limit INTEGER --explore --format [auto|human|agent|json|quiet]]` +- `hf repos move FROM_ID TO_ID` — Move a repository from a namespace to another namespace. `[--type [model|dataset|space] --format [auto|human|agent|json|quiet]]` +- `hf repos settings REPO_ID` — Update the settings of a repository. `[--gated [auto|manual|false] --private --public --protected --type [model|dataset|space] --format [auto|human|agent|json|quiet]]` +- `hf repos tag create REPO_ID TAG` — Create a tag for a repo. `[--message TEXT --revision TEXT --type [model|dataset|space] --format [auto|human|agent|json|quiet]]` +- `hf repos tag delete REPO_ID TAG` — Delete a tag for a repo. `[--yes --type [model|dataset|space] --format [auto|human|agent|json|quiet]]` +- `hf repos tag list REPO_ID` — List tags for a repo. `[--type [model|dataset|space] --format [auto|human|agent|json|quiet]]` + +### `hf skills` — Manage skills for AI assistants. + +- `hf skills add` — Download a Hugging Face skill and install it for an AI assistant. `[--claude --global --dest PATH --force --format [auto|human|agent|json|quiet]]` +- `hf skills list` — List available skills from the Hugging Face marketplace. `[--format [auto|human|agent|json|quiet]]` +- `hf skills preview` — Print the generated `hf-cli` SKILL.md to stdout. `[--format [auto|human|agent|json|quiet]]` +- `hf skills update` — Update installed Hugging Face marketplace skills. `[--claude --global --dest PATH --format [auto|human|agent|json|quiet]]` + +### `hf spaces` — Interact with spaces on the Hub. + +- `hf spaces card SPACE_ID` — Get the Space card (README) for a Space on the Hub. `[--metadata --text --format [auto|human|agent|json|quiet]]` +- `hf spaces dev-mode SPACE_ID` — Enable or disable dev mode on a Space. `[--stop --format [auto|human|agent|json|quiet]]` +- `hf spaces hardware` — List available hardware options for Spaces. `[--format [auto|human|agent|json|quiet]]` +- `hf spaces hot-reload SPACE_ID` — Hot-reload any Python file of a Space without a full rebuild + restart. `[--local-file PATH --skip-checks --skip-summary --format [auto|human|agent|json|quiet]]` +- `hf spaces info SPACE_ID` — Get info about a space on the Hub. `[--revision TEXT --expand TEXT --format [auto|human|agent|json|quiet]]` +- `hf spaces list` — List spaces on the Hub, or files in a space repo. `[--search TEXT --author TEXT --filter TEXT --sort [created_at|last_modified|likes|trending_score] --limit INTEGER --expand TEXT --human-readable --tree --recursive --revision TEXT --format [auto|human|agent|json|quiet]]` +- `hf spaces logs SPACE_ID` — Fetch the run or build logs of a Space. `[--build --follow --tail INTEGER --format [auto|human|agent|json|quiet]]` +- `hf spaces pause SPACE_ID` — Pause a Space. `[--format [auto|human|agent|json|quiet]]` +- `hf spaces restart SPACE_ID` — Restart a Space. `[--factory-reboot --format [auto|human|agent|json|quiet]]` +- `hf spaces search QUERY` — Search spaces on the Hub using semantic search. `[--filter TEXT --sdk TEXT --include-non-running --description --limit INTEGER --format [auto|human|agent|json|quiet]]` +- `hf spaces secrets add SPACE_ID` — Add or update secrets for a Space. `[--secrets TEXT --secrets-file TEXT --format [auto|human|agent|json|quiet]]` +- `hf spaces secrets delete SPACE_ID KEY` — Remove a secret from a Space. `[--yes --format [auto|human|agent|json|quiet]]` +- `hf spaces secrets list SPACE_ID` — List secrets for a Space. Secret values are write-only and not returned. `[--format [auto|human|agent|json|quiet]]` +- `hf spaces settings SPACE_ID` — Update the settings of a Space. `[--sleep-time INTEGER --hardware [cpu-basic|cpu-upgrade|zero-a10g|t4-small|t4-medium|l4x1|l4x4|l40sx1|l40sx4|l40sx8|a10g-small|a10g-large|a10g-largex2|a10g-largex4|a100-large|a100x4|a100x8] --format [auto|human|agent|json|quiet]]` +- `hf spaces ssh SPACE_ID` — SSH into a Space's Dev Mode container. `[--identity-file PATH --dry-run --auto --format [auto|human|agent|json|quiet]]` +- `hf spaces variables add SPACE_ID` — Add or update environment variables for a Space. `[--env TEXT --env-file TEXT --format [auto|human|agent|json|quiet]]` +- `hf spaces variables delete SPACE_ID KEY` — Remove an environment variable from a Space. `[--yes --format [auto|human|agent|json|quiet]]` +- `hf spaces variables list SPACE_ID` — List environment variables for a Space. `[--format [auto|human|agent|json|quiet]]` +- `hf spaces volumes delete SPACE_ID` — Remove all volumes from a Space. `[--yes --format [auto|human|agent|json|quiet]]` +- `hf spaces volumes list SPACE_ID` — List volumes mounted in a Space. `[--format [auto|human|agent|json|quiet]]` +- `hf spaces volumes set SPACE_ID` — Set (replace) volumes for a Space. `[--volume TEXT --format [auto|human|agent|json|quiet]]` + +### `hf webhooks` — Manage webhooks on the Hub. + +- `hf webhooks create --watch TEXT` — Create a new webhook. `[--url TEXT --job-id TEXT --domain [repo|discussions] --secret TEXT --format [auto|human|agent|json|quiet]]` +- `hf webhooks delete WEBHOOK_ID` — Delete a webhook permanently. `[--yes --format [auto|human|agent|json|quiet]]` +- `hf webhooks disable WEBHOOK_ID` — Disable an active webhook. `[--format [auto|human|agent|json|quiet]]` +- `hf webhooks enable WEBHOOK_ID` — Enable a disabled webhook. `[--format [auto|human|agent|json|quiet]]` +- `hf webhooks info WEBHOOK_ID` — Show full details for a single webhook. `[--format [auto|human|agent|json|quiet]]` +- `hf webhooks list` — List all webhooks for the current user. `[--format [auto|human|agent|json|quiet]]` +- `hf webhooks update WEBHOOK_ID` — Update an existing webhook. Only provided options are changed. `[--url TEXT --watch TEXT --domain [repo|discussions] --secret TEXT --format [auto|human|agent|json|quiet]]` + +## Common options + +- `--format` — Output format: `--format json` (or `--json`) or `--format table` (default). +- `-q / --quiet` — Quiet output (one ID per line). +- `--revision` — Git revision id which can be a branch name, a tag, or a commit hash. +- `--token` — Use a User Access Token. Prefer setting `HF_TOKEN` env var instead of passing `--token`. +- `--type` — The type of repository (model, dataset, or space). + +## Mounting repos as local filesystems + +To mount Hub repositories or buckets as local filesystems — no download, no copy, no waiting — use `hf-mount`. Files are fetched on demand. GitHub: https://github.com/huggingface/hf-mount + +Install: `curl -fsSL https://raw.githubusercontent.com/huggingface/hf-mount/main/install.sh | sh` + +Some command examples: +- `hf-mount start repo openai-community/gpt2 /tmp/gpt2` — mount a repo (read-only) +- `hf-mount start --hf-token $HF_TOKEN bucket myuser/my-bucket /tmp/data` — mount a bucket (read-write) +- `hf-mount status` / `hf-mount stop /tmp/data` — list or unmount + +## Tips + +- Use `hf --help` for full options, descriptions, usage, and real-world examples +- Authenticate with `HF_TOKEN` env var (recommended) or with `--token` +- Update the CLI with `hf update` (uses the correct command for the detected install method) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..403e03eab74e4cd41682db032cf93ec858d95f09 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Chief Engineer — config template. Copy to `.env` and fill in as needed. +# `.env` is LOADED AUTOMATICALLY at import (core/__init__.py); real shell env vars +# always win over the file. `.env` is gitignored — never committed, never uploaded +# to the Space (keep your HF_TOKEN here safely). +# +# TWO CONTEXTS: +# • LOCAL (this .env) → dev + recording via Ollama. Use the LOCAL block. +# • SPACE (HF Space) → do NOT push a .env. Set these same KEY=VALUE pairs +# as Space *Variables* (and HF_TOKEN as a *Secret*) +# in Settings, or via the huggingface_hub API +# (see docs/SPACE-DEPLOY.md). Use the ZEROGPU block. + +# ── LOCAL (Ollama) ──────────────────────────────────────────────────────── +# Primary model tag. gemma4:e4b = gemma4:latest (~9.6GB). NOTE: gemma4:4b does NOT exist. +CHIEF_ENGINEER_MODEL=gemma4:e4b # Kyle's pick (6/11). gemma4:e2b is ~2× faster if you want snappier. +# Force the deterministic fallback (skip Ollama) — handy for fast offline B-roll: +# CHIEF_ENGINEER_OFFLINE=1 + +# ── ZEROGPU / SPACE (live transformers inference) ────────────────────────── +# Set these on the ZeroGPU Space (Variables), not locally, unless you have a local GPU. +# CHIEF_ENGINEER_BACKEND=zerogpu # default 'ollama'; 'zerogpu' = live HF model +# CHIEF_ENGINEER_HF_MODEL=google/gemma-4-E2B-it # transformers-native (resolved 6/10) +# CHIEF_ENGINEER_GPU_SECONDS=90 # 1st call loads the model +# CHIEF_ENGINEER_MAX_NEW_TOKENS=512 +# HF_TOKEN=hf_xxx # ONLY if the model is gated (Space SECRET; keep out of git) diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..e4079346ba9f7b4fdd6f5f69f93b5df71d06699a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +assets/benchy.glb filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..62662d0cae75297bca10ce478c5c1187cb1ad78b --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Runtime ledger (seed corpus lives in seed_lessons.jsonl, which IS tracked) +data/lessons.jsonl +# Generated by `python -m ingest.run` from your configs +data/references.jsonl +# Learned policy — accumulates at runtime from the simulated loop +data/policy.json +# Transient UI artifacts (parametric-generated mesh, on-the-fly GIFs) +data/_generated.glb +data/_vprint.gif +data/_modal_candidate_lessons.jsonl # Modal Lane-B review file (pre-honesty-gate) +dist/ + +# Sample meshes ARE committed — the HF Space doesn't run make_assets, and +# benchy.glb can't be regenerated there (needs the STL + a decimation tool). +# Regenerate the primitives locally with `make assets` if needed. + +__pycache__/ +*.pyc +.venv/ +.env # local config + secrets (HF_TOKEN). NEVER commit or upload to the Space. diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000000000000000000000000000000000..2c0733315e415bfb5e5b353f9996ecd964d395b2 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..3ec42095ff4ed89ab78e17126abc7341da1e65b1 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +.PHONY: setup setup-zerogpu assets run test demo bench trace preflight + +# Local dev uses uv (fast, locked). The HF Space still installs via pip+requirements.txt. +# Entrypoints: app.py + test_core.py at root; helper scripts live in scripts/ and run +# as modules (-m scripts.) so the repo root is importable for `from core...`. +setup: ## create/sync the uv env (locked) + generate sample meshes + uv sync + uv run python -m scripts.make_assets + +setup-zerogpu: ## sync incl. the ZeroGPU live-inference extra (deploy/testing only) + uv sync --extra zerogpu + uv run python -m scripts.make_assets + +assets: ## (re)generate sample meshes for the 3D preview + uv run python -m scripts.make_assets + +run: ## launch the Gradio app (needs `ollama serve` for live inference) + uv run python app.py + +test: ## headless core tests (no Ollama required) + uv run python test_core.py + +preflight: ## GO/NO-GO gate on the real stack (see RUNBOOK) + uv run python -m scripts.preflight + +demo: ## scripted integration run / video-beat dry run + uv run python -m scripts.scripted_demo + +bench: ## measure model latency on this hardware (needs Ollama) + uv run python -m scripts.bench_latency + +trace: ## export the lesson ledger as a HF Datasets-ready trace + uv run python -m scripts.export_trace diff --git a/README.md b/README.md index 15bc2758f48e6b6e07deb07ff926ef1b2a85ec51..8a530528d51be13c556c9631f7a823bdf9d05f5c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,123 @@ --- -title: Microfactory Lab -emoji: 👁 -colorFrom: indigo -colorTo: blue +title: The Chief Engineer +emoji: 🖨️ +colorFrom: gray +colorTo: yellow sdk: gradio sdk_version: 6.17.3 -python_version: '3.12' +python_version: "3.11" app_file: app.py pinned: false license: mit +short_description: Local Gemma that predicts 3D-print failures before they run +tags: + - build-small-hackathon + - backyard-ai + - off-the-grid + - llama-champion + - off-brand --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# The Chief Engineer + +> My grandfather had a shop — an electrical engineer who tinkered, a home RadioShack with a +> machine shop attached. He spent a lifetime accumulating skill that lived in his hands. Then +> Alzheimer's took him, the shop was sold, and all of it just… went. The Chief Engineer is the +> opposite of that: a small AI that runs on my own machine and learns the craft of 3D printing +> the way a shop veteran would — job by job, remembering what worked in which conditions — and +> tells me where a print will fail *before* it runs. + +**Build Small hackathon · Backyard AI track.** A small local **Gemma** model that has accumulated +expert 3D-printing knowledge across prior jobs and applies it **proactively** to a new one. +Knowledge **compounds** — job N+1 is better-informed than job N. The proactive part is the +*judgment*: the model evaluates what prior jobs transfer to this one, applies precedent when it +fits, and says *"no close precedent"* when it doesn't. + +## The loop + +``` +Job (geometry + material) + Environment (temp, humidity) + → retrieve nearest prior jobs (exact material+geometry, normalized [temp,humidity] distance, top 2-3) + → Chief Engineer EVALUATES what transfers → proposes settings + risk regions (Brain) + → Spine validates against material bounds, clamps/vetoes unsafe values (Spine) + → HITL "Confirm & Print" → you print → you report the real outcome + → reflect → distill an environment-keyed lesson → ledger grows → next job is smarter +``` + +The model **never grades its own outcome** — you click *Printed clean / Sagged / Stringing*, or +run the **simulated world** (a deterministic stand-in for the printer + sensors). Either way the +outcome comes from outside the model. The honest signal is what makes the compounding real. + +**This is more than a lookup.** Two knowledge sources feed every recommendation — *RAG* (retrieved +prior jobs the LLM reasons over) and a **learned parametric policy** (`learn/policy.py`) that stores +setting offsets per (material, geometry, *environment-bucket*) and updates from each outcome. Because +cells are bucketed, a lesson from one humid PETG bridge **generalizes** to the next, not-identical +one. The **Learning Loop** tab runs the closed loop live: pick a hard job and watch print quality +climb from failure to clean over a handful of iterations. The one simulated boundary (print +outcomes) is spelled out in *What's real vs frontier* below. + +## Run it (fully local, no cloud, no API key) + +```bash +ollama serve & +ollama pull gemma4:e4b # = gemma4:latest, 9.6GB. (gemma4:4b does NOT exist.) +make setup # uv sync (locked env) + generate sample meshes +make run # → http://localhost:7860 (or: uv run python app.py) +``` + +Local dev uses [uv](https://docs.astral.sh/uv); the HF Space installs via `requirements.txt`. +**Layout:** `app.py` (Space entrypoint) + `test_core.py` at the root; the core library lives in +`core/`, runnable helpers in `scripts/` (run via `make ` or `uv run python -m scripts.`), +and the simulator / learning-loop / ingestion packages in `sim/`, `learn/`, `ingest/`. See +`docs/RUNBOOK.md` for the full run-and-test order of operations. + +Falls back to a deterministic advisor if Ollama is unreachable, so the demo never crashes; the +UI always shows which model ran. Set `CHIEF_ENGINEER_MODEL=gemma4:e2b` if CPU latency is high. + +**Resetting demo state.** The Learning Loop and the 🧪 *Simulate outcome* button accumulate runtime +state — earned/sim lessons in `data/lessons.jsonl` and the learned cells in `data/policy.json` (both +gitignored). For a clean slate between demos: + +```bash +rm -f data/lessons.jsonl data/policy.json # seed corpus reloads automatically on next launch +``` + +## Workspaces + +1. **Studio** — define the job: pick a reference part type, quick-load 3DBenchy, generate a + primitive, or upload a mesh; choose material; set run conditions. Live 3D preview + a + layer-by-layer *virtual print* of the actual part. +2. **Analysis** — the load-bearing moment, on its own page: the model's precedent evaluation and + reasoning, predicted failure regions, the Spine-validated settings + G-code as instrument + readouts, then close the loop (🧪 *Simulate outcome* or record a real one). +3. **Learning Loop** — the compounding made literal: run the closed simulated loop and watch the + quality curve climb while the learned policy cell fills in. +4. **Knowledge + Mesh** — the node mesh (one node executes, others as context) and the live ledger + where lessons accumulate (seed → earned → sim). + +## Badges + +Off the Grid (local Ollama/Gemma) · Llama Champion (Ollama runs on llama.cpp) · +Sharing is Caring (ledger/trace to the Hub) · Field Notes (build writeup). Off-Brand if the +Astrometrics UI lands (see `../DESIGN.md`). **Not** Well-Tuned — fine-tuning is named as frontier. + +## What's real vs frontier (honest claims) + +- **Built:** retrieval-based compounding knowledge, environment-keyed lessons, a learned parametric + policy that generalizes across similar conditions, the closed learning loop, the Brain/Spine veto, + proactive geometric risk flags, fully local Gemma inference, human-reported + simulated outcomes, + knowledge ingestion from slicer/firmware configs. +- **Simulated (the one boundary):** print outcomes, via a deterministic physics-lite stand-in for the + printer + sensors (`sim/outcome.py`) — the model never grades its own work. +- **Frontier (not built):** weight-level fine-tuning on the accumulated ledger, real distributed + multi-node execution, the physical interfaces (g-code streaming, env sensors, camera defect CV). + +## License + +MIT. **No OrcaSlicer/PrusaSlicer code** (AGPL-3.0) — trimesh/PySLM/manifold3d/pyclipr only. + +## Source + +Built in the open. The full build plan, run-and-test runbook, the small-Gemma steering notes, +and the write-up live in the project's source repository (curated docs are added to this Space +after the demo video + write-up are finalized). diff --git a/app.py b/app.py index 04cc31aa8d0e06aeaac3b59bb361ed71d831e43f..e1067b3bba6e32886f901c517bc332c2538c197c 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,463 @@ +"""The Chief Engineer — Gradio app (three workspaces). + +WORKBENCH: a staged single-job flow — ① define the job → ② part + virtual print → +③ engineer's analysis (precedent eval + reasoning) → ④ validation (Spine veto + +g-code) → ⑤ close the loop (simulate / record outcome → reflect → ledger grows). +Stages reveal top-to-bottom as the job runs. +LEARNING LOOP: the simulated compounding loop — quality climbs fail→clean. +KNOWLEDGE + MESH: the node mesh + the live ledger (seed → earned → sim). + +Local-only. Real Ollama calls (gemma4:e4b), deterministic fallback so the demo +never crashes. Run: `ollama serve` + `make run` (= `uv run python app.py`). +""" + +from __future__ import annotations + +import random + import gradio as gr -def greet(name): - return "Hello " + name + "!!" +from core import llm +from core import seed_lessons +from core.theme import THEME, CSS, rule, command_bar, footer_bar +from core.widgets import virtual_printer_html, layer_image, SCRUB_LAYERS, VP_HEAD +from core.chief_engineer import advise +from core.ledger import LedgerManager +from core.models import BED_POSITIONS, Environment, GEOMETRY_TYPES, Job, MATERIALS, PrintSettings +from core.nodes import render_node_cards +from core.reflect import reflect_on_job +from core.spine import SpineValidator +from learn.loop import run_iteration, run_session +from learn.policy import LearnedPolicy, cell_key +from sim.outcome import simulate +from core.viewer import ( + PART_LABEL, + benchy_mesh, + gcode_panel_html, + generate_primitive, + iteration_log_html, + placement_callout, + policy_cell_html, + precedent_eval_html, + quality_curve_html, + risk_callouts_html, + sample_mesh, + settings_panel_html, + steepest_overhang_hint, +) + +try: + from ingest.distill import reference_block +except Exception: # ingestion is optional / removable + def reference_block(material): # type: ignore + return [] + +LEDGER = LedgerManager() +SPINE = SpineValidator() +POLICY = LearnedPolicy() +_loaded = seed_lessons.ensure_seeded(LEDGER) + +# Astrometrics OS visual layer lives in core/theme.py (THEME + CSS + helpers) so +# the Off-Brand skin stays a single removable module. See ../DESIGN.md. + + +def ledger_html() -> str: + c = LEDGER.count() + entries = LEDGER.all()[-8:][::-1] + rows = [] + for e in entries: + tag = "SEED" if e.source == "seed" else "EARNED" + col = "var(--ao-outline)" if e.source == "seed" else "var(--ao-green)" + rows.append( + f"
" + f"{tag} " + f"{e.material}/{e.geometry_type} " + f"@ {e.env_temp:.0f}°C/{e.env_humidity:.0f}% → {e.outcome}" + f"
{e.lesson}
" + ) + head = ( + f"
" + f"LEDGER · {c['total']} lessons ({c['seed']} seed · {c['earned']} earned)
" + ) + return head + "".join(rows) + + +_DEFAULT_PART = {"geometry": "overhang", "mesh": sample_mesh("overhang"), "label": "OVERHANG TEST"} + + +def _set_part(geometry: str, mesh, label: str): + """Shared STUDIO preview update → (part_state, model3d, status). The virtual + print is NOT rendered here — it initializes on ANALYZE (see get_recommendation), + so it doesn't loop perpetually on the Studio preview.""" + return ( + {"geometry": geometry, "mesh": mesh, "label": label}, + gr.update(value=mesh), + f"▣ ACTIVE PART · **{label}** · class `{geometry}`", + ) + + +def analyze_start(part): + """Immediate feedback the instant ANALYZE is clicked: jump to the Analysis tab + and show spinners while the model runs (e4b warm ~15-20s on CPU). The heavy + get_recommendation runs right after via .then(). No part selected → stay put.""" + if not (part and part.get("geometry")): + gr.Warning("Select a part in Studio first — pick a type, load Benchy, generate, or upload.") + return (gr.update(),) * 7 + return ( + gr.Tabs(selected="analysis"), + "⏳ **Analyzing the job** — evaluating precedent and proposing settings…", # reasoning + "
⏳ retrieving precedent…
", # precedent + "", # risks + "", # settings_html + "", # gcode_html + "
⏳ slicing the part…
", # vprint + ) + + +def select_type(geo): # reference part-type pill chosen + return _set_part(geo, sample_mesh(geo), PART_LABEL.get(geo, geo.upper())) + + +def load_benchy(): + mesh = benchy_mesh() + label = "3DBENCHY (CC0)" if mesh else "BENCHY MISSING — add assets/benchy.glb" + return _set_part("overhang", mesh, label) + + +def generate_part(kind, size): + mesh, geo = generate_primitive(kind, size or 30) + return _set_part(geo, mesh, f"GENERATED {str(kind).upper()} · {float(size or 30):.0f}MM") + + +def upload_part(f): + if not f: + return (gr.update(), gr.update(), gr.update(), gr.update()) + from pathlib import Path as _P + return _set_part("overhang", f.name, f"UPLOAD · {_P(f.name).name[:26]}") + + +def scrub_layer(idx, part): + """Render one cross-section layer at full fidelity for the scrubber slider.""" + mesh = (part or {}).get("mesh") + return layer_image(mesh, idx) + + +# ── simulated sensors (this is a sim lab — conditions are generated, overridable) ── +def _sensor_readout(t, h, pos) -> str: + return ("
SIMULATED SENSORS · " + f"🌡 {float(t):.0f}°C · " + f"💧 {float(h):.0f}%RH · " + f"⌖ {pos} " + "(🎲 re-roll or ⚙ override)
") + + +def randomize_sensors(): + """Roll a plausible ambient + plate position — the lab's simulated sensor feed.""" + t = random.choice([18, 20, 22, 24, 26, 28, 30, 32]) + h = random.choice([30, 38, 45, 52, 60, 68]) + pos = random.choices(BED_POSITIONS, weights=[3, 2, 1])[0] # center most common + return gr.update(value=t), gr.update(value=h), gr.update(value=pos), _sensor_readout(t, h, pos) + + +def sync_readout(t, h, pos): + return _sensor_readout(t, h, pos) + + +def get_recommendation(part, material, description, temp, humidity, bed_position): + if not (part and part.get("geometry")): # guard: empty start, no part chosen + return ("", "", "⚠ **Select a part in Studio** (pick a type, load Benchy, generate, " + "or upload) before analyzing.", "", "", "", "", gr.update(visible=False), + gr.update(), {}, "", gr.update(), gr.update()) + geometry_type, mesh = part["geometry"], part.get("mesh") + job = Job(geometry_type=geometry_type, material=material, description=description or "", + bed_position=bed_position or "center") + env = Environment(temp=float(temp), humidity=float(humidity)) + + retrieved = LEDGER.retrieve(material, geometry_type, env.temp, env.humidity) + references = reference_block(material) + policy_note = POLICY.policy_note(material, geometry_type, env) + rec = advise(job, env, retrieved, references, policy_note) + spine = SPINE.check(rec.advice.settings, material) + hint = steepest_overhang_hint(mesh) if geometry_type in ("overhang", "bridge") else None + vp_html = virtual_printer_html(mesh, f"{material} · {geometry_type}") # init on ANALYZE + + precedent = precedent_eval_html(retrieved, env) + if retrieved: + rows = "".join( + f"
" + f"• {e.job_id} ({e.source}) {e.outcome} @ {e.env_temp:.0f}°C/{e.env_humidity:.0f}% " + f"(dist {dist:.2f})
" + for e, dist in retrieved + ) + precedent += f"
{rows}
" + + fb = " ⚠ deterministic fallback" if rec.used_fallback else "" + spine_md = ("**🛡 Spine veto:** " + " \n".join(spine.vetoes)) if spine.vetoes else "" + confirm_vis = gr.update(visible=spine.requires_approval) + approval_md = ("⏸ **HITL gate:** the Spine clamped a boundary setting — review, then **Confirm & Print**." + if spine.requires_approval else "✅ within safe envelope — ready when you are.") + state = {"job": job.model_dump(), "env": env.model_dump(), "settings": spine.settings.model_dump()} + + return ( + f"{rec.backend}{fb}", # backend status + precedent, # precedent + f"**Chief Engineer:** {rec.advice.reasoning}", # reasoning + risk_callouts_html(rec.advice.risks, hint) + placement_callout(material, bed_position), # risks + placement + settings_panel_html(spine.settings, material), # settings (LCARS panel) + f"{spine_md}\n\n{approval_md}" if spine_md else approval_md, # spine notes + gcode_panel_html(spine.settings, material), # g-code (LCARS panel) + confirm_vis, # confirm visibility + render_node_cards(env, working=True), # node cards + state, # state + vp_html, # virtual print (animates once) + gr.update(value=1), # reset layer scrubber + layer_image(mesh, 1), # initial scrubbed layer + ) + + +def run_learning(material, geometry_type, temp, humidity, iterations): + """Primary demo: run the closed simulated loop and watch quality compound.""" + job = Job(geometry_type=geometry_type, material=material, description="") + env = Environment(temp=float(temp), humidity=float(humidity)) + key = cell_key(material, geometry_type, env) + before = POLICY.cell_stats(material, geometry_type, env) + before_html = policy_cell_html(before, key) + + sess = run_session(job, env, int(iterations), POLICY, LEDGER) + after = POLICY.cell_stats(material, geometry_type, env) + + traj = sess.trajectory + first = sess.first_success + headline = ( + f"**{material}/{geometry_type} @ {env.temp:.0f}°C / {env.humidity:.0f}% RH** — " + f"started at quality **{traj[0]:.2f}** ({sess.records[0].result.outcome}); " + + (f"first clean print at **iteration {first}**, now **{traj[-1]:.2f}**." + if first else f"still improving — best **{max(traj):.2f}** after {len(traj)} runs.") + + " The model proposed; a separate simulated world reported the outcome; " + "the policy and ledger learned from it. *(Simulated — see SIMULATION.md.)*" + ) + policy_html = (f"{before_html}
▼ learned ▼
" + f"{policy_cell_html(after, key)}") + return ( + headline, # l_headline + quality_curve_html(traj), # l_curve + policy_html, # l_policy + iteration_log_html(sess.records), # l_log + ledger_html(), # ledger_panel + render_node_cards(env, working=False), # node_cards + ) + + +def simulate_outcome(state): + """Analysis: close the loop in the world (deterministic), then learn from it.""" + if not state: + return "Run a recommendation first.", ledger_html() + job = Job(**state["job"]) + env = Environment(**state["env"]) + settings = PrintSettings(**state["settings"]) + result = simulate(settings, job, env) + learned = POLICY.update(job.material, job.geometry_type, env, result) + POLICY.save() + # record the simulated outcome to the ledger as precedent + from learn.loop import _record_lesson + _record_lesson(job, env, settings, result, LEDGER) + icon = "✓" if result.outcome == "success" else "✗" + return ( + f"🧪 **Simulated world** (stand-in for printer + sensors): {icon} **{result.detail}**. \n" + f"↳ policy update: *{learned}* \n*(The model never saw this outcome in advance — SIMULATION.md.)*", + ledger_html(), + ) + + +def record_outcome(outcome, state): + if not state: + return "Run a recommendation first.", ledger_html(), render_node_cards(Environment(temp=22, humidity=45)) + job = Job(**state["job"]) + env = Environment(**state["env"]) + settings = PrintSettings(**state["settings"]) + entry = reflect_on_job(job, env, settings, outcome, LEDGER) + msg = f"📒 Lesson recorded (earned): *{entry.lesson}*" + return msg, ledger_html(), render_node_cards(env, working=False) + + +def launch(**kw): + """Single launch entrypoint so the Astrometrics theme/CSS/clock apply + everywhere the app is started (Gradio 6 takes these on launch, not Blocks).""" + return build().queue().launch(theme=THEME, css=CSS, head=VP_HEAD, **kw) + + +def build() -> gr.Blocks: + with gr.Blocks(title="The Chief Engineer") as demo: + gr.HTML(command_bar(llm.backend_status())) + gr.Markdown( + "
a small local model that learns 3D printing job by job — " + "and tells you where a print fails before it runs · gemma4 · fully local
" + ) + state = gr.State() + part = gr.State({"geometry": None, "mesh": None, "label": None}) + + with gr.Tabs() as tabs: + # ─────────────────────────── STUDIO (define) ─────────────────────────── + with gr.Tab("⬡ STUDIO", id="studio"): + gr.HTML("
DEFINE THE JOB · ① part → ② material → " + "③ conditions → ANALYZE
") + with gr.Row(): + with gr.Column(scale=2, elem_classes=["ce-console"]): + gr.HTML(rule("① SELECT PART")) + gr.HTML("
REFERENCE PART TYPE — what the engineer reasons about
") + type_radio = gr.Radio(GEOMETRY_TYPES, value=None, show_label=False, + elem_classes=["ce-pills"]) + gr.HTML("
OR LOAD A MODEL
") + with gr.Row(): + benchy_btn = gr.Button("⛴ QUICK-LOAD BENCHY", elem_classes=["ce-pillbtn"]) + with gr.Accordion("⚙ GENERATE A PRIMITIVE", open=False): + gen_kind = gr.Radio(["box", "cylinder", "cone", "sphere"], value="box", + show_label=False, elem_classes=["ce-pills"]) + gen_size = gr.Number(value=30, label="SIZE (mm)", elem_classes=["ce-num"]) + gen_btn = gr.Button("GENERATE", elem_classes=["ce-pillbtn"]) + mesh_in = gr.File(file_types=[".stl", ".glb", ".obj"], + label="UPLOAD YOUR OWN MESH", elem_classes=["ce-drop"]) + part_status = gr.Markdown("▣ **no part selected** — pick a type, load Benchy, " + "generate, or upload to begin") + + gr.HTML(rule("② MATERIAL")) + material = gr.Radio(MATERIALS, value="PLA", show_label=False, elem_classes=["ce-pills"]) + + gr.HTML(rule("③ SIMULATED SENSORS")) + sensors_readout = gr.HTML() + roll_btn = gr.Button("🎲 RANDOMIZE SENSORS", elem_classes=["ce-pillbtn"]) + with gr.Accordion("⚙ OVERRIDE SENSORS", open=False): + with gr.Row(): + temp = gr.Number(value=22, label="AMBIENT °C", elem_classes=["ce-num"]) + humidity = gr.Number(value=45, label="HUMIDITY %RH", elem_classes=["ce-num"]) + gr.HTML("
BUILD-PLATE POSITION — edges/corners run " + "cooler → warp/adhesion risk
") + bed_position = gr.Radio(BED_POSITIONS, value="center", show_label=False, + elem_classes=["ce-pills"]) + description = gr.Textbox(label="NOTES (OPTIONAL)", + placeholder="e.g. 45° bracket, 60mm tall") + run_btn = gr.Button("▶ ANALYZE JOB", variant="primary") + with gr.Column(scale=3): + gr.HTML(rule("PART PREVIEW")) + model3d = gr.Model3D(value=None, label="", height=320) + gr.HTML("
Rotate/zoom the part. The layer-by-layer " + "virtual print renders on the Analysis page when you ANALYZE.
") + + # ─────────────────────────── ANALYSIS (results) ──────────────────────── + with gr.Tab("◇ ANALYSIS", id="analysis"): + backend = gr.Markdown() + gr.HTML("
① the engineer's read → ② validated settings + " + "g-code → ③ virtual print → ④ close the loop
") + with gr.Row(): + with gr.Column(): + gr.HTML(rule("① PRECEDENT EVALUATION")) + precedent = gr.HTML(elem_id="ce-precedent") + gr.HTML(rule("ENGINEER'S REASONING")) + reasoning = gr.Markdown(elem_id="ce-reasoning") + gr.HTML(rule("PREDICTED FAILURE REGIONS")) + risks = gr.HTML() + with gr.Column(): + gr.HTML(rule("② VALIDATION + G-CODE")) + spine_notes = gr.Markdown() + settings_html = gr.HTML() + gcode_html = gr.HTML() + confirm_btn = gr.Button("✅ Confirm & Print", elem_id="ce-confirm", visible=False) + gr.HTML(rule("③ VIRTUAL PRINT")) + vprint = gr.HTML() + gr.HTML("
Slide through the layers — each is a real " + "cross-section of this part at full mesh fidelity.
") + vp_slider = gr.Slider(1, SCRUB_LAYERS, value=1, step=1, label="LAYER") + vp_layer = gr.Image(label="", height=380, show_label=False) + gr.HTML(rule("④ CLOSE THE LOOP")) + gr.HTML("
Simulate the print (deterministic stand-in for printer + " + "sensors), or record a real outcome. Either way the model never grades itself.
") + with gr.Row(elem_id="ce-sim"): + sim_btn = gr.Button("🧪 Simulate outcome (deterministic world)") + with gr.Row(elem_id="ce-outcomes"): + b_clean = gr.Button("✓ Printed clean") + b_sag = gr.Button("✗ Sagged") + b_string = gr.Button("✗ Stringing") + outcome_msg = gr.Markdown() + + # ─────────────────────────── LEARNING LOOP ───────────────────────────── + with gr.Tab("◳ LEARNING LOOP", id="loop"): + gr.HTML( + "
THE COMPOUNDING LOOP · the model proposes → a simulated world " + "reports the outcome → the learned policy and ledger update → the next run is " + "better. Pick a hard job and watch quality climb from failure to clean. " + "Simulated; physical interfaces framed in SIMULATION.md.
" + ) + with gr.Row(): + with gr.Column(scale=2, elem_classes=["ce-console"]): + gr.HTML(rule("① SIMULATION CONTROLS")) + l_geometry = gr.Radio(GEOMETRY_TYPES, value="bridge", show_label=False, + elem_classes=["ce-pills"]) + l_material = gr.Radio(MATERIALS, value="PETG", show_label=False, elem_classes=["ce-pills"]) + with gr.Row(): + l_temp = gr.Number(value=29, label="AMBIENT °C", elem_classes=["ce-num"]) + l_humidity = gr.Number(value=62, label="HUMIDITY %RH", elem_classes=["ce-num"]) + l_iters = gr.Slider(1, 16, value=8, step=1, label="ITERATIONS") + l_run = gr.Button("▶ RUN SIMULATED LEARNING", variant="primary") + l_headline = gr.Markdown() + with gr.Column(scale=3): + gr.HTML(rule("② QUALITY PER ITERATION")) + l_curve = gr.HTML() + gr.HTML(rule("③ LEARNED POLICY CELL")) + l_policy = gr.HTML() + gr.HTML(rule("④ ITERATION LOG")) + l_log = gr.HTML() + + # ─────────────────────────── KNOWLEDGE + MESH ────────────────────────── + with gr.Tab("▤ KNOWLEDGE + MESH", id="ledger"): + gr.HTML("
The capability mesh (one node executes, the others " + "stand by as context) and the live ledger where knowledge compounds " + "(seed → earned → sim).
") + with gr.Row(): + with gr.Column(): + gr.HTML(rule("CAPABILITY MESH")) + node_cards = gr.HTML(render_node_cards(Environment(temp=22, humidity=45))) + with gr.Column(): + gr.HTML(rule("LESSON LEDGER")) + ledger_panel = gr.HTML(ledger_html()) + refresh = gr.Button("⟳ REFRESH LEDGER", elem_classes=["ce-pillbtn"]) + + gr.HTML(footer_bar()) + + # ── wiring ── + preview_outs = [part, model3d, part_status] + type_radio.change(select_type, [type_radio], preview_outs) + benchy_btn.click(load_benchy, None, preview_outs) + gen_btn.click(generate_part, [gen_kind, gen_size], preview_outs) + mesh_in.upload(upload_part, [mesh_in], preview_outs) + + # Simulated sensors: roll on load, re-roll on demand, keep the readout in sync. + sensor_outs = [temp, humidity, bed_position, sensors_readout] + demo.load(randomize_sensors, None, sensor_outs) + roll_btn.click(randomize_sensors, None, sensor_outs) + for c in (temp, humidity, bed_position): + c.change(sync_readout, [temp, humidity, bed_position], [sensors_readout]) + + # Two-step ANALYZE: instant tab-switch + spinners, then the heavy model call. + analyze_outs = [backend, precedent, reasoning, risks, settings_html, spine_notes, + gcode_html, confirm_btn, node_cards, state, vprint, vp_slider, vp_layer] + run_btn.click(analyze_start, [part], + [tabs, reasoning, precedent, risks, settings_html, gcode_html, vprint]).then( + get_recommendation, [part, material, description, temp, humidity, bed_position], + analyze_outs) + vp_slider.change(scrub_layer, [vp_slider, part], [vp_layer]) + + for btn, oc in [(b_clean, "success"), (b_sag, "failed_sag"), (b_string, "failed_stringing")]: + btn.click(record_outcome, [gr.State(oc), state], [outcome_msg, ledger_panel, node_cards]) + sim_btn.click(simulate_outcome, [state], [outcome_msg, ledger_panel]) + refresh.click(lambda: ledger_html(), outputs=[ledger_panel]) + + l_run.click(run_learning, [l_material, l_geometry, l_temp, l_humidity, l_iters], + [l_headline, l_curve, l_policy, l_log, ledger_panel, node_cards]) + + return demo + -demo = gr.Interface(fn=greet, inputs="text", outputs="text") -demo.launch() +if __name__ == "__main__": + print(f"[chief-engineer] {llm.backend_status()} | seeded {_loaded} lessons this run") + launch(server_name="0.0.0.0", server_port=7860) diff --git a/assets/benchy.glb b/assets/benchy.glb new file mode 100644 index 0000000000000000000000000000000000000000..2b8061d933db8301bd79af3a89101b655025c868 --- /dev/null +++ b/assets/benchy.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37474bde1a081da6cda0a2d1ebc3c384a30f280fe335678aecb6873cc9da2ccc +size 1077356 diff --git a/assets/bridge.glb b/assets/bridge.glb new file mode 100644 index 0000000000000000000000000000000000000000..d08c5fda3c83f32c081cffc2ddb78ce028d0b033 Binary files /dev/null and b/assets/bridge.glb differ diff --git a/assets/cube.glb b/assets/cube.glb new file mode 100644 index 0000000000000000000000000000000000000000..7dddefc2cce9c99eaa7a5cfb79735da1f8063761 Binary files /dev/null and b/assets/cube.glb differ diff --git a/assets/overhang.glb b/assets/overhang.glb new file mode 100644 index 0000000000000000000000000000000000000000..8f25b5640e00fce85fb862bc124d22fed540c331 Binary files /dev/null and b/assets/overhang.glb differ diff --git a/assets/vase.glb b/assets/vase.glb new file mode 100644 index 0000000000000000000000000000000000000000..94dd4aea27936bca4b8736e408dc41c0c5c31184 Binary files /dev/null and b/assets/vase.glb differ diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c90661fd29b89e633c635cdd7e1d2cab5c19d830 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,25 @@ +"""Core library for the Chief Engineer (models, LLM, ledger, spine, advisor). +Imported as a package; entrypoints live at the repo root (app.py) and in scripts/.""" + +import os +from pathlib import Path + + +def _load_dotenv() -> None: + """Tiny stdlib .env loader (no python-dotenv dep): reads KEY=VALUE lines from + the repo root .env. Real environment always wins (setdefault), so + `CHIEF_ENGINEER_MODEL=x make run` still overrides the file.""" + env = Path(__file__).resolve().parent.parent / ".env" + if not env.is_file(): + return + for line in env.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + k, v = k.strip(), v.strip().strip('"').strip("'") + if k: + os.environ.setdefault(k, v) + + +_load_dotenv() diff --git a/core/chief_engineer.py b/core/chief_engineer.py new file mode 100644 index 0000000000000000000000000000000000000000..bdb0a52a1533c42597e7d5ca84c4f236f905e6f8 --- /dev/null +++ b/core/chief_engineer.py @@ -0,0 +1,80 @@ +"""The Chief Engineer — Brain. + +Reimplements hubAgent.ts's call shape (pattern-review.md §1): assemble system +prompt → real Ollama call → parse to Pydantic, with a conservative fallback so +a parse failure or an offline Space never crashes the demo. The reasoning text +carries the model's EVALUATION of precedent — the load-bearing moment. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from . import llm +from .models import Advice, Environment, Job, LessonEntry, PrintSettings, RiskRegion +from .prompts import build_system_prompt + +# Conservative per-material starting points for the offline/parse-fail fallback. +_FALLBACK_SETTINGS: dict[str, dict[str, float]] = { + "PLA": {"nozzle_temp": 205, "bed_temp": 60, "retraction_mm": 5, "fan_pct": 100, "first_layer_fan_pct": 0}, + "PETG": {"nozzle_temp": 235, "bed_temp": 80, "retraction_mm": 4, "fan_pct": 40, "first_layer_fan_pct": 0}, + "ABS": {"nozzle_temp": 245, "bed_temp": 100, "retraction_mm": 4, "fan_pct": 20, "first_layer_fan_pct": 0}, + "TPU": {"nozzle_temp": 228, "bed_temp": 50, "retraction_mm": 2, "fan_pct": 40, "first_layer_fan_pct": 0}, +} +_GEOMETRY_RISK = { + "overhang": ("underside of the overhang", "sag", "steep overhangs droop before the layer freezes", "overhang"), + "bridge": ("the unsupported span", "sag", "long bridges sag without enough cooling", "bridge"), + "stringing": ("travel moves across gaps", "stringing", "molten plastic oozes during travel", None), + "adhesion": ("first layer / corners", "adhesion", "poor first-layer bond lifts the part", "first_layer"), + "vase": ("thin single-wall sections", "warping", "tall thin walls can wobble or warp", None), +} + + +@dataclass +class Recommendation: + advice: Advice + used_fallback: bool + backend: str + + +def _fallback_advice(job: Job, env: Environment, retrieved: list[tuple[LessonEntry, float]]) -> Advice: + base = _FALLBACK_SETTINGS.get(job.material.upper(), _FALLBACK_SETTINGS["PLA"]) + loc, risk, why, hint = _GEOMETRY_RISK.get( + job.geometry_type, ("the part", "warping", "general risk", None) + ) + if retrieved: + e, dist = retrieved[0] + reasoning = ( + f"[fallback] Nearest precedent: Job {e.job_id} ({e.material}/{e.geometry_type} " + f"@ {e.env_temp:.0f}°C/{e.env_humidity:.0f}% → {e.outcome}, env-dist {dist:.2f}). " + f"Applying its lesson with conservative {job.material} defaults." + ) + else: + reasoning = ( + f"[fallback] No close precedent for {job.material}/{job.geometry_type}. " + f"Reasoning from material properties with conservative defaults." + ) + return Advice( + reasoning=reasoning, + settings=PrintSettings(**base), + risks=[RiskRegion(location=loc, risk=risk, why=why, anchor_hint=hint)], + ) + + +def advise( + job: Job, + env: Environment, + retrieved: list[tuple[LessonEntry, float]], + references: list[str] | None = None, + policy_note: str | None = None, +) -> Recommendation: + system = build_system_prompt(job, env, retrieved, references, policy_note) + raw = llm.chat_json(system, "Give your recommendation for THIS job now.") + backend = llm.backend_status() + if raw is None: + return Recommendation(_fallback_advice(job, env, retrieved), used_fallback=True, backend=backend) + try: + advice = Advice(**raw) + return Recommendation(advice, used_fallback=False, backend=backend) + except Exception: + return Recommendation(_fallback_advice(job, env, retrieved), used_fallback=True, backend=backend) diff --git a/core/ledger.py b/core/ledger.py new file mode 100644 index 0000000000000000000000000000000000000000..97d23c0a7dbe1a1de6d399e7cb4ade69ef7612db --- /dev/null +++ b/core/ledger.py @@ -0,0 +1,82 @@ +"""Append-only lesson ledger + environment-keyed retrieval. + +Modeled on pi-qmd-ledger's `append_ledger` (pattern-review.md §3) but with the +qmd embeddings / vector search deliberately left behind. Retrieval is the +locked design: exact match on material AND geometry_type, then rank by +Euclidean distance on NORMALIZED [temp, humidity], top 2-3. No vector DB. +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +from .models import LessonEntry + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +LEDGER_PATH = DATA_DIR / "lessons.jsonl" + +# Normalization ranges (02-ARCHITECTURE.md / 04-BUILD-PROMPT.md): map both env +# axes to 0-1 so humidity's 0-100 span doesn't swamp temperature in the metric. +TEMP_MIN, TEMP_MAX = 15.0, 35.0 +HUM_MIN, HUM_MAX = 20.0, 80.0 + + +def _norm(temp: float, humidity: float) -> tuple[float, float]: + t = (temp - TEMP_MIN) / (TEMP_MAX - TEMP_MIN) + h = (humidity - HUM_MIN) / (HUM_MAX - HUM_MIN) + return t, h + + +class LedgerManager: + def __init__(self, path: Path = LEDGER_PATH) -> None: + self.path = path + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.touch(exist_ok=True) + + # --- storage ----------------------------------------------------------- + def all(self) -> list[LessonEntry]: + out: list[LessonEntry] = [] + for line in self.path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + out.append(LessonEntry(**json.loads(line))) + except Exception: + continue # never let one bad line crash the demo + return out + + def append(self, entry: LessonEntry) -> None: + with self.path.open("a", encoding="utf-8") as f: + f.write(entry.model_dump_json() + "\n") + + def count(self) -> dict[str, int]: + buckets = {"seed": 0, "earned": 0, "ingested": 0, "sim": 0} + for e in self.all(): + buckets[e.source if e.source in buckets else "earned"] += 1 + buckets["total"] = sum(buckets.values()) + return buckets + + # --- retrieval (the thesis-critical query) ----------------------------- + def retrieve( + self, material: str, geometry_type: str, temp: float, humidity: float, k: int = 3 + ) -> list[tuple[LessonEntry, float]]: + """Return up to k (lesson, env_distance) sorted nearest-first. + + Exact match on material AND geometry_type; ranked by normalized + Euclidean env-distance. Returns [] when no precedent matches — which + is a *valid, strong* outcome (the model reasons from material + properties instead). + """ + tn, hn = _norm(temp, humidity) + scored: list[tuple[LessonEntry, float]] = [] + for e in self.all(): + if e.material != material or e.geometry_type != geometry_type: + continue + etn, ehn = _norm(e.env_temp, e.env_humidity) + dist = math.hypot(tn - etn, hn - ehn) + scored.append((e, dist)) + scored.sort(key=lambda x: x[1]) + return scored[:k] diff --git a/core/llm.py b/core/llm.py new file mode 100644 index 0000000000000000000000000000000000000000..9e8cfbd0f888b3502d75d3704b5ac96fb82bbd1c --- /dev/null +++ b/core/llm.py @@ -0,0 +1,99 @@ +"""Local Ollama client (real calls, not mocks). + +Fully local Gemma inference → earns Off the Grid + Llama Champion by +construction (Ollama runs on llama.cpp). The UI surfaces which model ran and +whether it was a real call or the deterministic fallback (never crash the demo). + +`gemma4:e4b` is the default (= gemma4:latest, 9.6GB). `gemma4:e2b` if CPU +latency demands. NOTE: `gemma4:4b` does NOT exist — never use that tag. +""" + +from __future__ import annotations + +import json +import os + +MODEL = os.environ.get("CHIEF_ENGINEER_MODEL", "gemma4:e4b") + +# Backend select. Default "ollama" keeps local/recording behavior IDENTICAL. +# Set CHIEF_ENGINEER_BACKEND=zerogpu ONLY on the published Space (see +# llm_zerogpu.py + docs/plan/08-ZEROGPU-DEPLOY.md). Unknown/import-fail → ollama. +BACKEND = os.environ.get("CHIEF_ENGINEER_BACKEND", "ollama").lower() + +try: + import ollama # type: ignore +except Exception: # pragma: no cover + ollama = None # type: ignore + + +def _zerogpu(): + """Lazily import the ZeroGPU backend; None unless selected and importable.""" + if BACKEND != "zerogpu": + return None + try: + from . import llm_zerogpu # heavy deps are import-guarded inside + return llm_zerogpu + except Exception: + return None + + +def _forced_offline() -> bool: + """Force the deterministic fallback path regardless of any daemon/backend. + Read dynamically (not cached) so tests can toggle it. Used by the offline + core suite so `make test` never touches Ollama, even when `ollama serve` is up.""" + return os.environ.get("CHIEF_ENGINEER_OFFLINE", "").lower() in ("1", "true", "yes") + + +def is_available() -> bool: + """True if the active backend can serve a real call.""" + if _forced_offline(): + return False + zg = _zerogpu() + if zg is not None: + return zg.is_available() + if ollama is None: + return False + try: + ollama.list() + return True + except Exception: + return False + + +def backend_status() -> str: + zg = _zerogpu() + if zg is not None: + return zg.backend_status() + return f"🟢 live · {MODEL} (local Ollama)" if is_available() else \ + f"🟡 offline fallback · {MODEL} unreachable (deterministic)" + + +def chat_json(system: str, user: str, temperature: float = 0.4) -> dict | None: + """One JSON-mode chat turn. Returns parsed dict, or None to signal fallback.""" + if _forced_offline(): + return None + zg = _zerogpu() + if zg is not None: + return zg.chat_json(system, user, temperature) + if not is_available(): + return None + try: + resp = ollama.chat( + model=MODEL, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + format="json", + options={"temperature": temperature}, + ) + content = resp["message"]["content"].strip() + # Fence-strip safety net (GEMMA-STEERING Technique 2): small Gemmas can + # wrap JSON in ```json fences even in JSON mode. Strip before parsing. + if content.startswith("```"): + content = content.strip("`").lstrip() + if content[:4].lower() == "json": + content = content[4:] + return json.loads(content) + except Exception: + return None diff --git a/core/llm_zerogpu.py b/core/llm_zerogpu.py new file mode 100644 index 0000000000000000000000000000000000000000..2a8f2e8be33f11f6802ce12da9ad9d9df8e5e7b1 --- /dev/null +++ b/core/llm_zerogpu.py @@ -0,0 +1,151 @@ +"""ZeroGPU inference backend — STAGED for the published HF Space, OFF by default. + +Why this exists: `docs/writeup/05-PROJECTED-OUTCOME.md` §3 flags that a judge who +opens our Space sees the deterministic FALLBACK (Ollama can't run on a standard +CPU Space), while only the video shows the real model. This module lets the LIVE +Space run the real model on **ZeroGPU** with a **QAT Gemma** checkpoint — closing +that gap — WITHOUT changing anything locally or in the current demo path. + +Activation is opt-in and total: + - Selected ONLY when `CHIEF_ENGINEER_BACKEND=zerogpu`. Otherwise `llm.py` + behaves exactly as before (Ollama → deterministic fallback). Nothing about + the local dev / recording flow changes. + - Deploy-only deps (torch/transformers/accelerate/spaces) live in + `requirements-zerogpu.txt`, NOT base `requirements.txt`, and are import- + guarded here — importing this on a box without them is a safe no-op that + reports unavailable. + +It preserves the Off-the-Grid story: the model CAN run fully local (proven in the +video); ZeroGPU is just the hosted convenience so judges can try it live. + +Model ids RESOLVED 6/10 — see docs/plan/08-ZEROGPU-DEPLOY.md (primary: +google/gemma-4-E2B-it, bf16 transformers-native). Original checklist: + - Set `CHIEF_ENGINEER_HF_MODEL` to the QAT Gemma checkpoint you pick. Google's + Gemma QAT int4 releases are the target. CONFIRM the exact repo id AND that it + loads via `transformers` — some QAT releases ship **GGUF** (for llama.cpp); + for those, use a llama-cpp-python path, not this transformers one. + - Confirm the model is ≤32B (hackathon rule) and note its params for Tiny Titan. +See `docs/plan/08-ZEROGPU-DEPLOY.md`. +""" + +from __future__ import annotations + +import json +import os +import re + +HF_MODEL = os.environ.get("CHIEF_ENGINEER_HF_MODEL", "google/gemma-4-E2B-it") # resolved 6/10 +_GPU_SECONDS = int(os.environ.get("CHIEF_ENGINEER_GPU_SECONDS", "90")) # 1st call loads the model +_MAX_NEW = int(os.environ.get("CHIEF_ENGINEER_MAX_NEW_TOKENS", "512")) + +# Import-guarded heavy deps. Absent locally → module reports unavailable, no crash. +try: + import torch # type: ignore + from transformers import AutoModelForCausalLM, AutoTokenizer # type: ignore + _HAVE_HF = True +except Exception: # pragma: no cover + torch = None # type: ignore + _HAVE_HF = False + +try: + import spaces # type: ignore (the ZeroGPU package; only present on the Space) + _HAVE_SPACES = True +except Exception: # pragma: no cover + _HAVE_SPACES = False + + +def _gpu(fn): + """Decorate with @spaces.GPU when available; identity off-Space (e.g. local GPU).""" + if _HAVE_SPACES: + return spaces.GPU(duration=_GPU_SECONDS)(fn) + return fn + + +_tok = None +_model = None + + +def _ensure_loaded() -> bool: + """Load tokenizer + model once. MUST be called inside the @spaces.GPU context on + ZeroGPU (GPU is only allocated there) — we load to CPU then move to CUDA. No + device_map='auto' (discouraged on ZeroGPU; it grabs devices outside the GPU window).""" + global _tok, _model + if not _HAVE_HF: + return False + if _model is not None: + return True + try: + _tok = AutoTokenizer.from_pretrained(HF_MODEL) + _model = AutoModelForCausalLM.from_pretrained( + HF_MODEL, + torch_dtype=getattr(torch, "bfloat16", None), + low_cpu_mem_usage=True, + ) + if torch is not None and torch.cuda.is_available(): + _model = _model.to("cuda") + return True + except Exception: + _tok = _model = None + return False + + +def is_available() -> bool: + """Whether this backend CAN serve — i.e. the heavy deps imported. The actual + model load is lazy (first chat, inside the GPU window), so we don't block app + startup on a multi-GB load that would also run outside the GPU allocation.""" + return _HAVE_HF + + +def backend_status() -> str: + where = "ZeroGPU" if _HAVE_SPACES else "local GPU/CPU" + if not _HAVE_HF: + return f"🟡 offline fallback · transformers/torch absent (deterministic)" + loaded = " (loaded)" if _model is not None else " (loads on first analyze)" + return f"🟢 live · {HF_MODEL} (transformers on {where}){loaded}" + + +def _build_prompt(system: str, user: str) -> str: + # Gemma's chat template has no separate system role — fold system into the + # first user turn (the standard Gemma pattern). + messages = [{"role": "user", "content": f"{system}\n\n{user}"}] + return _tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) + + +@_gpu +def _generate(system: str, user: str, temperature: float) -> str | None: + if not _ensure_loaded(): + return None + prompt = _build_prompt(system, user) + if torch is not None and torch.cuda.is_available() and _model.device.type != "cuda": + _model.to("cuda") # ZeroGPU: ensure on-GPU during the call + inputs = _tok(prompt, return_tensors="pt").to(_model.device) + out = _model.generate( + **inputs, + max_new_tokens=_MAX_NEW, + do_sample=temperature > 0, + temperature=max(temperature, 1e-4), + ) + text = _tok.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True) + return text + + +_JSON = re.compile(r"\{.*\}", re.DOTALL) + + +def chat_json(system: str, user: str, temperature: float = 0.4) -> dict | None: + """Mirror of llm.chat_json's contract: parsed dict, or None to trigger fallback.""" + try: + text = _generate(system, user, temperature) + except Exception: + return None + if not text: + return None + # Strip code fences, then grab the outermost JSON object. + text = text.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + m = _JSON.search(text) + if not m: + return None + try: + return json.loads(m.group(0)) + except Exception: + return None diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000000000000000000000000000000000000..68e0865bbb646d4997466d92a9fcd275150bbc32 --- /dev/null +++ b/core/models.py @@ -0,0 +1,67 @@ +"""Pydantic domain types for the Chief Engineer. + +Adapted from microfactory-lab `core/src/types.ts` — stripped of routing, +economic, energy, and tick concepts (over-scope per pattern-review.md §7). +Only what the compounding-knowledge loop needs. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +MATERIALS = ["PLA", "PETG", "ABS", "TPU"] +# geometry_type doubles as a retrieval key (pattern-review.md §3/§9) +GEOMETRY_TYPES = ["overhang", "bridge", "stringing", "adhesion", "vase"] +OUTCOMES = ["success", "failed_sag", "failed_stringing"] +# where the part sits on the heated bed — drives edge-cooling warp + adhesion risk +BED_POSITIONS = ["center", "edge", "corner"] + + +class Job(BaseModel): + geometry_type: str + material: str + description: str = "" + mesh_path: str | None = None + bed_position: str = "center" # center|edge|corner — affects warp/adhesion + + +class Environment(BaseModel): + temp: float = Field(description="Ambient temperature °C") + humidity: float = Field(description="Ambient humidity %") + + +class PrintSettings(BaseModel): + nozzle_temp: float + bed_temp: float + retraction_mm: float + fan_pct: float + first_layer_fan_pct: float + + +class RiskRegion(BaseModel): + location: str # human description, e.g. "underside of the overhang" + risk: str # "sag" | "stringing" | "adhesion" | "warping" | ... + why: str # one-line rationale + anchor_hint: str | None = None # optional geometry hint for the 3D annotation + + +class Advice(BaseModel): + """The Chief Engineer's structured proposal (the LLM's output).""" + + reasoning: str # the EVALUATION of precedent — the load-bearing text + settings: PrintSettings + risks: list[RiskRegion] = [] + + +class LessonEntry(BaseModel): + """Append-only ledger record. Schema is locked (02-ARCHITECTURE.md).""" + + job_id: str + material: str + geometry_type: str + env_temp: float + env_humidity: float + outcome: str + lesson: str + source: str = "earned" # "seed" | "earned" + timestamp: str diff --git a/core/nodes.py b/core/nodes.py new file mode 100644 index 0000000000000000000000000000000000000000..990c0f4e9ac8952fa862f6bda6ad7689d8b1c63e --- /dev/null +++ b/core/nodes.py @@ -0,0 +1,51 @@ +"""Node / swarm mesh — faithful VISUALIZATION of the lab, not a distributed system. + +Mirrors microfactory-lab's `zellij-layout.kdl`: a Chief Engineer hub coordinating +capability nodes. Only the 3D Print node executes real work (the core loop); +the others render as mesh context / available capacity. No FileBridge IPC, no +pi-link, no six live processes (that was the Kaggle failure surface). +""" + +from __future__ import annotations + +from .models import Environment + +# (name, capability, active?) — active node runs the real core loop. +NODES = [ + ("Chief Engineer", "hub / router", True), + ("3D Print", "fdm", True), + ("CNC Mill", "cnc", False), + ("Laser Cutter", "laser", False), + ("Sinter Press", "sinter", False), + ("Metal 3D Print", "metal3d", False), + ("Hub Router", "routing", False), +] + + +def render_node_cards(env: Environment, working: bool = False) -> str: + cards = [] + for name, cap, active in NODES: + if name == "3D Print": + state = "🟠 WORKING" if working else "🟢 ONLINE" + sub = f"{env.temp:.0f}°C · {env.humidity:.0f}% RH" + border = "var(--ao-orange)" + elif active: + state = "🟢 ONLINE" + sub = "coordinating" + border = "var(--ao-orange)" + else: + state = "⚪ IDLE" + sub = "mesh context · available capacity" + border = "var(--ao-outline-dim)" + cards.append( + f"
" + f"
{name.upper()}" + f"{state}
" + f"
{cap} · {sub}
" + ) + return ( + "
CAPABILITY MESH — 1 node executing, " + "others shown as network context
" + "".join(cards) + "
" + ) diff --git a/core/prompts.py b/core/prompts.py new file mode 100644 index 0000000000000000000000000000000000000000..e610c135b53e5f251b9e5eeffcc3c9afa4dad67c --- /dev/null +++ b/core/prompts.py @@ -0,0 +1,105 @@ +"""System-prompt assembly (versioned instruction steering, not activation steering). + +`build_system_prompt()` concatenates: persona + job/env + a Historical +Precedent block of 2-3 retrieved prior jobs. CRITICAL: the prompt asks the +model to EVALUATE applicability — apply, adapt, or set precedent aside, and +say "no close precedent" when nothing fits. It is NOT told to always cite. +""" + +from __future__ import annotations + +from .models import Environment, Job, LessonEntry + +PERSONA = """You are the Chief Engineer: a veteran print-shop master who has run \ +thousands of FDM jobs. You are terse and physical. You think in feeds, temps, \ +cooling, and how the room affects the plastic. You do not hype. You proposed \ +settings; a deterministic Spine will veto anything unsafe, so propose what is \ +*right*, not what is merely safe. + +You reason about PRECEDENT before you decide. You are given similar prior jobs \ +with their conditions and outcomes. Weigh what transfers to THIS job and what \ +does not. If a prior job is close, apply or adapt its lesson and say so. If \ +nothing close applies, say "no close precedent" and reason from material \ +properties. Knowing what you don't know is a strength, not a weakness.""" + +OUTPUT_CONTRACT = """Respond ONLY with valid JSON, no prose outside it, in exactly this shape: +{ + "reasoning": "2-4 sentences. START with your evaluation of the prior jobs: what transfers, what doesn't, and why. Then the decision.", + "settings": { + "nozzle_temp": , "bed_temp": , "retraction_mm": , + "fan_pct": <0-100>, "first_layer_fan_pct": <0-100> + }, + "risks": [ + {"location": "where on the part", "risk": "sag|stringing|adhesion|warping|delamination", + "why": "one line", "anchor_hint": "overhang|bridge|first_layer|corner|null"} + ] +}""" + + +def _precedent_block(lessons: list[tuple[LessonEntry, float]]) -> str: + if not lessons: + return ( + "HISTORICAL PRECEDENT:\n" + " (none) — no prior job matches this material + geometry. " + "Reason from material properties and say so plainly.\n" + ) + lines = ["HISTORICAL PRECEDENT (nearest prior jobs by environment):"] + for i, (e, dist) in enumerate(lessons, 1): + lines.append( + f" [{i}] Job {e.job_id} ({e.source}) — {e.material}/{e.geometry_type} " + f"@ {e.env_temp:.0f}°C, {e.env_humidity:.0f}% RH → {e.outcome} " + f"(env-distance {dist:.2f})\n lesson: {e.lesson}" + ) + return "\n".join(lines) + "\n" + + +def _reference_block(references: list[str]) -> str: + if not references: + return "" + lines = "\n".join(f" - {r}" for r in references) + return ( + "MATERIAL REFERENCE (hard parameters distilled from your slicer/firmware configs):\n" + f"{lines}\nTreat these as bounds/baselines, not precedent.\n\n" + ) + + +def build_system_prompt( + job: Job, + env: Environment, + retrieved: list[tuple[LessonEntry, float]], + references: list[str] | None = None, + policy_note: str | None = None, +) -> str: + policy_block = f"{policy_note}\n\n" if policy_note else "" + return ( + f"{PERSONA}\n\n" + f"CURRENT JOB:\n" + f" material: {job.material}\n" + f" geometry: {job.geometry_type}\n" + f" description: {job.description or '(none given)'}\n\n" + f"ENVIRONMENT (right now in the room):\n" + f" temperature: {env.temp:.0f}°C\n" + f" humidity: {env.humidity:.0f}% RH\n\n" + f"{_reference_block(references or [])}" + f"{_precedent_block(retrieved)}\n" + f"{policy_block}" + f"{OUTPUT_CONTRACT}" + ) + + +# --- reflection prompt (post-job compression) ------------------------------ +REFLECT_SYSTEM = """You are the Chief Engineer distilling a finished job into ONE \ +durable, reusable lesson for your future self. Be specific about material, the \ +conditions, and the lever that mattered. One or two sentences. No fluff. + +Respond ONLY with valid JSON: {"lesson": ""}""" + + +def build_reflect_prompt(job: Job, env: Environment, settings_summary: str, outcome: str) -> str: + return ( + f"JOB: {job.material}/{job.geometry_type} — {job.description or '(no description)'}\n" + f"ROOM: {env.temp:.0f}°C, {env.humidity:.0f}% RH\n" + f"SETTINGS USED: {settings_summary}\n" + f"REAL OUTCOME (human-reported): {outcome}\n\n" + f"Write the lesson." + ) diff --git a/core/reflect.py b/core/reflect.py new file mode 100644 index 0000000000000000000000000000000000000000..a8a2cede11cad9006c94120093e6a48f3616eec4 --- /dev/null +++ b/core/reflect.py @@ -0,0 +1,66 @@ +"""Post-job reflection — the compression step (the thesis). + +Reimplements hubAgent.ts `reviewOutcome()` (pattern-review.md §4), re-keyed +from routing-confidence to environment + geometry. A finished job + a +HUMAN-REPORTED outcome becomes one durable, env-keyed LessonEntry. The model +NEVER judges its own outcome — the outcome is always passed in from the manual +button. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from . import llm +from .ledger import LedgerManager +from .models import Environment, Job, LessonEntry, PrintSettings +from .prompts import REFLECT_SYSTEM, build_reflect_prompt + + +def _settings_summary(s: PrintSettings) -> str: + return ( + f"nozzle {s.nozzle_temp:.0f}°C, bed {s.bed_temp:.0f}°C, " + f"retraction {s.retraction_mm:.1f}mm, fan {s.fan_pct:.0f}%, " + f"first-layer fan {s.first_layer_fan_pct:.0f}%" + ) + + +def _fallback_lesson(job: Job, env: Environment, s: PrintSettings, outcome: str) -> str: + verdict = { + "success": "held up", + "failed_sag": "sagged", + "failed_stringing": "strung", + }.get(outcome, outcome) + return ( + f"{job.material} {job.geometry_type} at {env.temp:.0f}°C/{env.humidity:.0f}% RH " + f"{verdict} with {_settings_summary(s)}." + ) + + +def reflect_on_job( + job: Job, + env: Environment, + settings: PrintSettings, + outcome: str, + ledger: LedgerManager, + job_id: str | None = None, +) -> LessonEntry: + """Distill outcome → lesson, append as an 'earned' entry, return it.""" + raw = llm.chat_json(REFLECT_SYSTEM, build_reflect_prompt(job, env, _settings_summary(settings), outcome)) + lesson = (raw or {}).get("lesson") if isinstance(raw, dict) else None + if not lesson: + lesson = _fallback_lesson(job, env, settings, outcome) + + entry = LessonEntry( + job_id=job_id or f"job-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}", + material=job.material, + geometry_type=job.geometry_type, + env_temp=env.temp, + env_humidity=env.humidity, + outcome=outcome, + lesson=lesson, + source="earned", + timestamp=datetime.now(timezone.utc).isoformat(), + ) + ledger.append(entry) + return entry diff --git a/core/seed_lessons.py b/core/seed_lessons.py new file mode 100644 index 0000000000000000000000000000000000000000..0dcb79c94907c021023a1efabc7dd4a49ae1399a --- /dev/null +++ b/core/seed_lessons.py @@ -0,0 +1,37 @@ +"""Seed the ledger on first run. + +If `data/lessons.jsonl` is empty, load the 12 curated seed lessons +(`data/seed_lessons.jsonl`, source="seed") so the demo starts with a real +capability corpus and the ledger visibly grows seed → earned. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .ledger import LedgerManager +from .models import LessonEntry + +SEED_PATH = Path(__file__).resolve().parent.parent / "data" / "seed_lessons.jsonl" + + +def ensure_seeded(ledger: LedgerManager) -> int: + """Load seeds if the ledger has no entries. Returns count loaded.""" + if ledger.all(): + return 0 + if not SEED_PATH.exists(): + return 0 + loaded = 0 + for line in SEED_PATH.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + entry = LessonEntry(**json.loads(line)) + except Exception: + continue + entry.source = "seed" + ledger.append(entry) + loaded += 1 + return loaded diff --git a/core/spine.py b/core/spine.py new file mode 100644 index 0000000000000000000000000000000000000000..619f5cbee5afe290e2d5d95cc57f4aeede82afbd --- /dev/null +++ b/core/spine.py @@ -0,0 +1,60 @@ +"""The Spine — deterministic veto over the model's proposed settings. + +Reimplements the lab's "LLM proposes, code decides" paradigm (nodeAgent.ts +canDoJob() / 15W breaker; pattern-review.md §2) as hardcoded material bounds. +No power/Joule tracking. The model never overrides these clamps. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .models import PrintSettings + +# Hardcoded hardware/material envelope (Ender-class FDM). +# nozzle/bed in °C; retraction in mm; fan in %. +MATERIAL_BOUNDS: dict[str, dict[str, tuple[float, float]]] = { + "PLA": {"nozzle_temp": (190, 220), "bed_temp": (45, 65)}, + "PETG": {"nozzle_temp": (220, 245), "bed_temp": (70, 90)}, + "ABS": {"nozzle_temp": (230, 260), "bed_temp": (95, 110)}, + "TPU": {"nozzle_temp": (220, 235), "bed_temp": (35, 60)}, +} +COMMON_BOUNDS: dict[str, tuple[float, float]] = { + "retraction_mm": (0.5, 8.0), + "fan_pct": (0.0, 100.0), + "first_layer_fan_pct": (0.0, 30.0), +} + + +@dataclass +class SpineResult: + settings: PrintSettings # clamped, safe-to-run + vetoes: list[str] # human-readable clamp notes + requires_approval: bool # trips the HITL gate + + +class SpineValidator: + def check(self, settings: PrintSettings, material: str) -> SpineResult: + vetoes: list[str] = [] + data = settings.model_dump() + + bounds = dict(COMMON_BOUNDS) + mat = MATERIAL_BOUNDS.get(material.upper()) + if mat: + bounds.update(mat) + else: + vetoes.append(f"Unknown material '{material}' — applying generic limits only.") + + for field, (lo, hi) in bounds.items(): + val = float(data[field]) + if val < lo: + vetoes.append(f"{field} {val:g} below safe {lo:g} for {material} → clamped to {lo:g}.") + data[field] = lo + elif val > hi: + vetoes.append(f"{field} {val:g} above safe {hi:g} for {material} → clamped to {hi:g}.") + data[field] = hi + + clamped = PrintSettings(**data) + # Trip the human gate if the model tried to push a real boundary. + requires_approval = any("clamped" in v for v in vetoes) + return SpineResult(settings=clamped, vetoes=vetoes, requires_approval=requires_approval) diff --git a/core/theme.py b/core/theme.py new file mode 100644 index 0000000000000000000000000000000000000000..c999b08762e959fee9154f5fd74ad10f6ac2f061 --- /dev/null +++ b/core/theme.py @@ -0,0 +1,271 @@ +"""Astrometrics OS — the Off-Brand visual layer (badge: 🎨 Off-Brand). + +Single source of truth for the custom look, kept OUT of app.py so it stays a +removable layer: `from core.theme import THEME, CSS, rule, command_bar`. Every +token traces to ../DESIGN.md §1 — no ad-hoc colors. Aesthetic: Futuristic +Brutalism / LCARS terminal — Command Orange on Void Black, monospace, square +corners, signal-colored panes (orange=active, purple=cognition, yellow=human +gate, red=veto, green=pass). + +Two parts: + THEME — a gr.themes.Base that recolors Gradio's OWN component variables (so + buttons/inputs/blocks obey the palette, not just our CSS overrides). + CSS — the structural chrome CSS does on top can't: the LCARS command bar, + ribbon tabs, rule headers, elbow corners, focused-pane borders. +""" + +from __future__ import annotations + +import gradio as gr + +# --- canonical palette (DESIGN.md §1) ---------------------------------------- +VOID = "#131313" +SURFACE = "#1f1f1f" +SURFACE_HI = "#2a2a2a" +ORANGE = "#ff9c00" +ORANGE_SOFT = "#ffc384" +PURPLE = "#c2c1ff" +BLUE = "#5fafff" +YELLOW = "#ffff66" +TEXT = "#e2e2e2" +OUTLINE = "#a28d79" +OUTLINE_DIM = "#544433" +GREEN = "#82c88c" +RED = "#ffb4ab" + +_MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" + + +def astrometrics_theme() -> gr.themes.Base: + """Recolor Gradio's component variables to the Astrometrics palette. Both the + base and *_dark variants are set so the deck looks identical regardless of the + browser's light/dark preference (a Space defaults to system).""" + t = gr.themes.Base( + primary_hue=gr.themes.colors.orange, + secondary_hue=gr.themes.colors.blue, + neutral_hue=gr.themes.colors.stone, + font=[_MONO], font_mono=[_MONO], + radius_size=gr.themes.sizes.radius_none, + ) + pairs = dict( + body_background_fill=VOID, body_background_fill_dark=VOID, + body_text_color=TEXT, body_text_color_dark=TEXT, + body_text_color_subdued=OUTLINE, body_text_color_subdued_dark=OUTLINE, + background_fill_primary=VOID, background_fill_primary_dark=VOID, + background_fill_secondary=SURFACE, background_fill_secondary_dark=SURFACE, + block_background_fill=SURFACE, block_background_fill_dark=SURFACE, + block_border_color=OUTLINE_DIM, block_border_color_dark=OUTLINE_DIM, + block_label_background_fill=VOID, block_label_background_fill_dark=VOID, + block_label_text_color=ORANGE, block_label_text_color_dark=ORANGE, + block_title_text_color=ORANGE, block_title_text_color_dark=ORANGE, + border_color_primary=OUTLINE_DIM, border_color_primary_dark=OUTLINE_DIM, + color_accent_soft=SURFACE_HI, color_accent_soft_dark=SURFACE_HI, + input_background_fill=VOID, input_background_fill_dark=VOID, + input_border_color=OUTLINE_DIM, input_border_color_dark=OUTLINE_DIM, + input_border_color_focus=ORANGE, input_border_color_focus_dark=ORANGE, + button_primary_background_fill=ORANGE, button_primary_background_fill_dark=ORANGE, + button_primary_text_color=VOID, button_primary_text_color_dark=VOID, + button_secondary_background_fill=SURFACE, button_secondary_background_fill_dark=SURFACE, + button_secondary_text_color=ORANGE, button_secondary_text_color_dark=ORANGE, + button_secondary_border_color=OUTLINE_DIM, button_secondary_border_color_dark=OUTLINE_DIM, + slider_color=ORANGE, slider_color_dark=ORANGE, + link_text_color=BLUE, link_text_color_dark=BLUE, + ) + return t.set(**pairs) + + +THEME = astrometrics_theme() + +# --- structural chrome the theme can't express ------------------------------- +CSS = f""" +/* kill the stock Gradio tells */ +footer, .gradio-container footer {{ display:none !important; }} +.gradio-container {{ padding-top:4px; }} + +/* deck frame around the whole console */ +#ce-deck {{ border:1px solid var(--ao-outline-dim); border-top:2px solid var(--ao-orange); + padding:14px 16px 4px; background:linear-gradient(180deg, rgba(255,156,0,0.03), transparent 120px); }} + +/* flatten Gradio's boxed labels into bare instrument labels */ +.block {{ box-shadow:none !important; }} +.block > .label-wrap, span[data-testid="block-info"] {{ background:transparent !important; + border:none !important; padding:0 0 2px !important; }} +.block .label-wrap span, label > span:first-child {{ background:transparent !important; }} + +/* a "console" column: thin orange spine down the left */ +.ce-console {{ border-left:2px solid var(--ao-orange) !important; padding-left:12px !important; + background:linear-gradient(90deg, rgba(255,156,0,0.04), transparent 40px) !important; }} +""" + +CSS += f""" +:root, .gradio-container {{ + --ao-void:{VOID}; --ao-surface:{SURFACE}; --ao-surface-hi:{SURFACE_HI}; + --ao-orange:{ORANGE}; --ao-orange-soft:{ORANGE_SOFT}; --ao-purple:{PURPLE}; + --ao-blue:{BLUE}; --ao-yellow:{YELLOW}; --ao-text:{TEXT}; + --ao-outline:{OUTLINE}; --ao-outline-dim:{OUTLINE_DIM}; --ao-green:{GREEN}; --ao-red:{RED}; +}} +.gradio-container {{ background:var(--ao-void) !important; max-width:1180px !important; }} +* {{ font-family:{_MONO} !important; }} +.gradio-container .prose, .gradio-container p, .gradio-container span, +.gradio-container li {{ color:var(--ao-text); }} + +/* ── LCARS COMMAND BAR ─────────────────────────────────────────────── */ +#ce-cmdbar {{ display:flex; align-items:stretch; gap:0; margin-bottom:14px; }} +#ce-cmdbar .ce-elbow {{ background:var(--ao-orange); color:var(--ao-void); + font-weight:800; letter-spacing:3px; text-transform:uppercase; font-size:15px; + padding:10px 22px 10px 16px; border-top-right-radius:18px; white-space:nowrap; + display:flex; align-items:center; }} +#ce-cmdbar .ce-bar {{ flex:1; background:var(--ao-surface-hi); + border-bottom:2px solid var(--ao-orange); display:flex; align-items:center; + justify-content:flex-end; gap:18px; padding:0 16px; font-size:11px; + letter-spacing:1px; text-transform:uppercase; }} +#ce-cmdbar .ce-bar b {{ color:var(--ao-orange); }} +#ce-cmdbar #ce-clock {{ color:var(--ao-blue); }} +.ce-sub {{ color:var(--ao-outline); letter-spacing:.5px; font-size:12px; line-height:1.5; }} +.ce-sub b {{ color:var(--ao-orange-soft); }} + +/* ── RULE HEADERS ── LABEL ──────────── (flex line fills the width) */ +.ce-rule {{ display:flex; align-items:center; gap:10px; color:var(--ao-orange); + text-transform:uppercase; letter-spacing:3px; font-size:12px; font-weight:700; margin:10px 0 4px; }} +.ce-rule::after {{ content:""; flex:1; height:0; border-top:1px solid var(--ao-outline-dim); }} +.ce-rule::before {{ content:"▸"; color:var(--ao-orange); }} + +/* labels as orange uppercase */ +.block .label-wrap span, label > span, span[data-testid="block-info"] {{ + color:var(--ao-orange) !important; text-transform:uppercase; letter-spacing:1.4px; + font-size:11px !important; font-weight:700; }} + +/* square brutalist blocks, dim borders */ +.block, .form, .gr-box, .gr-panel {{ border-radius:0 !important; + border:1px solid var(--ao-outline-dim) !important; background:var(--ao-surface) !important; }} +textarea, input, select, .gr-input {{ background:var(--ao-void) !important; + color:var(--ao-text) !important; border-radius:0 !important; }} +textarea:focus, input:focus {{ border-color:var(--ao-orange) !important; box-shadow:none !important; }} + +/* ── RIBBON TABS (LCARS elbows) ── each tab a rounded ribbon; selected = solid orange */ +.tab-nav, .tab-container {{ border-bottom:2px solid var(--ao-orange) !important; gap:6px !important; + padding-bottom:0 !important; }} +.tab-nav button, .tab-container button, button[role="tab"] {{ + color:var(--ao-orange-soft) !important; background:var(--ao-outline-dim) !important; + text-transform:uppercase; letter-spacing:2px; font-size:12px !important; font-weight:800; + border:none !important; border-radius:14px 14px 0 0 !important; padding:8px 22px !important; + margin:0 !important; }} +.tab-nav button:hover, button[role="tab"]:hover {{ color:var(--ao-void) !important; + background:var(--ao-orange-soft) !important; }} +.tab-nav button.selected, button[role="tab"][aria-selected="true"], +.tab-container button.selected {{ background:var(--ao-orange) !important; color:#131313 !important; }} + +/* ── file dropzone as an LCARS bay ── */ +.ce-drop, .ce-drop .center, .ce-drop [data-testid="block-label"] {{ background:var(--ao-void) !important; }} +.ce-drop {{ border:1px dashed var(--ao-outline) !important; border-radius:0 !important; }} +.ce-drop:hover {{ border-color:var(--ao-orange) !important; }} +.ce-drop .wrap, .ce-drop .or, .ce-drop button {{ color:var(--ao-orange-soft) !important; + text-transform:uppercase; letter-spacing:1px; font-size:12px; }} + +/* sliders + ranges */ +input[type=range] {{ accent-color:var(--ao-orange) !important; }} + +/* buttons: square, uppercase, letter-spaced */ +button.primary, .gr-button-primary, button[variant="primary"] {{ + background:var(--ao-orange) !important; color:var(--ao-void) !important; + font-weight:800 !important; border:none !important; border-radius:0 !important; + letter-spacing:1.5px !important; text-transform:uppercase; }} +button.secondary {{ background:var(--ao-surface) !important; color:var(--ao-orange) !important; + border:1px solid var(--ao-outline-dim) !important; border-radius:0 !important; + text-transform:uppercase; letter-spacing:1px !important; }} +button.secondary:hover {{ border-color:var(--ao-orange) !important; }} + +/* ── SIGNAL-CODED PANELS (left accent = meaning) ── */ +#ce-reasoning {{ border-left:3px solid var(--ao-orange) !important; padding:10px 14px !important; }} +#ce-precedent .ce-panel, .ce-cognition {{ border-left:3px solid var(--ao-purple); }} +#ce-confirm {{ background:var(--ao-orange) !important; color:var(--ao-void) !important; + font-weight:800 !important; border:none !important; border-radius:0 !important; + text-transform:uppercase; letter-spacing:2px !important; }} +/* outcome / sim buttons get a quieter deck look */ +#ce-sim button, #ce-outcomes button {{ background:var(--ao-surface) !important; + border:1px solid var(--ao-outline-dim) !important; color:var(--ao-text) !important; }} + +/* JSON + code blocks on the void */ +.gr-json, .cm-editor, .code-wrap {{ background:var(--ao-void) !important; }} +footer {{ color:var(--ao-outline-dim) !important; }} + +/* ── LCARS pill radio groups (part type, material) ── */ +.ce-pills fieldset, .ce-pills .wrap {{ display:flex !important; flex-wrap:wrap; gap:6px; + border:none !important; background:transparent !important; }} +.ce-pills label {{ border:1px solid var(--ao-outline) !important; border-radius:999px !important; + padding:5px 15px !important; cursor:pointer; background:var(--ao-surface) !important; + color:var(--ao-text) !important; text-transform:uppercase; letter-spacing:.5px; + font-size:12px !important; font-weight:600; transition:all .12s; margin:0 !important; }} +.ce-pills label span {{ color:var(--ao-text) !important; }} /* unselected: light, legible */ +.ce-pills label:hover {{ border-color:var(--ao-orange) !important; color:var(--ao-orange-soft) !important; }} +.ce-pills label:hover span {{ color:var(--ao-orange-soft) !important; }} +.ce-pills input[type=radio] {{ position:absolute !important; opacity:0 !important; width:0 !important; height:0 !important; }} +.ce-pills label:has(input:checked) {{ background:var(--ao-orange) !important; + border-color:var(--ao-orange) !important; font-weight:800; }} +.ce-pills label:has(input:checked), .ce-pills label:has(input:checked) span {{ + color:#131313 !important; }} /* selected: dark on orange, readable */ + +/* ── LCARS pill buttons (mesh sources) ── */ +.ce-pillbtn button {{ border-radius:999px !important; border:1px solid var(--ao-orange) !important; + background:var(--ao-surface) !important; color:var(--ao-orange-soft) !important; box-shadow:none !important; + text-transform:uppercase; letter-spacing:.5px; font-size:12px !important; font-weight:700; }} +.ce-pillbtn button:hover {{ border-color:var(--ao-orange) !important; + background:var(--ao-orange) !important; color:#131313 !important; }} + +/* number inputs as instrument fields */ +.ce-num input {{ background:var(--ao-void) !important; border:1px solid var(--ao-outline-dim) !important; + border-radius:0 !important; color:var(--ao-blue) !important; font-size:15px !important; font-weight:700; }} + +/* accordion headers as LCARS stage bars */ +.gradio-container .label-wrap, button.label-wrap {{ color:var(--ao-orange) !important; }} +details > summary, .accordion .label-wrap {{ text-transform:uppercase; letter-spacing:2px; }} + +/* optional CRT scanline — off by default; add class 'ce-crt' to body to enable */ +body.ce-crt .gradio-container::before {{ content:""; position:fixed; inset:0; z-index:9999; + pointer-events:none; background:repeating-linear-gradient(0deg, + rgba(0,0,0,0.10) 0px, rgba(0,0,0,0.10) 1px, transparent 1px, transparent 3px); }} +""" + +# tiny LCARS clock — updates the command-bar time; no-op if the element is absent +CLOCK_JS = """ +() => { + const tick = () => { + const el = document.getElementById('ce-clock'); + if (el) { + const d = new Date(); + el.textContent = d.toISOString().slice(11,19) + ' UTC'; + } + }; + tick(); setInterval(tick, 1000); +} +""" + + +def rule(label: str) -> str: + """An LCARS rule header: ``▸ LABEL ─────────────`` (line fills via CSS).""" + return f"
{label}
" + + +def footer_bar() -> str: + """Replaces the hidden Gradio footer with an Astrometrics status strip.""" + return ( + "
" + "ASTROMETRICS OS · CHIEF ENGINEER" + "LLM PROPOSES · SPINE DISPOSES · HUMAN CONFIRMS" + "FULLY LOCAL · GEMMA4
" + ) + + +def command_bar(backend_status: str) -> str: + """The top LCARS command strip: orange elbow title + status/clock bar.""" + return ( + "
" + "
▚ THE CHIEF ENGINEER
" + "
" + f"{backend_status}" + "BUILD SMALL · BACKYARD AI" + "--:--:-- UTC" + "
" + ) diff --git a/core/viewer.py b/core/viewer.py new file mode 100644 index 0000000000000000000000000000000000000000..bef50514d43209f073bba9284c68cc53e399873a --- /dev/null +++ b/core/viewer.py @@ -0,0 +1,309 @@ +"""3D preview helpers + G-code readout. + +gr.Model3D gives orbit/zoom/pan for free. We keep trimesh use minimal: pick a +sample mesh for the geometry_type (or use an uploaded one) and, where it helps, +locate the steepest overhang so a risk callout anchors to something real. Risk +regions render as labeled callouts beside the interactive model — robust, and +the model stays interactive. No slicing, no simulation. +""" + +from __future__ import annotations + +from pathlib import Path + +from .models import PrintSettings, RiskRegion + +ASSETS = Path(__file__).resolve().parent.parent / "assets" +DATA = Path(__file__).resolve().parent.parent / "data" +_SAMPLE = { + "overhang": "overhang.glb", + "bridge": "bridge.glb", + "vase": "vase.glb", + "stringing": "cube.glb", + "adhesion": "cube.glb", +} + + +def sample_mesh(geometry_type: str) -> str | None: + path = ASSETS / _SAMPLE.get(geometry_type, "cube.glb") + return str(path) if path.exists() else None + + +PART_LABEL = { + "overhang": "OVERHANG TEST", "bridge": "BRIDGE TEST", "vase": "VASE (THIN WALL)", + "stringing": "STRINGING TOWER", "adhesion": "ADHESION CUBE", +} + + +def benchy_mesh() -> str | None: + """The CC0 3DBenchy, IF dropped into assets/benchy.glb (can't fetch on a + locked Space; Kyle adds it locally). None → caller shows a 'add the file' hint.""" + p = ASSETS / "benchy.glb" + return str(p) if p.exists() else None + + +_PRIMITIVES = ("box", "cylinder", "cone", "sphere") +# which geometry_type (the model's reasoning class) each primitive maps to +_PRIM_GEO = {"box": "adhesion", "cylinder": "vase", "cone": "overhang", "sphere": "overhang"} + + +def generate_primitive(kind: str, size_mm: float = 30.0) -> tuple[str, str]: + """Generate a parametric primitive with trimesh → (mesh_path, geometry_type). + Offline, zero new deps. Saved to data/_generated.glb for the preview/slicer.""" + import trimesh + + s = max(5.0, float(size_mm)) + kind = kind if kind in _PRIMITIVES else "box" + if kind == "box": + m = trimesh.creation.box(extents=(s, s, s)) + elif kind == "cylinder": + m = trimesh.creation.cylinder(radius=s / 2, height=s) + elif kind == "cone": + m = trimesh.creation.cone(radius=s / 2, height=s) + else: + m = trimesh.creation.icosphere(radius=s / 2) + m.apply_translation(-m.bounds[0]) # sit on the bed (z ≥ 0) + DATA.mkdir(exist_ok=True) + out = DATA / "_generated.glb" + m.export(out) + return str(out), _PRIM_GEO[kind] + + +def settings_panel_html(settings: PrintSettings, material: str) -> str: + """Render proposed settings as an LCARS instrument readout (not raw JSON).""" + rows = [ + ("NOZZLE", f"{settings.nozzle_temp:.0f}", "°C"), + ("BED", f"{settings.bed_temp:.0f}", "°C"), + ("RETRACTION", f"{settings.retraction_mm:.1f}", "mm"), + ("FAN", f"{settings.fan_pct:.0f}", "%"), + ("FIRST-LAYER FAN", f"{settings.first_layer_fan_pct:.0f}", "%"), + ] + cells = "".join( + "
" + f"{name}" + f"{val}" + f" {unit}
" + for name, val, unit in rows + ) + return ( + "
" + f"
PROPOSED SETTINGS · {material} (SPINE-VALIDATED)
" + cells + "
" + ) + + +def gcode_panel_html(settings: PrintSettings, material: str) -> str: + """The g-code readout as a styled terminal panel (not gr.Code).""" + body = gcode_readout(settings, material).replace("<", "<") + return ( + "
" + "
" + "START G-CODE (HEADER TIED TO SETTINGS)
" + f"
{body}
" + ) + + +def steepest_overhang_hint(mesh_path: str | None) -> str | None: + """Optional: report where the steepest downward-facing face sits (minimal trimesh).""" + if not mesh_path or not Path(mesh_path).exists(): + return None + try: + import numpy as np + import trimesh + + mesh = trimesh.load(mesh_path, force="mesh") + import math + normals = mesh.face_normals + downward = normals[:, 2] # -1 = fully downward-facing + idx = int(downward.argmin()) + steep = -float(downward[idx]) # 0..1; 1 = horizontal ceiling + if steep > 0.30: # meaningfully overhanging + angle = math.degrees(math.asin(min(1.0, steep))) # overhang angle from vertical + c = mesh.triangles_center[idx] + note = f"steepest overhang ~{angle:.0f}° near (x={c[0]:.0f}, y={c[1]:.0f}, z={c[2]:.0f}) mm" + if angle >= 50: # past the usual support threshold + note += " — likely needs supports (or reorient to reduce it)" + return note + except Exception: + return None + return None + + +def risk_callouts_html(risks: list[RiskRegion], geo_hint: str | None = None) -> str: + if not risks: + body = "
No failure regions flagged.
" + else: + rows = [] + for r in risks: + anchor = f" · {r.anchor_hint}" if r.anchor_hint else "" + rows.append( + f"
" + f"⚠ {r.risk.upper()} " + f"@ {r.location}{anchor}" + f"
{r.why}
" + ) + body = "".join(rows) + if geo_hint: + body += f"
↳ {geo_hint}
" + return f"
PREDICTED FAILURE REGIONS
{body}
" + + +_VERDICT = {"failed_sag": "sagged", "failed_stringing": "strung", "success": "printed clean"} + + +def precedent_eval_html(retrieved, env) -> str: + """The load-bearing moment, narrated deterministically from the env delta. + + Makes the "humidity is higher than the job that sagged" framing reliable on + screen even before the model's prose — the model's reasoning then adds to it. + """ + if not retrieved: + return ( + "
" + "
NO CLOSE PRECEDENT
" + "
Nothing in the ledger matches this material + geometry. " + "Reasoning from material properties — and saying so. Knowing what it doesn't know is the point.
" + ) + e, dist = retrieved[0] + dt = env.temp - e.env_temp + dh = env.humidity - e.env_humidity + + def phrase(delta, unit, hi, lo): + if abs(delta) < 1: + return f"about the same {unit}" + return f"{abs(delta):.0f}{unit} {hi if delta > 0 else lo}" + + t_ph = phrase(dt, "°C", "warmer", "cooler") + h_ph = phrase(dh, " pts", "more humid", "drier") + verdict = _VERDICT.get(e.outcome, e.outcome) + + failed = e.outcome.startswith("failed") + worse = (e.geometry_type in ("overhang", "bridge") and dt > 1) or ("string" in e.geometry_type and dh > 1) + if failed and worse: + impl = "Conditions are worse than that failure — expect the same risk and adjusting to prevent it." + col = "var(--ao-red)" + elif failed and not worse: + impl = "Conditions are better than that failure — the original cause is less likely now." + col = "var(--ao-orange)" + else: + impl = "That job succeeded under similar conditions — leaning on what worked." + col = "var(--ao-green)" + + return ( + f"
" + f"
PRECEDENT EVALUATION
" + f"
Nearest prior job {e.job_id} ({e.source}) — " + f"{e.material}/{e.geometry_type} {verdict} at {e.env_temp:.0f}°C / {e.env_humidity:.0f}% RH.
" + f"Right now it's {t_ph} and {h_ph} (env-distance {dist:.2f}).
{impl}
" + ) + + +_POS_SHRINK = {"ABS": "high", "PETG": "moderate", "PLA": "low", "TPU": "low"} + + +def placement_callout(material: str, bed_position: str) -> str: + """Deterministic build-plate placement risk + suggested alignment. Bed edges/ + corners run cooler + draftier → warp/adhesion risk, worst for high-shrink + materials. Returns an HTML block (or '' when centered/low-risk).""" + pos = (bed_position or "center").lower() + if pos == "center": + return "" + shrink = _POS_SHRINK.get(material.upper(), "moderate") + risky = shrink in ("high", "moderate") + col = "var(--ao-red)" if (pos == "corner" and risky) else ( + "var(--ao-orange)" if risky else "var(--ao-outline)") + sev = "corner" if pos == "corner" else "edge" + body = (f"{material} has {shrink} shrink; a {sev} of the heated bed runs cooler and " + f"draftier, so the first layer can lift and the part can warp.") + fix = ("Center the part on the bed" + ( + ", add a brim, and an enclosure if you have one." if shrink == "high" + else " and add a brim." if risky else "; minor risk for this material.")) + return ( + f"
" + f"⌖ PLACEMENT · {sev.upper()} " + f"{body}" + f"
↳ suggested: {fix}
" + ) + + +def gcode_readout(settings: PrintSettings, material: str) -> str: + """Short snippet whose header lines come from the proposed settings.""" + return "\n".join([ + f"; Chief Engineer — start g-code for {material} (header tied to recommendation)", + f"M140 S{settings.bed_temp:.0f} ; set bed", + f"M104 S{settings.nozzle_temp:.0f} ; set nozzle", + f"M190 S{settings.bed_temp:.0f} ; wait for bed", + f"M109 S{settings.nozzle_temp:.0f} ; wait for nozzle", + "G28 ; home all axes", + "G92 E0 ; reset extruder", + f"M106 S{settings.first_layer_fan_pct * 2.55:.0f} ; first-layer fan {settings.first_layer_fan_pct:.0f}%", + f"; retraction {settings.retraction_mm:.1f} mm ; cruise fan {settings.fan_pct:.0f}%", + "; … (toolpath generated by your slicer, never by the model)", + ]) + + +# --- learning-loop renderers (the primary demo surface) -------------------- + +def quality_curve_html(trajectory: list[float], threshold: float = 0.7) -> str: + """Astrometrics bar chart of quality per iteration — the 'it gets better' shot.""" + if not trajectory: + return "
run the loop →
" + bars = [] + for i, q in enumerate(trajectory, 1): + h = max(4, int(q * 120)) + col = "var(--ao-green)" if q >= threshold else ("var(--ao-orange)" if q >= 0.5 else "var(--ao-red)") + bars.append( + f"
" + f"
{q:.2f}
" + f"
" + f"
{i}
" + ) + line_top = int((1 - threshold) * 120) + 14 + return ( + "
" + "
PRINT QUALITY PER ITERATION " + f"(green = clean ≥ {threshold:.2f})
" + "
" + f"
" + + "".join(bars) + "
" + ) + + +def iteration_log_html(records) -> str: + rows = [] + for r in records: + clean = r.result.outcome == "success" + col = "var(--ao-green)" if clean else "var(--ao-red)" + rows.append( + f"
" + f"#{r.n} {r.result.outcome} · q={r.result.quality:.2f} · " + f"noz {r.settings.nozzle_temp:.0f}°C bed {r.settings.bed_temp:.0f}°C fan {r.settings.fan_pct:.0f}% " + f"ret {r.settings.retraction_mm:.1f}mm" + + (" · spine-clamped" if r.clamped else "") + + f"
↳ {r.learned}
" + ) + return "".join(rows) + + +def policy_cell_html(cell, key: str) -> str: + if cell is None or not getattr(cell, "offsets", None): + return ("
" + f"POLICY CELL {key}: untrained (baseline only).
") + deltas = " ".join(f"{k} {v:+g}" for k, v in cell.offsets.items()) + return ( + f"
" + f"
LEARNED POLICY CELL · {key}
" + f"
offsets vs baseline: {deltas}
" + f"
{cell.trials} runs · {cell.success_rate*100:.0f}% clean
" + ) diff --git a/core/widgets.py b/core/widgets.py new file mode 100644 index 0000000000000000000000000000000000000000..0933a5550bd0a0a989e1bd73515842ba22ccb6d2 --- /dev/null +++ b/core/widgets.py @@ -0,0 +1,212 @@ +"""Custom visual-beat widgets — live in-browser instruments rendered into gr.HTML. + +The marquee one is the VIRTUAL PRINTER: the real mesh sliced into cross-sections +(reusing sim.virtual_printer) and animated *rising* on a , client-side, so +it's a live instrument in the cockpit rather than a pre-rendered GIF. Astrometrics +palette; zero new deps; fully offline/Space-safe. + +Gradio gotcha handled here: a +""" diff --git a/data/seed_lessons.jsonl b/data/seed_lessons.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..99af57146184f74744e5a463b0e22151d8d27d81 --- /dev/null +++ b/data/seed_lessons.jsonl @@ -0,0 +1,12 @@ +{"job_id": "seed-001", "material": "PLA", "geometry_type": "stringing", "env_temp": 22.0, "env_humidity": 45.0, "outcome": "failed_stringing", "lesson": "PLA strung at 215C in moderate humidity. Dropping nozzle to ~200C and nudging retraction up cleared it. With PLA, temperature is usually the bigger lever than retraction distance.", "source": "seed", "timestamp": "2026-05-20T10:00:00Z"} +{"job_id": "seed-002", "material": "PLA", "geometry_type": "stringing", "env_temp": 24.0, "env_humidity": 70.0, "outcome": "failed_stringing", "lesson": "High ambient humidity made PLA string even at a sane 205C. Settings tweaks did little; the real fix was drying the filament. Above ~60% humidity, suspect moisture before chasing retraction.", "source": "seed", "timestamp": "2026-05-20T10:05:00Z"} +{"job_id": "seed-003", "material": "PLA", "geometry_type": "overhang", "env_temp": 23.0, "env_humidity": 40.0, "outcome": "success", "lesson": "Steep PLA overhang printed clean with cooling fan at 100%. PLA likes maximum cooling on overhangs to freeze the layer before it droops.", "source": "seed", "timestamp": "2026-05-20T10:10:00Z"} +{"job_id": "seed-004", "material": "PLA", "geometry_type": "overhang", "env_temp": 28.0, "env_humidity": 50.0, "outcome": "failed_sag", "lesson": "PLA overhang sagged in a warm room even at full fan. Warm ambient air weakens part cooling; on hot days drop a few degrees and ensure unobstructed airflow, or add support past ~50 degrees of overhang.", "source": "seed", "timestamp": "2026-05-20T10:15:00Z"} +{"job_id": "seed-005", "material": "PLA", "geometry_type": "bridge", "env_temp": 22.0, "env_humidity": 42.0, "outcome": "success", "lesson": "Short PLA bridge under ~50mm printed flat with full cooling and slightly increased bridge flow. Max fan plus brisk bridge speed is the reliable combo for PLA spans.", "source": "seed", "timestamp": "2026-05-20T10:20:00Z"} +{"job_id": "seed-006", "material": "PLA", "geometry_type": "adhesion", "env_temp": 19.0, "env_humidity": 38.0, "outcome": "failed_sag", "lesson": "First layer wouldn't stick in a cold room. Bumping bed to ~60C, slowing the first layer, and turning the fan off for the opening layers fixed adhesion. Cold ambient hurts first-layer bonding most.", "source": "seed", "timestamp": "2026-05-20T10:25:00Z"} +{"job_id": "seed-007", "material": "PETG", "geometry_type": "stringing", "env_temp": 23.0, "env_humidity": 48.0, "outcome": "failed_stringing", "lesson": "PETG strung badly at 245C. Lowering to ~232C, raising travel speed, and enabling wipe-before-travel cleaned it up. PETG strings more than PLA by nature, so live at the low end of its temp range.", "source": "seed", "timestamp": "2026-05-20T10:30:00Z"} +{"job_id": "seed-008", "material": "PETG", "geometry_type": "stringing", "env_temp": 25.0, "env_humidity": 65.0, "outcome": "failed_stringing", "lesson": "PETG is hygroscopic and strung heavily in humid air despite good settings. Drying the spool was the only durable fix. Above ~60% humidity, dry PETG first and treat settings as secondary.", "source": "seed", "timestamp": "2026-05-20T10:35:00Z"} +{"job_id": "seed-009", "material": "PETG", "geometry_type": "overhang", "env_temp": 23.0, "env_humidity": 45.0, "outcome": "failed_sag", "lesson": "PETG overhang drooped when I ran the fan low for strength. PETG needs a cooling compromise: ~30-50% fan generally, bumped to 60-70% specifically over overhangs and bridges.", "source": "seed", "timestamp": "2026-05-20T10:40:00Z"} +{"job_id": "seed-010", "material": "PETG", "geometry_type": "adhesion", "env_temp": 22.0, "env_humidity": 47.0, "outcome": "failed_sag", "lesson": "PETG layers came out brittle with the fan too high. Dropping cooling to ~30% and raising temp 5C restored layer bonding. Over-cooling PETG causes delamination; it is the opposite of PLA.", "source": "seed", "timestamp": "2026-05-20T10:45:00Z"} +{"job_id": "seed-011", "material": "PETG", "geometry_type": "bridge", "env_temp": 24.0, "env_humidity": 44.0, "outcome": "success", "lesson": "PETG bridge held by temporarily raising fan to ~70% for the span only, then returning to 40%. Targeted cooling bursts beat globally high fan, which would weaken the rest of the part.", "source": "seed", "timestamp": "2026-05-20T10:50:00Z"} +{"job_id": "seed-012", "material": "PETG", "geometry_type": "stringing", "env_temp": 21.0, "env_humidity": 40.0, "outcome": "success", "lesson": "Clean PETG print at 230C with gentle retraction (moderate distance, ~40mm/s) in dry conditions. PETG stays molten longer than PLA, so gentle retraction plus fast travel beats aggressive retraction, which clogs.", "source": "seed", "timestamp": "2026-05-20T10:55:00Z"} diff --git a/ingest/README.md b/ingest/README.md new file mode 100644 index 0000000000000000000000000000000000000000..485728a15c16b7b5794928ea563ba52091c41ae4 --- /dev/null +++ b/ingest/README.md @@ -0,0 +1,36 @@ +# Knowledge Ingestion + +Turns the sources in `../docs/KNOWLEDGE-SOURCES.md` into Chief Engineer knowledge. +Two kinds, matching how each source actually carries information: + +| Output | From | Goes to | Used as | +|--------|------|---------|---------| +| **Reference facts** | Prusa `.ini`, Klipper `.cfg`, Marlin `.h` (data only) | `data/references.jsonl` | injected into the prompt as a *Material Reference* block | +| **Candidate lessons** | research distilled into env-keyed JSONL rows | the ledger (`source="ingested"`) | retrieved exactly like seed/earned lessons | + +## Local distiller (deterministic, runs offline) + +```bash +uv run python -m ingest.run # uses ingest/samples/ +uv run python -m ingest.run --dir /path/to/your/marlin_klipper_prusa_files +``` + +Replace `ingest/samples/*` with your real configs and a `*lessons*.jsonl` of +research-distilled rows. The sample files are illustrative and clearly marked. + +> **License-safe:** we ingest profile *data* (INI/cfg/#define values). We never +> import or link OrcaSlicer/PrusaSlicer code (AGPL-3.0). + +## Heavy datasets + fine-tune → Modal (`modal_app.py`) + +`ablam/gcode` (>6GB) and `3DTimeDataset/3DTime` are too big for the Space; they +run on Modal. `modal_app.py` is a **stub to be replaced by Kyle's MCP-hackathon +ingestion code** (which already parses training data for multi-agent use). +Off the critical demo path; this is where the Modal bonus + the frontier +fine-tune live. + +```bash +uv pip install modal datasets # not in the Space requirements +modal token set +modal run ingest/modal_app.py::sample_gcode +``` diff --git a/ingest/__init__.py b/ingest/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..836ec55f1653b0b986bad2453fb1db4d709c79d5 --- /dev/null +++ b/ingest/__init__.py @@ -0,0 +1,2 @@ +"""Knowledge ingestion — distill structured 3D-printing sources into Chief +Engineer references + candidate lessons. See ingest/README.md.""" diff --git a/ingest/distill.py b/ingest/distill.py new file mode 100644 index 0000000000000000000000000000000000000000..5b393c866a1dd42dbff68bfc7c3176381bce1f51 --- /dev/null +++ b/ingest/distill.py @@ -0,0 +1,168 @@ +"""Knowledge distiller — turn structured sources into Chief Engineer knowledge. + +Two outputs, matching how the sources actually carry information: + +1. **Reference facts** (material baselines from slicer/firmware configs) → + `data/references.jsonl`. Injected into the prompt as a "Material Reference" + block. These are NOT env-keyed precedent — they're hard parameters. +2. **Candidate lessons** (research distilled into env-keyed rows) → appended to + the ledger as `source="ingested"`, retrieved exactly like seed/earned lessons. + +Deterministic + stdlib (configparser/regex). License-safe: we ingest *profile +data*, never slicer code. No OrcaSlicer/PrusaSlicer imports. +""" + +from __future__ import annotations + +import configparser +import json +import re +from pathlib import Path + +from pydantic import BaseModel + +from core.ledger import LedgerManager +from core.models import LessonEntry, MATERIALS + +DATA = Path(__file__).resolve().parent.parent / "data" +REFERENCES_PATH = DATA / "references.jsonl" + + +class ReferenceFact(BaseModel): + material: str + param: str # "nozzle_temp" | "bed_temp" | "retraction_mm" | "max_temp" | ... + value: float + source: str # where it came from (filename / profile) + + +def _material_of(text: str) -> str | None: + up = text.upper() + for m in MATERIALS: + if m in up: + return m + if "ACETAL" in up or "DELRIN" in up: + return None + return None + + +# --- 3D-ADAM defect taxonomy (encoded from the paper's defect classes) ------ +# RAG-ready risk knowledge keyed to the geometry/condition that triggers it. +DEFECT_TAXONOMY = { + "warping": {"risk": "warping", "trigger": "high-shrinkage material on a cool/draughty bed", + "note": "corners lift as lower layers cool and contract; raise bed temp, enclose, slow first layer."}, + "under_extrusion": {"risk": "under_extrusion", "trigger": "too-low temp / too-fast flow / partial clog", + "note": "gaps and weak walls; raise temp or slow down, check for moisture and clogs."}, + "stringing": {"risk": "stringing", "trigger": "wet filament or hot travel", + "note": "fine whiskers across gaps; dry filament first, then lower temp / tune retraction."}, + "cracking": {"risk": "delamination", "trigger": "over-cooling on tall prints / poor layer bond", + "note": "layers split under stress; reduce fan, raise temp, enclose for ABS."}, +} + + +def parse_prusa_ini(path: Path) -> list[ReferenceFact]: + """Extract per-filament baselines from a PrusaSlicer-style INI (data only).""" + facts: list[ReferenceFact] = [] + cp = configparser.ConfigParser(strict=False, interpolation=None) + try: + cp.read(path, encoding="utf-8") + except Exception: + return facts + for section in cp.sections(): + mat = _material_of(section) + if not mat: + continue + src = f"{path.name}:[{section}]" + kv = cp[section] + + def first_num(raw: str | None) -> float | None: + if not raw: + return None + m = re.search(r"-?\d+(?:\.\d+)?", raw.split(",")[0]) + return float(m.group()) if m else None + + for key, param in ( + ("temperature", "nozzle_temp"), + ("first_layer_temperature", "nozzle_temp"), + ("bed_temperature", "bed_temp"), + ("retract_length", "retraction_mm"), + ): + v = first_num(kv.get(key)) + if v is not None: + facts.append(ReferenceFact(material=mat, param=param, value=v, source=src)) + return facts + + +def parse_klipper_cfg(path: Path) -> list[ReferenceFact]: + """Pull safety-relevant limits from a Klipper printer.cfg ([extruder] max_temp).""" + facts: list[ReferenceFact] = [] + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return facts + for section, param in (("extruder", "max_temp"), ("heater_bed", "bed_max_temp")): + m = re.search(rf"\[{section}\][^\[]*?max_temp\s*[:=]\s*(\d+(?:\.\d+)?)", text, re.S | re.I) + if m: + facts.append(ReferenceFact(material="*", param=param, value=float(m.group(1)), + source=f"{path.name}:[{section}]")) + return facts + + +def parse_marlin_config(path: Path) -> list[ReferenceFact]: + """Pull hotend/bed max temps from a Marlin Configuration.h (#define ... MAXTEMP).""" + facts: list[ReferenceFact] = [] + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return facts + for define, param in (("HEATER_0_MAXTEMP", "max_temp"), ("BED_MAXTEMP", "bed_max_temp")): + m = re.search(rf"#define\s+{define}\s+(\d+)", text) + if m: + facts.append(ReferenceFact(material="*", param=param, value=float(m.group(1)), + source=f"{path.name}:{define}")) + return facts + + +def write_references(facts: list[ReferenceFact]) -> int: + DATA.mkdir(parents=True, exist_ok=True) + with REFERENCES_PATH.open("w", encoding="utf-8") as f: + for fact in facts: + f.write(fact.model_dump_json() + "\n") + return len(facts) + + +def load_references() -> list[ReferenceFact]: + if not REFERENCES_PATH.exists(): + return [] + out = [] + for line in REFERENCES_PATH.read_text(encoding="utf-8").splitlines(): + if line.strip(): + try: + out.append(ReferenceFact(**json.loads(line))) + except Exception: + continue + return out + + +def reference_block(material: str, facts: list[ReferenceFact] | None = None) -> list[str]: + """Compact material-reference lines for the prompt (this material + global limits).""" + facts = facts if facts is not None else load_references() + rel = [f for f in facts if f.material in (material, "*")] + return [f"{f.param}={f.value:g} ({f.source})" for f in rel] + + +def ingest_candidate_lessons(path: Path, ledger: LedgerManager) -> int: + """Append research-distilled env-keyed rows to the ledger as source='ingested'.""" + if not path.exists(): + return 0 + n = 0 + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + row = json.loads(line) + row["source"] = "ingested" + ledger.append(LessonEntry(**row)) + n += 1 + except Exception: + continue + return n diff --git a/ingest/modal_app.py b/ingest/modal_app.py new file mode 100644 index 0000000000000000000000000000000000000000..3c075d123828698d44dc3faaee53ee3180742f3a --- /dev/null +++ b/ingest/modal_app.py @@ -0,0 +1,139 @@ +"""Modal app — heavy-dataset ingestion at scale (the Modal-bonus track). + +SCAFFOLD: the structure + the output CONTRACT are done; the per-dataset *mapping* +(raw row → Chief Engineer record) is where Kyle's MCP-hackathon parser plugs in — +see the MAPPERS registry below. Isolated from the demo path: `modal` is +import-guarded, not in the Space requirements, and nothing in app.py imports this. + +Why Modal: the source datasets (slicer g-code corpora, 3D-ADAM defect sets, etc.) +are too big to pull/parse on a laptop or in the Space. Modal does the heavy +download+parse; the OUTPUT is the same three lanes the local pipeline already +consumes, so the artifacts drop straight into the app. + +THE CONTRACT — three artifact shapes (one JSON object per line): + A) reference fact -> data/references.jsonl + {"material","param","value","source"} (hard params) + B) candidate lesson -> data/_modal_candidate_lessons.jsonl (REVIEW before ledger) + {"job_id","material","geometry_type","env_temp","env_humidity", + "outcome","lesson","source":"ingested","timestamp"} + C) calibration obs -> sim/calibration/observations.modal.jsonl + {"material","geometry_type","env_temp","env_humidity","nozzle_temp", + "bed_temp","retraction_mm","fan_pct","first_layer_fan_pct","outcome","quality"?} + (enums: see core/models.py MATERIALS / GEOMETRY_TYPES / OUTCOMES.) + +Run (after `uv pip install modal datasets` + `modal token set`): + modal run ingest/modal_app.py # all registered datasets + modal run ingest/modal_app.py --dataset 3d-adam --limit 2000 +Lane B is written to a REVIEW file, never straight to the ledger (honesty gate). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +# Import-guarded so the rest of the app/tests never depend on modal being present. +try: + import modal +except Exception: # pragma: no cover + modal = None # type: ignore + +# ── per-dataset mappers: raw HF/GH row -> list of (lane, record dict) ────────── +# TODO(kyle): drop your MCP-hackathon parsing here. Each mapper takes one dataset +# row and returns zero or more ("A"|"B"|"C", record) tuples in the shapes above. +# Honesty rule: only emit Lane-B lessons that are true + directional; emit Lane-C +# only when the row carries the actual settings used. + +def _map_3d_adam(row: dict) -> list[tuple[str, dict]]: + """3D-ADAM-style defect dataset → mostly Lane C (settings + defect outcome).""" + # TODO(kyle): map your columns. Example shape (pseudocode — adjust field names): + # return [("C", {"material": row["material"], "geometry_type": "overhang", + # "env_temp": row.get("ambient_c", 22), "env_humidity": row.get("rh", 45), + # "nozzle_temp": row["nozzle"], "bed_temp": row["bed"], "retraction_mm": row["retract"], + # "fan_pct": row["fan"], "first_layer_fan_pct": 0, + # "outcome": _defect_to_outcome(row["label"]), "quality": row.get("quality")})] + return [] + + +def _map_gcode(row: dict) -> list[tuple[str, dict]]: + """Slicer g-code corpus → Lane A material baselines (temps/retraction).""" + # TODO(kyle): extract M104/M140/retraction etc. into reference facts: + # return [("A", {"material": m, "param": "nozzle_temp", "value": v, "source": "ablam/gcode"})] + return [] + + +# dataset key → (HF dataset id or loader, mapper). Add yours here. +MAPPERS = { + "3d-adam": ("", _map_3d_adam), # TODO(kyle): real id + "gcode": ("ablam/gcode", _map_gcode), +} + +_VALID_LANES = {"A", "B", "C"} + + +if modal is not None: + app = modal.App("chief-engineer-ingest") + image = modal.Image.debian_slim().pip_install( + "datasets", "pyarrow", "huggingface_hub", "pydantic>=2.7") + + @app.function(image=image, timeout=3600) + def distill(dataset: str, limit: int = 1000, split: str = "train") -> dict: + """Heavy step on Modal: load a dataset, map rows → lane records, return them. + Returns {"A":[...], "B":[...], "C":[...], "stats":{...}} for the local + entrypoint to write into the repo's artifact files.""" + from datasets import load_dataset + + if dataset not in MAPPERS: + return {"error": f"unknown dataset '{dataset}'; known: {list(MAPPERS)}"} + hf_id, mapper = MAPPERS[dataset] + ds = load_dataset(hf_id, split=f"{split}[:{limit}]") + out: dict[str, list] = {"A": [], "B": [], "C": []} + for row in ds: + for lane, rec in mapper(dict(row)): + if lane in _VALID_LANES: + out[lane].append(rec) + out["stats"] = {"dataset": dataset, "hf_id": hf_id, "rows": len(ds), + "A": len(out["A"]), "B": len(out["B"]), "C": len(out["C"])} + return out + + @app.local_entrypoint() + def main(dataset: str = "", limit: int = 1000): + """Run locally: fan out to Modal, then write artifacts into the repo in the + three lane shapes. Lane B goes to a REVIEW file (not the ledger).""" + root = Path(__file__).resolve().parent.parent + targets = [dataset] if dataset else list(MAPPERS) + agg: dict[str, list] = {"A": [], "B": [], "C": []} + for d in targets: + res = distill.remote(d, limit) + print(" ", res.get("stats", res)) + for lane in ("A", "B", "C"): + agg[lane].extend(res.get(lane, [])) + + def _write(path: Path, rows: list, append: bool): + if not rows: + return + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a" if append else "w") as f: + for r in rows: + f.write(json.dumps(r) + "\n") + print(f" wrote {len(rows)} → {path.relative_to(root)}") + + _write(root / "data" / "references.jsonl", agg["A"], append=True) # Lane A (safe) + _write(root / "data" / "_modal_candidate_lessons.jsonl", agg["B"], append=False) # Lane B (REVIEW) + _write(root / "sim" / "calibration" / "observations.modal.jsonl", agg["C"], append=False) # Lane C + print("\nNext:") + print(" • REVIEW data/_modal_candidate_lessons.jsonl (honesty gate), then fold into the") + print(" ledger via ingest_candidate_lessons / the ingestion work order.") + print(" • Calibrate: uv run python -m sim.calibrate --data sim/calibration/observations.modal.jsonl") + print(" • make test → make run") + + # FRONTIER (named in the writeup, NOT in-window): fine-tune a small Gemma on the + # accumulated ledger. Kyle's MCP-hackathon training pipeline would plug in here. + # @app.function(gpu="A10G", image=image, timeout=3600) + # def finetune_on_ledger(...): ... + + +if __name__ == "__main__": + print("Modal app scaffold. Install modal + datasets, `modal token set`, then:") + print(" modal run ingest/modal_app.py [--dataset 3d-adam --limit 2000]") + print("Plug your MCP-hackathon parsers into the MAPPERS registry (see docstring).") diff --git a/ingest/run.py b/ingest/run.py new file mode 100644 index 0000000000000000000000000000000000000000..748ce5b45fad52ccd3a689a3d5e840412b4c679e --- /dev/null +++ b/ingest/run.py @@ -0,0 +1,56 @@ +"""CLI: distill the sample sources into references + candidate lessons. + + uv run python -m ingest.run # uses ingest/samples/ + uv run python -m ingest.run --dir /path/to/your/configs + +Replace ingest/samples/ with your real Marlin/Klipper/Prusa files and research +JSONL (or point --dir at them). Heavy datasets (ablam/gcode, 3DTime) go through +ingest/modal_app.py, not here. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from core.ledger import LedgerManager +from ingest.distill import ( + ingest_candidate_lessons, + parse_klipper_cfg, + parse_marlin_config, + parse_prusa_ini, + write_references, +) + +SAMPLES = Path(__file__).resolve().parent / "samples" + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--dir", type=Path, default=SAMPLES, help="folder of source files") + args = ap.parse_args() + d = args.dir + + facts = [] + for p in d.glob("*.ini"): + facts += parse_prusa_ini(p) + for p in d.glob("*.cfg"): + facts += parse_klipper_cfg(p) + for p in list(d.glob("*.h")) + list(d.glob("*config*.txt")): + facts += parse_marlin_config(p) + n_ref = write_references(facts) + print(f"references: {n_ref} facts → data/references.jsonl") + for f in facts: + print(f" · {f.material:4} {f.param:14} {f.value:g} ({f.source})") + + ledger = LedgerManager() + total = 0 + for p in d.glob("*lessons*.jsonl"): + k = ingest_candidate_lessons(p, ledger) + total += k + print(f"candidate lessons: +{k} from {p.name}") + print(f"ledger now: {ledger.count()}") + + +if __name__ == "__main__": + main() diff --git a/ingest/samples/klipper_extruder.cfg b/ingest/samples/klipper_extruder.cfg new file mode 100644 index 0000000000000000000000000000000000000000..3596f14ae2ecd551f26cbd899b796b7b58cc422a --- /dev/null +++ b/ingest/samples/klipper_extruder.cfg @@ -0,0 +1,11 @@ +# SAMPLE (trimmed) Klipper printer.cfg — REPLACE with your real Voron/Ender cfg. +[extruder] +step_pin: PE2 +microsteps: 16 +nozzle_diameter: 0.400 +max_temp: 300 +pressure_advance: 0.040 + +[heater_bed] +heater_pin: PA1 +max_temp: 120 diff --git a/ingest/samples/marlin_config.h b/ingest/samples/marlin_config.h new file mode 100644 index 0000000000000000000000000000000000000000..f7c4df6af36fc4e34ceca30a9e4d725c02dddac1 --- /dev/null +++ b/ingest/samples/marlin_config.h @@ -0,0 +1,5 @@ +// SAMPLE (trimmed) Marlin Configuration.h — REPLACE with your real firmware config. +#define TEMP_SENSOR_0 1 +#define HEATER_0_MAXTEMP 275 +#define BED_MAXTEMP 125 +#define PIDTEMP diff --git a/ingest/samples/prusa_filaments.ini b/ingest/samples/prusa_filaments.ini new file mode 100644 index 0000000000000000000000000000000000000000..a2c8a964b3dc38bfae616b5853679d874b5623f2 --- /dev/null +++ b/ingest/samples/prusa_filaments.ini @@ -0,0 +1,25 @@ +; SAMPLE (trimmed) PrusaSlicer-style filament profile data — REPLACE with your real Anycubic.ini etc. +; We ingest profile DATA only; never slicer code. Values are illustrative. +[filament:Generic PLA] +temperature = 210 +first_layer_temperature = 215 +bed_temperature = 60 +retract_length = 5 + +[filament:Generic PETG] +temperature = 240 +first_layer_temperature = 240 +bed_temperature = 85 +retract_length = 4 + +[filament:Generic ABS] +temperature = 250 +first_layer_temperature = 255 +bed_temperature = 100 +retract_length = 4 + +[filament:Generic TPU] +temperature = 230 +first_layer_temperature = 230 +bed_temperature = 50 +retract_length = 2 diff --git a/ingest/samples/research_lessons.jsonl b/ingest/samples/research_lessons.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..5fdc1a69909de66d878f3d1628d820e7a37afdbf --- /dev/null +++ b/ingest/samples/research_lessons.jsonl @@ -0,0 +1,3 @@ +{"job_id": "ingest-001", "material": "ABS", "geometry_type": "warping", "env_temp": 20.0, "env_humidity": 40.0, "outcome": "failed_sag", "lesson": "ABS corners lifted in an open, cool room. ABS needs an enclosure and a 100-110C bed; without it, warping is near-certain regardless of settings.", "source": "ingested", "timestamp": "2026-05-22T09:00:00Z"} +{"job_id": "ingest-002", "material": "ABS", "geometry_type": "adhesion", "env_temp": 24.0, "env_humidity": 45.0, "outcome": "success", "lesson": "ABS first layer held with bed at 105C, brim, and fan off for opening layers. Draught control matters more than glue for ABS adhesion.", "source": "ingested", "timestamp": "2026-05-22T09:05:00Z"} +{"job_id": "ingest-003", "material": "TPU", "geometry_type": "stringing", "env_temp": 23.0, "env_humidity": 55.0, "outcome": "failed_stringing", "lesson": "TPU strung badly; it is highly hygroscopic. Dry the spool and print slow (~25mm/s) with minimal retraction — TPU clogs with aggressive retraction.", "source": "ingested", "timestamp": "2026-05-22T09:10:00Z"} diff --git a/learn/__init__.py b/learn/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a4f9c70ce5de3e7c9592cad079441bfda9915673 --- /dev/null +++ b/learn/__init__.py @@ -0,0 +1,2 @@ +"""Learning — the parametric policy (policy.py) and the closed loop (loop.py) +that improves it from simulated outcomes. See ../SIMULATION.md.""" diff --git a/learn/loop.py b/learn/loop.py new file mode 100644 index 0000000000000000000000000000000000000000..c5441494bc6f518774083872ba8f17a7dfab765e --- /dev/null +++ b/learn/loop.py @@ -0,0 +1,87 @@ +"""The closed learning loop — the primary thing we demo. + +One iteration: the policy PROPOSES settings → the Spine vetoes unsafe values → +the simulated world returns an outcome (the printer's role) → the outcome is +written to the ledger as precedent AND folded into the policy. Run it N times +on the same job and watch quality climb from failure to clean: knowledge +compounding, made literal and self-driving. + +Deterministic and offline by design (no LLM call in the loop) so the demo is +reproducible and fast. The cockpit's live path still runs the real LLM with +this same policy injected — see chief_engineer.advise / prompts.policy_note. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from core.ledger import LedgerManager +from learn.policy import LearnedPolicy +from core.models import Environment, Job, LessonEntry, PrintSettings +from sim.outcome import SimResult, simulate +from core.spine import SpineValidator + +_SPINE = SpineValidator() + + +@dataclass +class IterationRecord: + n: int + settings: PrintSettings + result: SimResult + learned: str # what the policy changed + clamped: bool + + +@dataclass +class SessionResult: + job: Job + env: Environment + records: list[IterationRecord] = field(default_factory=list) + + @property + def trajectory(self) -> list[float]: + return [r.result.quality for r in self.records] + + @property + def first_success(self) -> int | None: + for r in self.records: + if r.result.outcome == "success": + return r.n + return None + + +def _record_lesson(job: Job, env: Environment, s: PrintSettings, result: SimResult, + ledger: LedgerManager) -> None: + verb = {"success": "printed clean", "failed_sag": "sagged", + "failed_stringing": "strung"}.get(result.outcome, result.outcome) + lesson = (f"[sim] {job.material} {job.geometry_type} at {env.temp:.0f}°C/{env.humidity:.0f}% RH " + f"{verb} (q={result.quality:.2f}) with nozzle {s.nozzle_temp:.0f}°C, fan {s.fan_pct:.0f}%, " + f"retraction {s.retraction_mm:.1f}mm.") + ledger.append(LessonEntry( + job_id=f"sim-{datetime.now(timezone.utc).strftime('%H%M%S%f')}", + material=job.material, geometry_type=job.geometry_type, + env_temp=env.temp, env_humidity=env.humidity, outcome=result.outcome, + lesson=lesson, source="sim", timestamp=datetime.now(timezone.utc).isoformat())) + + +def run_iteration(job: Job, env: Environment, policy: LearnedPolicy, ledger: LedgerManager, + n: int, record: bool = True) -> IterationRecord: + proposed = policy.propose(job.material, job.geometry_type, env) + checked = _SPINE.check(proposed, job.material) + settings = checked.settings + result = simulate(settings, job, env) + if record: + _record_lesson(job, env, settings, result, ledger) + learned = policy.update(job.material, job.geometry_type, env, result) + return IterationRecord(n=n, settings=settings, result=result, learned=learned, + clamped=bool(checked.vetoes)) + + +def run_session(job: Job, env: Environment, iterations: int, policy: LearnedPolicy, + ledger: LedgerManager) -> SessionResult: + sess = SessionResult(job=job, env=env) + for i in range(1, iterations + 1): + sess.records.append(run_iteration(job, env, policy, ledger, i)) + return sess diff --git a/learn/policy.py b/learn/policy.py new file mode 100644 index 0000000000000000000000000000000000000000..b63bab6b26e612e17de0e06ad6ae993802aaa9bc --- /dev/null +++ b/learn/policy.py @@ -0,0 +1,140 @@ +"""Learned policy — the parametric layer that actually *improves*. + +This is what makes the Chief Engineer more than a lookup. Each (material, +geometry, environment-BUCKET) cell holds learned offsets to the baseline +settings, updated from observed outcomes. Because cells are bucketed (not +exact env points), a lesson from one humid PETG bridge transfers to the *next* +humid PETG bridge — it generalizes, rather than recalling a single past job. + +Two knowledge sources feed a recommendation, exactly as intended: + • RAG — retrieved prior jobs reasoned over by the LLM (chief_engineer.py) + • policy — these learned offsets, applied deterministically + shown to the LLM + +Persisted to data/policy.json. Pure-Python, deterministic, no network. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +from core.chief_engineer import _FALLBACK_SETTINGS +from core.models import Environment, Job, PrintSettings +from sim.outcome import SimResult + +POLICY_PATH = Path(__file__).resolve().parent.parent / "data" / "policy.json" + +# Corrective step per observed failure mode: which offsets to nudge, and by how +# much. Each move reduces the matching penalty in sim.outcome — so cells climb. +_CORRECTIONS = { + "sag": {"fan_pct": +12, "nozzle_temp": -3}, + "stringing": {"retraction_mm": +0.5, "nozzle_temp": -4}, + "adhesion": {"bed_temp": +6, "first_layer_fan_pct": -10}, + "under_extrusion": {"nozzle_temp": +5}, + "warp": {"fan_pct": -10}, +} +# Keep learned offsets sane; the Spine still clamps the final settings. +_OFFSET_CLAMP = { + "nozzle_temp": 30, "bed_temp": 25, "retraction_mm": 3, "fan_pct": 80, "first_layer_fan_pct": 60, +} + + +def env_bucket(temp: float, humidity: float) -> tuple[str, str]: + tb = "cool" if temp < 20 else ("warm" if temp > 26 else "mid") + hb = "dry" if humidity < 35 else ("humid" if humidity > 55 else "mid") + return tb, hb + + +def cell_key(material: str, geometry: str, env: Environment) -> str: + tb, hb = env_bucket(env.temp, env.humidity) + return f"{material}/{geometry}/{tb}/{hb}" + + +@dataclass +class Cell: + offsets: dict[str, float] + trials: int = 0 + successes: int = 0 + quality_history: list[float] | None = None + + @property + def success_rate(self) -> float: + return self.successes / self.trials if self.trials else 0.0 + + +class LearnedPolicy: + def __init__(self, path: Path = POLICY_PATH) -> None: + self.path = path + self.cells: dict[str, Cell] = {} + self._load() + + def _load(self) -> None: + if not self.path.exists(): + return + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except Exception: + return + for k, v in raw.items(): + self.cells[k] = Cell(offsets=v.get("offsets", {}), trials=v.get("trials", 0), + successes=v.get("successes", 0), + quality_history=v.get("quality_history", [])) + + def save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + out = {k: {"offsets": c.offsets, "trials": c.trials, "successes": c.successes, + "quality_history": c.quality_history or []} for k, c in self.cells.items()} + self.path.write_text(json.dumps(out, indent=2), encoding="utf-8") + + # --- read side --------------------------------------------------------- + def _baseline(self, material: str) -> dict[str, float]: + return dict(_FALLBACK_SETTINGS.get(material.upper(), _FALLBACK_SETTINGS["PLA"])) + + def offsets_for(self, material: str, geometry: str, env: Environment) -> dict[str, float]: + c = self.cells.get(cell_key(material, geometry, env)) + return dict(c.offsets) if c else {} + + def propose(self, material: str, geometry: str, env: Environment) -> PrintSettings: + """Deterministic proposal = material baseline + learned offsets (clamped).""" + base = self._baseline(material) + for k, dv in self.offsets_for(material, geometry, env).items(): + base[k] = base.get(k, 0.0) + dv + base["fan_pct"] = max(0.0, min(100.0, base["fan_pct"])) + base["first_layer_fan_pct"] = max(0.0, min(100.0, base["first_layer_fan_pct"])) + base["retraction_mm"] = max(0.0, base["retraction_mm"]) + return PrintSettings(**base) + + def cell_stats(self, material: str, geometry: str, env: Environment) -> Cell | None: + return self.cells.get(cell_key(material, geometry, env)) + + def policy_note(self, material: str, geometry: str, env: Environment) -> str: + """One line for the system prompt — steers the LLM with what's been learned.""" + c = self.cell_stats(material, geometry, env) + if not c or not c.offsets: + return "" + tb, hb = env_bucket(env.temp, env.humidity) + deltas = ", ".join(f"{k} {v:+g}" for k, v in c.offsets.items()) + return (f"LEARNED POLICY for {material}/{geometry} in {tb}/{hb} conditions " + f"(earned over {c.trials} runs, {c.success_rate*100:.0f}% clean): adjust baseline by {deltas}. " + f"Weigh this against the precedent above.") + + # --- write side (learning) -------------------------------------------- + def update(self, material: str, geometry: str, env: Environment, result: SimResult) -> str: + """Fold one observed outcome into the cell. Returns a human log line.""" + key = cell_key(material, geometry, env) + c = self.cells.setdefault(key, Cell(offsets={})) + c.trials += 1 + c.quality_history = (c.quality_history or []) + [round(result.quality, 3)] + if result.outcome == "success": + c.successes += 1 + self.save() + return f"{key}: success (q={result.quality:.2f}) — holding policy" + moved = [] + for field, step in _CORRECTIONS.get(result.failure_mode, {}).items(): + cur = c.offsets.get(field, 0.0) + step + lim = _OFFSET_CLAMP.get(field, 1e9) + c.offsets[field] = max(-lim, min(lim, cur)) + moved.append(f"{field} {step:+g}") + self.save() + return f"{key}: {result.failure_mode} (q={result.quality:.2f}) — learned: {', '.join(moved) or 'no-op'}" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..8e6568ff7e108aa3eb6f67d85c61577e5f6adde2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "chief-engineer" +version = "0.1.0" +description = "A small local model that learns 3D printing job by job and predicts print failures before they happen." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +dependencies = [ + "gradio>=6.17,<7", + "ollama>=0.4", + "pydantic>=2.7", + "trimesh>=4.4", + "shapely>=2.0", +] + +# Deploy-only extra for the ZeroGPU live path (mirrors requirements-zerogpu.txt). +# Install locally with: uv sync --extra zerogpu +[project.optional-dependencies] +zerogpu = [ + "spaces>=0.30", + "torch>=2.4", + "transformers>=4.49", + "accelerate>=0.34", +] + +# pyproject is the source of truth for deps. requirements.txt is kept in sync by +# hand (only 4 base deps) because the HF Space installs via pip+requirements.txt, +# not uv. If they drift, regenerate: uv export --no-hashes --no-dev -o requirements.txt + +# Permissive licenses only. Do NOT add OrcaSlicer/PrusaSlicer (AGPL-3.0). +[tool.chief-engineer] +model_default = "gemma4:e4b" # = gemma4:latest. gemma4:4b does NOT exist. +model_fast = "gemma4:e2b" diff --git a/requirements-zerogpu.txt b/requirements-zerogpu.txt new file mode 100644 index 0000000000000000000000000000000000000000..a0e2663c3d893ad593f0b4fe9e522659c30d487c --- /dev/null +++ b/requirements-zerogpu.txt @@ -0,0 +1,17 @@ +# Deploy-only deps for the ZeroGPU live-inference path. NOT in base requirements.txt +# and NOT needed for local dev / recording (that path uses Ollama). +# +# On the published HF Space (ZeroGPU hardware), install base + these, then set: +# CHIEF_ENGINEER_BACKEND=zerogpu +# CHIEF_ENGINEER_HF_MODEL= # ⚠ verify on HF +# See docs/plan/08-ZEROGPU-DEPLOY.md. Leave this OFF until the Branch-A baseline +# (fallback Space + recorded video) is submitted. + +spaces>=0.30 # ZeroGPU @spaces.GPU decorator (HF-provided on the Space) +torch>=2.4 +transformers>=4.49 # Gemma 3 family support +accelerate>=0.34 +# bitsandbytes # ONLY if loading a 4-bit (non-QAT) checkpoint on GPU +# llama-cpp-python # ALTERNATIVE: if you pick a QAT *GGUF* release instead of a +# # transformers checkpoint, run that via a llama.cpp path +# # (not llm_zerogpu.py, which is transformers-based). diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..0792425919d16dc4f228afb2285082e986405041 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +# The Chief Engineer — HF Space deps. Local-first; permissive licenses only. +# NEVER add OrcaSlicer/PrusaSlicer (AGPL-3.0). +gradio>=6.17,<7 +ollama>=0.4 # local Gemma via Ollama (runs on llama.cpp) +pydantic>=2.7 +trimesh>=4.4 # MIT — mesh load + overhang location (light use) +shapely>=2.0 # BSD — filled cross-section layers (GEOS bundled in the wheel; Space-safe) + +# ── ZeroGPU live inference: ONE-STEP FLIP ────────────────────────────────── +# Uncomment the next line on the ZeroGPU Space (+ set hardware=ZeroGPU and the +# CHIEF_ENGINEER_BACKEND=zerogpu / CHIEF_ENGINEER_HF_MODEL env vars). Leave it +# commented for the light CPU fallback Space. See docs/plan/08-ZEROGPU-DEPLOY.md. +# -r requirements-zerogpu.txt diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..20f2ff0de31a83fcbf655ded056286b382147d3d --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Runnable entrypoints (run via `make ` or `uv run python -m scripts.`).""" diff --git a/scripts/bench_latency.py b/scripts/bench_latency.py new file mode 100644 index 0000000000000000000000000000000000000000..2727a18cc1fe735f6cb42a6865651af2ed0e23f7 --- /dev/null +++ b/scripts/bench_latency.py @@ -0,0 +1,53 @@ +"""Pre-window latency check — does the model respond fast enough for a live demo? + +Run on the ACTUAL target hardware (your laptop / the Space). If a turn takes +~40s, switch to a smaller quant now (gemma4:e2b), not on June 13. +Run: `make bench` (optionally CHIEF_ENGINEER_MODEL=gemma4:e2b) +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # repo root on path + +from core import llm +from core.models import Environment, Job +from core.prompts import build_system_prompt + +N = 3 + + +def main() -> None: + if not llm.is_available(): + print("⚠ Ollama not reachable — start `ollama serve` and pull the model, then re-run.") + print(f" target model: {llm.MODEL}") + return + + job = Job(geometry_type="overhang", material="PLA", description="45° bracket") + env = Environment(temp=28, humidity=50) + system = build_system_prompt(job, env, []) + + times = [] + for i in range(N): + t0 = time.time() + out = llm.chat_json(system, "Give your recommendation for THIS job now.") + dt = time.time() - t0 + times.append(dt) + ok = "ok" if out else "parse-fail" + print(f" run {i + 1}: {dt:5.1f}s ({ok})") + + # Same cold/warm split + bands as preflight G2 (calibrated 6/10: warm <20s + # reads fine in a narrated demo). + cold, warm = times[0], (times[1:] or times) + warm_avg = sum(warm) / len(warm) + verdict = ("✅ fine for a live narrated demo" if warm_avg < 20 else + ("🟡 long pauses — tighten prompt or use e2b/ZeroGPU" if warm_avg < 35 else + "🔴 too slow — use gemma4:e2b")) + print(f"\n{llm.MODEL}: warm avg {warm_avg:.1f}s (first call {cold:.1f}s) over {N} runs → {verdict}") + + +if __name__ == "__main__": + main() diff --git a/scripts/capture.py b/scripts/capture.py new file mode 100644 index 0000000000000000000000000000000000000000..70224d8720ba8132d99a9485129eb227fb29ee1c --- /dev/null +++ b/scripts/capture.py @@ -0,0 +1,168 @@ +"""Capture spec — drive the live app through the demo beats deterministically, +so the Cap CLI (or any screen recorder) captures clean, repeatable clips. + +This does NOT record — it walks the UI slowly with deliberate pauses while YOU +record the browser window with Cap. Beats are independent (record one, re-record +one) per the modular plan in RUNBOOK Phase 3. + +One-time setup (local; not a Space/runtime dep): + uv pip install playwright && uv run playwright install chromium + +Run the app first in another terminal: + make run # http://localhost:7860 (pre-warm one analyze!) + +Then walk the beats (headed browser; record it with Cap): + uv run python -m scripts.capture --beat all # the full Studio->Analysis->Loop run + uv run python -m scripts.capture --beat 3 # just the load-bearing beat + uv run python -m scripts.capture --beat benchy # Studio + Benchy quick-load + uv run python -m scripts.capture --beat loop # the Learning Loop + # options: --url, --slowmo MS (default 350), --pause S between beats, --headless + +Selectors are by visible text/role, so they track the current Astrometrics UI. +""" + +from __future__ import annotations + +import argparse +import sys +import time + + +def _pill(page, value): + """Click an LCARS pill. CSS uppercases the text, but the DOM keeps the raw + value (part types are lowercase: 'overhang'; materials are 'PLA'). Match the +