heulistic 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
heulistic/__init__.py ADDED
File without changes
heulistic/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Entry point for `python -m heulistic`.
2
+
3
+ The `heulistic` console script is the normal way in; this makes the module form
4
+ work too, which is how the CLI gets run inside a virtualenv that has not been
5
+ activated.
6
+ """
7
+
8
+ from .main import main
9
+
10
+ if __name__ == "__main__":
11
+ main()
heulistic/client.py ADDED
@@ -0,0 +1,161 @@
1
+ """Talk to the local Heulistic API.
2
+
3
+ Standard library only, so `pip install heulistic` stays light — which is why
4
+ multipart bodies are assembled here rather than handed to a library.
5
+
6
+ Local mode accepts a fixed bearer token in place of a Cognito JWT. When cloud
7
+ support lands this is the seam where a real credential goes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import mimetypes
14
+ import secrets
15
+ import urllib.error
16
+ import urllib.request
17
+ from pathlib import Path
18
+ from typing import Iterator
19
+
20
+ __all__ = [
21
+ "ApiError",
22
+ "encode_multipart",
23
+ "submit",
24
+ "get_job",
25
+ "download_url",
26
+ "stream_logs",
27
+ ]
28
+
29
+ DEV_TOKEN = "dev-localuser"
30
+ _TIMEOUT = 30
31
+ # Training runs are long; the log stream must not time out mid-job.
32
+ _STREAM_TIMEOUT = 60 * 60 * 24
33
+
34
+
35
+ class ApiError(Exception):
36
+ """The API refused the request, carrying its `detail` where there is one."""
37
+
38
+
39
+ def _headers(content_type: str | None = None) -> dict[str, str]:
40
+ headers = {"Authorization": f"Bearer {DEV_TOKEN}"}
41
+ if content_type:
42
+ headers["Content-Type"] = content_type
43
+ return headers
44
+
45
+
46
+ def _raise_for_error(exc: urllib.error.HTTPError) -> None:
47
+ try:
48
+ detail = json.loads(exc.read()).get("detail", "")
49
+ except Exception:
50
+ detail = ""
51
+ raise ApiError(detail or f"HTTP {exc.code}") from exc
52
+
53
+
54
+ def _request(
55
+ method: str,
56
+ url: str,
57
+ *,
58
+ body: bytes | None = None,
59
+ content_type: str | None = None,
60
+ stream: bool = False,
61
+ ):
62
+ """Perform one HTTP request. Tests replace this wholesale."""
63
+ request = urllib.request.Request(
64
+ url, data=body, method=method, headers=_headers(content_type)
65
+ )
66
+ try:
67
+ response = urllib.request.urlopen(
68
+ request, timeout=_STREAM_TIMEOUT if stream else _TIMEOUT
69
+ )
70
+ except urllib.error.HTTPError as exc:
71
+ _raise_for_error(exc)
72
+ except urllib.error.URLError as exc:
73
+ raise ApiError(
74
+ f"Could not reach the Heulistic API at {url} — is it running? "
75
+ "Start it with: heulistic start"
76
+ ) from exc
77
+ return response if stream else response.read()
78
+
79
+
80
+ def encode_multipart(
81
+ fields: dict[str, str | None], files: dict[str, tuple[str, bytes]]
82
+ ) -> tuple[str, bytes]:
83
+ """Build a multipart/form-data body. Returns (content_type, body)."""
84
+ boundary = secrets.token_hex(16)
85
+ parts: list[bytes] = []
86
+
87
+ for name, value in fields.items():
88
+ if value in (None, ""):
89
+ continue # an empty optional field must not be sent at all
90
+ parts.append(
91
+ f"--{boundary}\r\n"
92
+ f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
93
+ f"{value}\r\n".encode()
94
+ )
95
+
96
+ for name, (filename, content) in files.items():
97
+ mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
98
+ parts.append(
99
+ f"--{boundary}\r\n"
100
+ f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
101
+ f"Content-Type: {mime}\r\n\r\n".encode()
102
+ )
103
+ parts.append(content)
104
+ parts.append(b"\r\n")
105
+
106
+ parts.append(f"--{boundary}--\r\n".encode())
107
+ return f"multipart/form-data; boundary={boundary}", b"".join(parts)
108
+
109
+
110
+ def submit(
111
+ api_url: str,
112
+ config: Path,
113
+ dataset: Path | None = None,
114
+ hf_token: str | None = None,
115
+ ) -> dict:
116
+ """Submit a training job. Returns the API's job record."""
117
+ files = {"config": (config.name, config.read_bytes())}
118
+ if dataset is not None:
119
+ files["dataset"] = (dataset.name, dataset.read_bytes())
120
+
121
+ content_type, body = encode_multipart({"hf_token": hf_token}, files)
122
+ raw = _request(
123
+ "POST", f"{api_url}/api/jobs", body=body, content_type=content_type
124
+ )
125
+ return json.loads(raw)
126
+
127
+
128
+ def get_job(api_url: str, job_id: str) -> dict:
129
+ return json.loads(_request("GET", f"{api_url}/api/jobs/{job_id}"))
130
+
131
+
132
+ def download_url(api_url: str, job_id: str) -> str:
133
+ raw = _request("GET", f"{api_url}/api/jobs/{job_id}/download")
134
+ return json.loads(raw)["url"]
135
+
136
+
137
+ def _stream_lines(url: str) -> Iterator[bytes]:
138
+ response = _request("GET", url, stream=True)
139
+ for raw_line in response:
140
+ yield raw_line.rstrip(b"\n")
141
+
142
+
143
+ def stream_logs(api_url: str, job_id: str) -> Iterator[tuple[str, str]]:
144
+ """Yield ("line", text) for each log line, then ("done", status) at the end.
145
+
146
+ Server-sent events; anything that is not a well-formed `data:` frame — a
147
+ keepalive, a blank separator — is skipped rather than surfaced.
148
+ """
149
+ url = f"{api_url}/api/jobs/{job_id}/logs"
150
+ for raw_line in _stream_lines(url):
151
+ if not raw_line.startswith(b"data: "):
152
+ continue
153
+ try:
154
+ payload = json.loads(raw_line[len(b"data: "):])
155
+ except json.JSONDecodeError:
156
+ continue
157
+ if payload.get("event") == "done":
158
+ yield "done", payload.get("status", "")
159
+ return
160
+ if "line" in payload:
161
+ yield "line", payload["line"]
@@ -0,0 +1,92 @@
1
+ """`heulistic download` — fetch a finished model.
2
+
3
+ Locally the artifact sits in MinIO inside Docker, so without this the output of
4
+ a run is hard to reach. The API hands back a URL; this follows it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import urllib.error
11
+ import urllib.request
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ import typer
16
+
17
+ from . import client, stack
18
+
19
+ _SUFFIX = "-model.tar.gz"
20
+
21
+
22
+ def _fetch(url: str, dest: Path) -> Path:
23
+ """Stream the artifact to disk. Replaced in tests."""
24
+ with urllib.request.urlopen(url, timeout=300) as response, open(dest, "wb") as out:
25
+ shutil.copyfileobj(response, out)
26
+ return dest
27
+
28
+
29
+ def _resolve_destination(output: Optional[Path], job_id: str) -> Path:
30
+ default_name = f"{job_id}{_SUFFIX}"
31
+ if output is None:
32
+ return Path.cwd() / default_name
33
+ if output.is_dir():
34
+ return output / default_name
35
+ return output
36
+
37
+
38
+ def download(
39
+ job_id: str = typer.Argument(..., help="Job ID to download the model for."),
40
+ output: Optional[Path] = typer.Option(
41
+ None, "--output", "-o", help="File or directory to write to."
42
+ ),
43
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite an existing file."),
44
+ url_only: bool = typer.Option(
45
+ False, "--url-only", help="Print the download URL instead of fetching it."
46
+ ),
47
+ ) -> None:
48
+ """Download the trained model for a completed job."""
49
+ api_url = stack.api_url()
50
+ if not api_url:
51
+ typer.secho("Heulistic is not running.", fg=typer.colors.RED)
52
+ typer.echo("Start it with: heulistic start")
53
+ raise typer.Exit(code=1)
54
+
55
+ try:
56
+ job = client.get_job(api_url, job_id)
57
+ except client.ApiError as exc:
58
+ typer.secho(str(exc), fg=typer.colors.RED)
59
+ raise typer.Exit(code=1)
60
+
61
+ status = job.get("status", "unknown")
62
+ if status != "completed":
63
+ typer.secho(f"Job {job_id} is '{status}' — a model is only available once "
64
+ "the job has completed.", fg=typer.colors.RED)
65
+ raise typer.Exit(code=1)
66
+
67
+ try:
68
+ url = client.download_url(api_url, job_id)
69
+ except client.ApiError as exc:
70
+ typer.secho(str(exc), fg=typer.colors.RED)
71
+ raise typer.Exit(code=1)
72
+
73
+ if url_only:
74
+ typer.echo(url)
75
+ raise typer.Exit(code=0)
76
+
77
+ dest = _resolve_destination(output, job_id)
78
+ if dest.exists() and not force:
79
+ typer.secho(f"{dest} already exists — pass --force to overwrite.",
80
+ fg=typer.colors.RED)
81
+ raise typer.Exit(code=1)
82
+
83
+ typer.echo(f"Downloading {job_id} → {dest}")
84
+ try:
85
+ _fetch(url, dest)
86
+ except (urllib.error.URLError, OSError) as exc:
87
+ typer.secho(f"Download failed: {exc}", fg=typer.colors.RED)
88
+ raise typer.Exit(code=1)
89
+
90
+ size_mb = dest.stat().st_size / (1024 * 1024)
91
+ typer.secho(f"Saved {dest} ({size_mb:.1f} MB)", fg=typer.colors.GREEN)
92
+ typer.echo(f" tar xzf {dest.name}")
heulistic/gpu.py ADDED
@@ -0,0 +1,90 @@
1
+ """Local GPU detection via nvidia-smi.
2
+
3
+ Used to check whether a config fits the machine it is about to run on. Every
4
+ path here degrades to "no GPU found" rather than raising — running
5
+ `validate-config` on a laptop without an NVIDIA card is ordinary, not an error.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import subprocess
12
+ from dataclasses import dataclass
13
+
14
+ from heulistic_axolotl import Hardware
15
+
16
+ __all__ = ["DetectedGpu", "detect_gpus", "detect_hardware"]
17
+
18
+ _MB_PER_GB = 1024
19
+ _QUERY = ["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class DetectedGpu:
24
+ name: str
25
+ vram_mb: int
26
+
27
+
28
+ def _nvidia_smi_output() -> str | None:
29
+ """Raw `nvidia-smi` CSV, or None when unavailable.
30
+
31
+ Isolated so tests can supply canned output without mocking subprocess.
32
+ """
33
+ if shutil.which("nvidia-smi") is None:
34
+ return None
35
+ try:
36
+ result = subprocess.run(
37
+ ["nvidia-smi", *_QUERY],
38
+ capture_output=True,
39
+ text=True,
40
+ timeout=10,
41
+ )
42
+ except (OSError, subprocess.SubprocessError):
43
+ return None
44
+ return result.stdout if result.returncode == 0 else None
45
+
46
+
47
+ def detect_gpus() -> list[DetectedGpu]:
48
+ """Every NVIDIA GPU on this machine. Empty when none can be found."""
49
+ output = _nvidia_smi_output()
50
+ if not output:
51
+ return []
52
+
53
+ cards: list[DetectedGpu] = []
54
+ for line in output.splitlines():
55
+ line = line.strip()
56
+ if not line or "," not in line:
57
+ continue
58
+ name, _, memory = line.rpartition(",")
59
+ try:
60
+ vram_mb = int(memory.strip())
61
+ except ValueError:
62
+ continue # a driver error lands in this column instead of a number
63
+ cards.append(DetectedGpu(name=name.strip(), vram_mb=vram_mb))
64
+ return cards
65
+
66
+
67
+ def _label(cards: list[DetectedGpu]) -> str:
68
+ names = {c.name for c in cards}
69
+ if len(cards) == 1:
70
+ return cards[0].name
71
+ if len(names) == 1:
72
+ return f"{len(cards)}x {cards[0].name}"
73
+ return f"{len(cards)} GPUs"
74
+
75
+
76
+ def detect_hardware() -> Hardware | None:
77
+ """The local GPUs as a validation target, or None if there are none usable."""
78
+ cards = detect_gpus()
79
+ if not cards:
80
+ return None
81
+ total_gb = sum(c.vram_mb for c in cards) / _MB_PER_GB
82
+ try:
83
+ return Hardware(
84
+ total_vram_gb=round(total_gb, 2),
85
+ gpu_count=len(cards),
86
+ label=_label(cards),
87
+ )
88
+ except ValueError:
89
+ # A device reporting zero memory is not something we can size against.
90
+ return None
heulistic/init_cmd.py ADDED
@@ -0,0 +1,152 @@
1
+ """`heulistic init` — write a starter config sized to this machine.
2
+
3
+ A generic sample config is a poor first experience: the usual failure is an
4
+ OOM twenty minutes in, because the example assumed a bigger card. Since the
5
+ CLI can already see the local GPU and estimate VRAM, the starter config picks
6
+ a model that actually fits.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ import typer
15
+
16
+ from heulistic_axolotl import validate
17
+ from heulistic_axolotl.vram import vram_required_gb
18
+
19
+ from . import gpu
20
+
21
+ # QLoRA-friendly models, smallest first. The largest entry that fits the card
22
+ # wins.
23
+ #
24
+ # The ladder deliberately stops at 8B rather than climbing to 70B. This is a
25
+ # starter config: the goal is a run that finishes quickly and works, not one
26
+ # that fills the GPU. Someone with an 80 GB card can pass --model.
27
+ _LADDER = [
28
+ "Qwen/Qwen2.5-0.5B",
29
+ "Qwen/Qwen2.5-1.5B",
30
+ "Qwen/Qwen2.5-3B",
31
+ "Qwen/Qwen2.5-7B",
32
+ ]
33
+ # Every entry must be ungated. The ladder previously led with Llama-3.2, which
34
+ # is gated on the Hub — so the first `heulistic train` after `init` was rejected
35
+ # for want of an approved HuggingFace token, which is precisely the wall `init`
36
+ # exists to avoid. Qwen2.5 is Apache-2.0 and needs no token.
37
+ _FALLBACK_MODEL = "Qwen/Qwen2.5-0.5B"
38
+
39
+ # Leave room for activations, fragmentation and the desktop's own usage.
40
+ _USABLE_FRACTION = 0.75
41
+
42
+
43
+ def _template(base_model: str) -> dict:
44
+ """A complete, warning-free QLoRA config.
45
+
46
+ Every field the validator would otherwise flag as missing is set
47
+ explicitly, so `heulistic validate-config` on a freshly generated file is
48
+ silent — a starter config that immediately complains teaches the wrong
49
+ thing.
50
+ """
51
+ return {
52
+ "base_model": base_model,
53
+ "adapter": "qlora",
54
+ "load_in_4bit": True,
55
+ "datasets": [{"path": "tatsu-lab/alpaca", "type": "alpaca"}],
56
+ "sequence_len": 1024,
57
+ "micro_batch_size": 1,
58
+ "gradient_accumulation_steps": 8,
59
+ "num_epochs": 1,
60
+ "learning_rate": 0.0002,
61
+ "lora_r": 16,
62
+ "lora_alpha": 32,
63
+ "lora_dropout": 0.05,
64
+ "lora_target_linear": True,
65
+ "bf16": "auto",
66
+ "gradient_checkpointing": True,
67
+ "save_steps": 100,
68
+ "val_set_size": 0.05,
69
+ "output_dir": "./outputs",
70
+ }
71
+
72
+
73
+ def _pick_model(budget_gb: float | None) -> str:
74
+ """Largest model from the ladder that fits the budget."""
75
+ if budget_gb is None:
76
+ return _FALLBACK_MODEL
77
+ chosen = None
78
+ for model in _LADDER:
79
+ if vram_required_gb(_template(model)) <= budget_gb:
80
+ chosen = model
81
+ return chosen or _LADDER[0]
82
+
83
+
84
+ def _render(config: dict) -> str:
85
+ """Emit YAML by hand, so the file is commented and ordered for a human."""
86
+ import yaml
87
+
88
+ header = (
89
+ "# Generated by `heulistic init`.\n"
90
+ "# Check it before training: heulistic validate-config config.yaml\n"
91
+ "# Then run it: heulistic train config.yaml\n\n"
92
+ )
93
+ return header + yaml.safe_dump(config, sort_keys=False, default_flow_style=False)
94
+
95
+
96
+ def init(
97
+ output: Path = typer.Option(
98
+ Path("config.yaml"), "--output", "-o", help="Where to write the config."
99
+ ),
100
+ model: Optional[str] = typer.Option(
101
+ None, "--model", "-m", help="Base model to use instead of the auto-picked one."
102
+ ),
103
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite an existing file."),
104
+ ) -> None:
105
+ """Write a starter Axolotl config, sized to the local GPU."""
106
+ if output.exists() and not force:
107
+ typer.secho(f"{output} already exists — pass --force to overwrite.",
108
+ fg=typer.colors.RED)
109
+ raise typer.Exit(code=1)
110
+
111
+ hardware = gpu.detect_hardware()
112
+ if hardware is None:
113
+ typer.secho(
114
+ "No NVIDIA GPU detected — writing a small starter config. "
115
+ "Use --model to choose another.",
116
+ fg=typer.colors.YELLOW,
117
+ )
118
+ budget = None
119
+ else:
120
+ budget = hardware.total_vram_gb * _USABLE_FRACTION
121
+ typer.echo(
122
+ f"Detected {hardware.label} ({hardware.total_vram_gb:g} GB) — "
123
+ f"sizing for roughly {budget:.0f} GB of usable VRAM."
124
+ )
125
+
126
+ base_model = model or _pick_model(budget)
127
+ config = _template(base_model)
128
+ required = vram_required_gb(config)
129
+
130
+ if model and hardware is not None and required > hardware.total_vram_gb:
131
+ typer.secho(
132
+ f" Warning: {model} needs roughly {required:.0f} GB of VRAM but "
133
+ f"{hardware.label} has {hardware.total_vram_gb:g} GB.",
134
+ fg=typer.colors.YELLOW,
135
+ )
136
+
137
+ output.write_text(_render(config))
138
+
139
+ typer.secho(f"Wrote {output}", fg=typer.colors.GREEN)
140
+ typer.echo(f" model: {base_model} (~{required:.1f} GB VRAM as QLoRA)")
141
+ typer.echo(f" data: tatsu-lab/alpaca — swap in your own under 'datasets'")
142
+ typer.echo()
143
+ typer.echo(f"Next: heulistic train {output}")
144
+
145
+ # Never ship a starter file our own validator would complain about.
146
+ problems = [d for d in validate(output.read_text()) if d.level in ("error", "warning")]
147
+ if problems: # pragma: no cover - guards against template drift
148
+ typer.secho(
149
+ f" ({len(problems)} validator finding(s) in the generated file — "
150
+ "please report this)",
151
+ fg=typer.colors.YELLOW,
152
+ )
@@ -0,0 +1,101 @@
1
+ name: heulistic
2
+
3
+ services:
4
+ db:
5
+ image: postgres:15
6
+ environment:
7
+ POSTGRES_DB: heulistic
8
+ POSTGRES_USER: heulistic_admin
9
+ POSTGRES_PASSWORD: password
10
+ volumes:
11
+ - postgres_data:/var/lib/postgresql/data
12
+ healthcheck:
13
+ test: ["CMD-SHELL", "pg_isready -U heulistic_admin -d heulistic"]
14
+ interval: 5s
15
+ timeout: 5s
16
+ retries: 5
17
+
18
+ minio:
19
+ image: minio/minio
20
+ command: server /data --console-address ":9001"
21
+ environment:
22
+ MINIO_ROOT_USER: minioadmin
23
+ MINIO_ROOT_PASSWORD: minioadmin
24
+ ports:
25
+ - "9000:9000"
26
+ - "9001:9001"
27
+ volumes:
28
+ - minio_data:/data
29
+
30
+ minio-init:
31
+ image: minio/mc
32
+ depends_on:
33
+ - minio
34
+ entrypoint: >
35
+ /bin/sh -c "
36
+ until mc alias set local http://minio:9000 minioadmin minioadmin; do sleep 1; done;
37
+ mc mb local/heulistic-local --ignore-existing;
38
+ exit 0"
39
+
40
+ api:
41
+ image: heulistic/api:latest
42
+ environment:
43
+ DATABASE_URL: postgresql+asyncpg://heulistic_admin:password@db:5432/heulistic
44
+ LOCAL_MODE: "true"
45
+ S3_BUCKET: heulistic-local
46
+ S3_ENDPOINT_URL: http://minio:9000
47
+ MINIO_ACCESS_KEY: minioadmin
48
+ MINIO_SECRET_KEY: minioadmin
49
+ FRONTEND_URL: http://localhost:${PORT:-3000}
50
+ # Download links are built from this, so it must match the published port.
51
+ API_PUBLIC_URL: http://localhost:${API_PORT:-8000}
52
+ AWS_REGION: us-east-1
53
+ ports:
54
+ - "${API_PORT:-8000}:8000"
55
+ depends_on:
56
+ db:
57
+ condition: service_healthy
58
+ minio-init:
59
+ condition: service_completed_successfully
60
+
61
+ docker-proxy:
62
+ image: tecnativa/docker-socket-proxy:edge
63
+ environment:
64
+ CONTAINERS: 1
65
+ IMAGES: 1
66
+ POST: 1
67
+ volumes:
68
+ - /var/run/docker.sock:/var/run/docker.sock:ro
69
+ restart: unless-stopped
70
+
71
+ scheduler:
72
+ image: heulistic/scheduler:latest
73
+ environment:
74
+ DATABASE_URL: postgresql://heulistic_admin:password@db:5432/heulistic
75
+ LOCAL_MODE: "true"
76
+ API_INTERNAL_URL: http://api:8000
77
+ LOCAL_TRAINING_IMAGE: heulistic/training:latest
78
+ DOCKER_NETWORK: heulistic_default
79
+ LOCAL_CACHE_VOLUME: heulistic-cache
80
+ LOCAL_GPU: "${LOCAL_GPU:-true}"
81
+ MAX_CONCURRENT_JOBS: "1"
82
+ HF_TOKEN: "${HF_TOKEN:-}"
83
+ AWS_REGION: us-east-1
84
+ DOCKER_SOCKET: tcp://docker-proxy:2375
85
+ depends_on:
86
+ - api
87
+ - docker-proxy
88
+
89
+ frontend:
90
+ image: heulistic/frontend-local:latest
91
+ environment:
92
+ API_URL: http://api:8000
93
+ ports:
94
+ - "${PORT:-3000}:3000"
95
+ depends_on:
96
+ - api
97
+
98
+ volumes:
99
+ postgres_data:
100
+ minio_data:
101
+ heulistic-cache: