diff --git a/.github/workflows/training.yml b/.github/workflows/training.yml new file mode 100644 index 0000000..73776b7 --- /dev/null +++ b/.github/workflows/training.yml @@ -0,0 +1,48 @@ +name: Nightangel training verification + +on: + workflow_dispatch: + schedule: + - cron: '17 6 * * *' + push: + branches: [main] + paths: ['training/progress.json'] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install verification dependencies + run: | + python -m pip install Pillow + python -m pip install torch --index-url https://download.pytorch.org/whl/cpu + - name: Repeat every offline test 1000 times + run: python tools/verify_1000.py --output output/githubverification + - name: Record training and verification summary + run: | + python - <<'PY' + import json,os,pathlib + out=pathlib.Path(os.environ['GITHUB_STEP_SUMMARY']) + result=json.loads(pathlib.Path('output/githubverification/1000-test-summary.json').read_text()) + progress=pathlib.Path('training/progress.json') + text=f"Completed {result['completed_runs']} suite runs and {result['test_executions']} test executions.\n\n" + if progress.exists(): + p=json.loads(progress.read_text()) + text+=f"Latest local training report: {p.get('examples_seen',0)} examples, {p.get('updates',0)} updates, stage {p.get('stage')}.\n\n" + text+='Training runs on the owner computer. This workflow verifies code and records the last published progress.\n' + out.write_text(text) + PY + - uses: actions/upload-artifact@v4 + if: always() + with: + name: nightangelverification + path: output/githubverification/ + retention-days: 7 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index a94068d..934ffb6 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -5,6 +5,11 @@ on: branches: [main] pull_request: workflow_dispatch: + inputs: + full_verification: + description: Repeat every offline test 1000 times + type: boolean + default: false permissions: contents: read @@ -22,10 +27,23 @@ jobs: python-version: "3.12" - name: Install test dependencies - run: python -m pip install Pillow + run: | + python -m pip install Pillow + python -m pip install torch --index-url https://download.pytorch.org/whl/cpu - - name: Run focused perception tests + - name: Run data, training pipeline, checkpoint, and perception tests run: python -m unittest discover -s tests -v + - name: Repeat the complete suite 1000 times + if: github.event_name == 'workflow_dispatch' && inputs.full_verification + run: python tools/verify_1000.py --output output/githubverification + + - uses: actions/upload-artifact@v4 + if: always() && github.event_name == 'workflow_dispatch' && inputs.full_verification + with: + name: nightangelverification + path: output/githubverification/ + retention-days: 7 + - name: Confirm public project records exist run: test -s README.md && test -s WORKLOG.md diff --git a/README.md b/README.md index cefeee9..10e9723 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ That workflow connects directly to the work I want to do professionally: careful - `closed-loop`: tracks conservative visual candidates and records structured replay events. - Browser extension and local bridge for connecting the game tab to the Python agent. - Perception tests covering the current candidate-detection layer. +- Optional [Unsloth decision-training data preparation](docs/unsloth-decisions.md), with replay holdouts and action coverage checks. Model training and gameplay gains remain unverified. +- [Reusable training dataset](datasets/README.md): 159,890 verified examples, referenced gameplay images, preserved validation splits, provenance, and a local CPU vision/language adapter trainer. Full training is running; gameplay improvement remains unverified. +- [Nightangel training progress](training/README.md): meaningful progress recorded under the owner account, with complete repeated verification recorded in GitHub Actions. ## Verified experiment snapshot @@ -68,7 +71,7 @@ Only enable live action during short, bounded tests after reviewing the captured Meaningful sessions are recorded through small commits, dated build-log entries, and automatic test runs. The exact routine is documented in `CONTRIBUTING.md` so the public history shows evidence of real progress rather than artificial contribution activity. -Large replay frames, trained model binaries, local environments, and temporary output are intentionally excluded from version control. +Large replay frames, trained model binaries, local environments, and temporary output are intentionally excluded from version control. The [Unsloth guide](docs/unsloth-decisions.md) covers controller decisions and the broader verified human gameplay archive, with local exports and protected evaluation recordings. ## Safety and privacy diff --git a/WORKLOG.md b/WORKLOG.md index 448eeec..ed900c0 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -2,6 +2,33 @@ This file is the public record of ongoing work. Each meaningful coding, data, training, or evaluation session should add one short entry with evidence and the next question. +## 2026-10-10 Nightangel contribution reporting + +The working branch is named `nightangelflowerwin`. Added GitHub verification runs and a local reporter for meaningful daily training progress and milestones on `main`, attributed to the owner account. Reports exclude machine details and unchanged progress does not produce extra commits. The expanded suite has 26 tests; complete repeated verification is recorded in GitHub Actions. Model training remains local and gameplay validation remains unfinished. + +## 2026-10-10 - Complete reusable corpus and repeated verification + +- Changed: publish a portable corpus reader, complete CPU vision/language LoRA trainer, checkpoint publication guard, dataset section, and repeatable 1,000-run verifier. Export every final eligible example and original referenced image with relative paths, source attribution, hashes, and preserved validation splits. +- Data: 98,560 training and 61,330 validation examples across five explicitly separated tasks. All exported records were parsed and every referenced image decoded and hashed; decoded images do not overlap the two splits. +- Verification: the expanded 24-test suite completed 1,000 runs against frozen source hashes: 24,000 executions, zero failures. A separate 1,000-round check completed 5,000 checks with the actual local Qwen processor across all five domains. Complete result ledgers accompany the release. The public reader validated every exported example and image, and every target was checked against its task schema. +- Training: the local Unsloth Qwen3.5-0.8B base is training vision and language adapters through the Transformers/PEFT CPU reference backend. Training consumes a complete corpus pass and complete gameplay-image consolidation pass, with no step cap. +- Limitation: historical actions include unsuccessful controllers; engine observations are privileged data; HUD grounding is not action prediction. Training and full paired holdout evaluation are unfinished. No gameplay improvement or model promotion is claimed. +- Next: finish the complete training and holdout evaluation, then compare real engine gameplay against the current controller using fixed seeds and genuine exit evidence. + +## 2026-10-10 - Broader Human Training Archive + +- Changed: inventory archived model datasets and raw human inputs; export verified DoomSol keyboard recordings as cropped image/control conversations for optional Unsloth vision training. Preserve historical evaluation blocks, guard boundaries by 850 ms, remove exact duplicate examples and train/validation image overlap. +- Evidence: 12 tests pass. The local export contains 3,311 training examples (702 firing) and 800 validation examples (65 firing); 486 duplicate examples, 67 boundary examples and four shared training images were removed. +- Limitation: 7,195 derived rows have missing DoomSol provenance or belong to another game; simulator manifests are inventoried but excluded. Some captured sessions have no input rows. Main validation shares a recording with training and measures approximate synchronized imitation, not episode generalization. Model training has not run locally. +- Next: use the optional vision trainer with a compatible local model and compare held-out loss before any shadow gameplay evaluation. + +## 2026-10-10 - Unsloth Decision Data Bridge + +- Changed: export pre-decision browser observations to Unsloth's choice-model format; keep whole replays in separate partitions; remove exact state overlap; add an optional training entry point with class-coverage gates. +- Evidence: nine focused tests pass. Four retained local replays yield 233 train and 17 validation decisions, with 11 rejected decisions; all exported rows pass upstream question and gold-label validation. +- Limitation: the existing data lacks fire demonstrations and validation aim examples. Training is blocked by coverage checks; no model was trained and no gameplay improvement is claimed. +- Next: record reviewed demonstrations across independent episodes with all three action classes, then train and measure held-out imitation. + ## 2026 08 27 Public verification workflow - Changed: added a GitHub Actions workflow and a documented end-of-session routine. @@ -39,3 +66,8 @@ This file is the public record of ongoing work. Each meaningful coding, data, tr - Limitation: - Next: ``` +# 2026-10-10: Complete vision passes and a CPU reference backend + +The archive trainer now defaults to a complete epoch, with an explicit debug-only step cap. CPU machines use Transformers/PEFT adapters in both the vision and language linear layers; compatible GPU machines retain the Unsloth backend. Real images and continuous control labels are preserved, and the CPU loss covers only the assistant action response. Checkpoints support resuming training. + +Validation: all 12 repository tests pass; the archive's 3,311 training images and 800 held-out images pass content-hash, control-schema and overlap checks. A local Qwen3.5-0.8B vision forward pass, backward pass and saved adapter succeeded. Complete corpus training and gameplay improvement remain unverified. The broader local campaign and embedded-frame training audit remains outside the public repository. diff --git a/agent/archive_data.py b/agent/archive_data.py new file mode 100644 index 0000000..879ddd5 --- /dev/null +++ b/agent/archive_data.py @@ -0,0 +1,227 @@ +"""Prepare verified DoomSol human recordings for optional Unsloth vision SFT.""" +import argparse +from bisect import bisect_right +from collections import Counter +import hashlib +import json +import math +from pathlib import Path +from urllib.parse import urlparse + +from PIL import Image + +HOSTS = {"doomsol.com", "www.doomsol.com", "doomsol.io", "www.doomsol.io"} +AXES = ("move_x", "move_y", "look_x", "look_y") +BUTTONS = ("fire", "use", "map", "escape") +PROMPT = ("Imitate the recorded DoomSol keyboard and mouse controls for this gameplay image. " + "Return JSON with move_x (A=-1,D=1), move_y (W=-1,S=1), " + "look_x (mouse dx/180), look_y (mouse dy/120), all clipped to [-1,1], " + "and fire, use (Space), map (Tab), escape as 0 or 1. " + "These are demonstrations, not guaranteed optimal actions.") + + +def lines(path): + with path.open(encoding="utf-8") as handle: + for line in handle: + if line.strip(): + yield json.loads(line) + + +def number(value): + if isinstance(value, bool) or not isinstance(value, (float, int)) or not math.isfinite(value): + raise ValueError("invalid numeric control") + return value + + +def controls(row): + result = {name: round(number(row[name]), 6) for name in AXES} + if any(abs(value) > 1 for value in result.values()): + raise ValueError("axis outside [-1,1]") + for name in BUTTONS: + value = row[name] + if value not in (0, 1): + raise ValueError("invalid binary control") + result[name] = int(value) + return result + + +def input_controls(sample): + """Keep original batch semantics; do not invent target aim from mouse movement.""" + if not sample.get("pointerLocked") or "heldKeys" not in sample: + raise ValueError("no locked keyboard recording") + events = sample.get("events", []) + # Long accumulated event batches cannot be aligned to one image reliably. + times = [number(e["t"]) for e in events if "t" in e] + if times and max(times) - min(times) > 850: + raise ValueError("long accumulated event batch") + held = set(sample["heldKeys"]) + down = {e.get("code") for e in events if e.get("kind") == "key" and e.get("down")} + pointer = [e for e in events if e.get("kind") == "pointer"] + return controls({ + "move_x": int("KeyD" in held) - int("KeyA" in held), + "move_y": int("KeyS" in held) - int("KeyW" in held), + "look_x": max(-1, min(1, sum(number(e.get("dx", 0)) for e in pointer) / 180)), + "look_y": max(-1, min(1, sum(number(e.get("dy", 0)) for e in pointer) / 120)), + "fire": int(any(e.get("kind") == "button" and e.get("button") == 0 and e.get("down") for e in events)), + "use": int("Space" in down), "map": int("Tab" in down), "escape": int("Escape" in down), + }) + + +def safe_frame(run, relative): + path = (run / str(relative).replace("\\", "/")).resolve() + if not path.is_relative_to(run.resolve()): + raise ValueError("frame escapes replay") + return path + + +def build_archive(root, output): + root, output = Path(root), Path(output) + if output.exists(): + raise ValueError("choose a fresh output directory to preserve previous exports") + inventory, replay_info = [], {} + for path in sorted((root / "replays").glob("*/meta.json")): + meta = json.loads(path.read_text(encoding="utf-8")) + ids = set(meta.get("game_ids", [])) + accepted = (urlparse(meta.get("source_url") or "").hostname in HOSTS + and ids <= {"doomsol"} and meta.get("mode") == "human_demonstration") + replay_info[path.parent.name] = (path.parent, meta, accepted) + candidates, reserved, stats = [], set(), Counter() + # Preserve every historical test frame, including copied model versions. + for path in sorted((root / "models").rglob("*.jsonl")): + teacher = path.parent.name == "dwasm_teacher" + count = 0 + report_path = path.parent / "training_report.json" + model_report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.exists() else {} + reason = "simulator provenance and action/frame timing unverified" if teacher else "" + if teacher: + with path.open("rb") as handle: + count = sum(1 for line in handle if line.strip()) + inventory.append({"file": path.relative_to(root).as_posix(), "rows": count, "exclusion": reason}) + continue + for row in lines(path): + count += 1 + run_name = row.get("run") or model_report.get("source_run") or model_report.get("source_replay") + info = replay_info.get(run_name) + if not info or not info[2]: + stats["unverified_or_other_game_rows"] += 1 + reason = "missing DoomSol provenance or other game" + continue + try: + frame = safe_frame(info[0], row["frame"]) + if path.name == "test_sequences.jsonl": + reserved.add(str(frame)) + action = controls(row) + if not row.get("pointer_locked"): + raise ValueError("pointer unlocked") + candidates.append((run_name, frame, number(row["wall_time"]), action, "derived_controls")) + except (ValueError, KeyError, TypeError): + stats["unsupported_derived_rows"] += 1 + inventory.append({"file": path.relative_to(root).as_posix(), "rows": count, "exclusion": reason or None}) + for run_name, (run, meta, accepted) in sorted(replay_info.items()): + path = run / "inputs.jsonl" + if not path.exists(): + continue + frames = sorted(meta.get("frames", []), key=lambda f: f["timestamp"]) + timestamps = [number(f["timestamp"]) for f in frames] + count = 0 + for sample in lines(path): + count += 1 + if not accepted: + continue + try: + t = number(sample["wallTime"]) + index = bisect_right(timestamps, t) - 1 + if index < 0 or t - timestamps[index] > 850: + raise ValueError("no recent preceding frame") + frame = safe_frame(run, frames[index]["path"]) + candidates.append((run_name, frame, t, input_controls(sample), "raw_input_batch")) + except (ValueError, KeyError, TypeError): + stats["unaligned_or_unsupported_input_batches"] += 1 + inventory.append({"file": path.relative_to(root).as_posix(), "rows": count, + "exclusion": None if accepted else "missing DoomSol provenance or other game"}) + # Preserve historical test blocks and guard boundaries against alignment lag. + bases = {run: min(r[2] for r in candidates if r[0] == run) for run in {r[0] for r in candidates}} + reserved_blocks = {(run, int((t - bases[run]) // 20000)) + for run, frame, t, _, _ in candidates if str(frame) in reserved} + runs = {row[0] for row in candidates} + validation_runs = set() + if not reserved_blocks and len(runs) >= 2: + validation_runs = {min(runs, key=lambda r: hashlib.sha256(r.encode()).hexdigest())} + if not reserved_blocks and not validation_runs: + raise ValueError("need historical test blocks or independent recording groups") + output.mkdir(parents=True) + (output / "images").mkdir() + rows, seen, image_cache = [], set(), {} + for run, frame, t, action, source in candidates: + block = int((t - bases[run]) // 20000) + split = "validation" if run in validation_runs or (run, block) in reserved_blocks else "train" + if split == "train" and any((run, int((t + delta - bases[run]) // 20000)) in reserved_blocks + for delta in (-850, 850)): + stats["holdout_boundary_guard"] += 1 + continue + try: + if frame not in image_cache: + with Image.open(frame) as image: + image = image.convert("RGB"); w, h = image.size + # Same legacy gameplay crop, excluding site controls and status panels. + image = image.crop((round(w*.165), round(h*.105), round(w*.835), round(h*.665))) + image = image.resize((384, 224), Image.Resampling.BILINEAR) + digest = hashlib.sha256(image.tobytes()).hexdigest() + image.save(output / "images" / f"{digest}.png") + image_cache[frame] = digest + digest = image_cache[frame] + except (OSError, ValueError): + stats["missing_or_invalid_images"] += 1 + continue + key = (split, digest, json.dumps(action, sort_keys=True)) + if key in seen: + stats["duplicate_examples"] += 1 + continue + seen.add(key) + rows.append({"image": f"images/{digest}.png", "image_sha256": digest, + "controls": action, "recording": run, "timestamp": t, + "group": f"{run}:{block}" if reserved_blocks else run, + "label_source": source, "split": split}) + val_images = {r["image_sha256"] for r in rows if r["split"] == "validation"} + kept = [r for r in rows if r["split"] == "validation" or r["image_sha256"] not in val_images] + stats["train_images_overlapping_validation"] = len(rows) - len(kept) + report = {"schema_version": 1, "sources": inventory, "counts": dict(stats), + "split": "historical_20_second_blocks_with_850ms_guard" if reserved_blocks else "whole_recording", + "limitations": ["Synchronized imitation, not proof of pre-action causal timing.", + "Derived labels retain legacy temporal dilation and alignment noise.", + "Single frames omit motion history; recordings may share game episodes.", + "Simulator samples and unverified game sources are inventoried but excluded."]} + for split in ("train", "validation"): + subset = [r for r in kept if r["split"] == split] + if not subset: + raise ValueError(f"empty {split} after image filtering") + (output / f"{split}.jsonl").write_text("".join(json.dumps(r) + "\n" for r in subset), encoding="utf-8") + report[split] = {"rows": len(subset), "fire_positive": sum(r["controls"]["fire"] for r in subset), + "recordings": sorted({r["recording"] for r in subset}), "groups": sorted({r["group"] for r in subset})} + (output / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8") + return report + + +def conversation(row, folder): + """Only image and static control instructions are inputs; metadata stays out.""" + action = controls(row["controls"]) + path = safe_frame(Path(folder), row["image"]) + with Image.open(path) as image: + image = image.convert("RGB").copy() + return {"messages": [ + {"role": "user", "content": [{"type": "text", "text": PROMPT}, {"type": "image", "image": image}]}, + {"role": "assistant", "content": [{"type": "text", "text": json.dumps(action, sort_keys=True)}]}, + ]} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("archive", type=Path, help="local archive containing models/ and replays/") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + report = build_archive(args.archive, args.output) + print(json.dumps({name: report[name] for name in ("train", "validation", "counts")}, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/agent/decision_data.py b/agent/decision_data.py new file mode 100644 index 0000000..ec92c54 --- /dev/null +++ b/agent/decision_data.py @@ -0,0 +1,150 @@ +"""Export closed-loop controller demonstrations for Unsloth decision training.""" +import argparse +import hashlib +import json +import math +from collections import Counter +from pathlib import Path + + +QUESTIONS = { + "action": { + "type": "choice", + "instructions": "Choose the next controller action from visual candidate telemetry.", + "criteria": { + "none": "Do not aim or fire.", + "aim": "Adjust aim toward the tracked candidate.", + "fire": "Tap fire at the tracked candidate.", + }, + } +} + + +def observation_state(perception): + """Allow only pre-decision features, without IDs, paths or outcome evidence.""" + resolution = perception.get("resolution") + if (not isinstance(resolution, list) or len(resolution) != 2 + or any(not isinstance(v, (int, float)) or isinstance(v, bool) + or not math.isfinite(v) or v <= 0 for v in resolution)): + raise ValueError("observation needs a positive resolution") + width, height = resolution + + def number(value): + if (not isinstance(value, (int, float)) or isinstance(value, bool) + or not math.isfinite(value)): + raise ValueError("non-finite or missing observation feature") + return value + + target = perception.get("target") + aim = perception.get("aim_error") + features = {"resolution": resolution, "target": None, "aim_error": None} + if target is not None: + confidence = number(target.get("confidence")) + age = number(target.get("age")) + if not 0 <= confidence <= 1 or age < 1: + raise ValueError("invalid candidate confidence or age") + if not isinstance(aim, dict): + raise ValueError("target has no aim evidence") + features["target"] = {"confidence": confidence, "age": age} + features["aim_error"] = { + "dx": round(number(aim.get("dx")) / width, 6), + "dy": round(number(aim.get("dy")) / height, 6), + "distance": round(number(aim.get("distance")) / math.hypot(width, height), 6), + } + movement = perception.get("movement", {}) + features["movement"] = { + "state": movement.get("state") if movement.get("state") in + {"unknown", "stationary", "turning", "moving"} else "unknown", + "global_change": number(movement.get("global_change")), + "center_change": number(movement.get("center_change")), + } + return json.dumps(features, sort_keys=True, allow_nan=False) + + +def export_replay(meta): + """Pair adjacent observation/decision events; labels imitate the controller.""" + if meta.get("schema_version") != 2 or meta.get("mode") != "closed_loop": + raise ValueError("expected a schema 2 closed_loop replay") + rows, skipped, pending = [], 0, None + for event in meta.get("events", []): + kind = event.get("kind") + if kind == "observation": + pending = event + elif kind == "decision": + action = event.get("action", {}).get("type") + try: + if pending is None or action not in QUESTIONS["action"]["criteria"]: + raise ValueError("unpaired or unsupported decision") + state = observation_state(pending["perception"]) + rows.append({"state": state, "questions": QUESTIONS, + "gold": {"action": action}}) + except (KeyError, TypeError, AttributeError, ValueError): + skipped += 1 + pending = None + else: + pending = None + return rows, skipped + + +def write_dataset(paths, output, holdout_fraction=0.2, seed=7): + if not 0 < holdout_fraction < 1: + raise ValueError("holdout fraction must be between zero and one") + groups, seen, skipped = [], set(), 0 + for path in paths: + meta = json.loads(path.read_text(encoding="utf-8")) + # Content identity makes copied replays one group regardless of filename. + digest = hashlib.sha256(json.dumps(meta, sort_keys=True).encode()).hexdigest() + if digest in seen: + continue + seen.add(digest) + rows, rejected = export_replay(meta) + skipped += rejected + if rows: + groups.append((digest, rows)) + if len(groups) < 2: + raise ValueError("need at least two distinct nonempty replays for a replay holdout") + groups.sort(key=lambda group: hashlib.sha256(f"{seed}:{group[0]}".encode()).hexdigest()) + count = max(1, min(len(groups) - 1, round(len(groups) * holdout_fraction))) + partitions = {"validation": groups[:count], "train": groups[count:]} + # Repeated identical feature states across runs are also excluded from training. + validation_states = {row["state"] for _, rows in partitions["validation"] for row in rows} + train_rows = [row for _, rows in partitions["train"] for row in rows + if row["state"] not in validation_states] + validation_rows = [row for _, rows in partitions["validation"] for row in rows] + if not train_rows: + raise ValueError("no training states remain after excluding validation duplicates") + output.mkdir(parents=True, exist_ok=True) + for name, rows in (("train", train_rows), ("validation", validation_rows)): + (output / f"{name}.jsonl").write_text( + "".join(json.dumps(row, allow_nan=False) + "\n" for row in rows), encoding="utf-8") + report = { + "schema_version": 1, "label_source": "recorded_controller_demonstration", + "split": "whole_replay_with_exact_state_overlap_removed", "seed": seed, + "train_rows": len(train_rows), "validation_rows": len(validation_rows), + "skipped_decisions": skipped, + "removed_train_overlap": sum(len(rows) for _, rows in partitions["train"]) - len(train_rows), + "action_counts": { + name: dict(Counter(row["gold"]["action"] for row in rows)) + for name, rows in (("train", train_rows), ("validation", validation_rows)) + }, + "groups": {name: [digest for digest, _ in values] for name, values in partitions.items()}, + "limitations": ["Candidates are not confirmed enemies.", + "Controller actions are not human-reviewed optimal actions.", + "Validation measures imitation, not kills or level completion.", + "Separate replays may still come from the same game episode."], + } + (output / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8") + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("replays", nargs="+", type=Path, help="closed-loop meta.json files") + parser.add_argument("--output", type=Path, default=Path("output/decision-data")) + parser.add_argument("--seed", type=int, default=7) + args = parser.parse_args() + print(json.dumps(write_dataset(args.replays, args.output, seed=args.seed), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/agent/train_archive_model.py b/agent/train_archive_model.py new file mode 100644 index 0000000..c46e4b7 --- /dev/null +++ b/agent/train_archive_model.py @@ -0,0 +1,182 @@ +"""Optional Unsloth vision imitation training on the verified archive export.""" +import argparse +import json +from pathlib import Path + +try: + from .archive_data import conversation, controls, lines, safe_frame +except ImportError: + from archive_data import conversation, controls, lines, safe_frame +from PIL import Image +import hashlib + + +def check_archive(folder): + partitions = {name: list(lines(folder / f"{name}.jsonl")) for name in ("train", "validation")} + report = json.loads((folder / "report.json").read_text(encoding="utf-8")) + for name, rows in partitions.items(): + if not rows or len(rows) != report[name]["rows"]: + raise ValueError("empty partition or report count mismatch") + if sorted({r["recording"] for r in rows}) != report[name]["recordings"]: + raise ValueError("recording manifest mismatch") + if sorted({r["group"] for r in rows}) != report[name]["groups"]: + raise ValueError("holdout group mismatch") + for row in rows: + controls(row["controls"]) + if row["split"] != name: + raise ValueError("partition label mismatch") + with Image.open(safe_frame(folder, row["image"])) as image: + if hashlib.sha256(image.convert("RGB").tobytes()).hexdigest() != row["image_sha256"]: + raise ValueError("image content hash mismatch") + train, validation = partitions["train"], partitions["validation"] + if ({r["group"] for r in train} & {r["group"] for r in validation} + or {r["image_sha256"] for r in train} & {r["image_sha256"] for r in validation}): + raise ValueError("train/validation overlap") + return partitions + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--data", type=Path, required=True) + parser.add_argument("--check-data", action="store_true") + parser.add_argument("--model", help="explicit local or Hugging Face vision model") + parser.add_argument("--local-files-only", action="store_true") + parser.add_argument("--output", type=Path, default=Path("output/archive-model")) + parser.add_argument("--epochs", type=float, default=1, help="complete passes over the training partition") + parser.add_argument("--steps", type=int, help="explicit debug-only update cap; overrides epochs") + parser.add_argument("--device", choices=("auto", "cpu"), default="auto") + parser.add_argument("--resume", type=Path) + args = parser.parse_args() + partitions = check_archive(args.data) + if args.check_data: + print(json.dumps({"valid": True, **{name: {"rows": len(rows), "fire_positive": sum(r["controls"]["fire"] for r in rows)} + for name, rows in partitions.items()}})) + return + if not args.model or args.epochs <= 0 or (args.steps is not None and args.steps <= 0): + parser.error("choose --model and positive --epochs or --steps") + for rows in partitions.values(): + if {r["controls"]["fire"] for r in rows} != {0, 1}: + parser.error("both partitions must contain firing and non-firing examples") + import torch + use_cpu = args.device == "cpu" or not torch.cuda.is_available() + if use_cpu: + train_cpu(args, partitions) + return + from unsloth import FastVisionModel + from unsloth.trainer import UnslothVisionDataCollator + from trl import SFTTrainer, SFTConfig + + model, processor = FastVisionModel.from_pretrained( + args.model, load_in_4bit=False, local_files_only=args.local_files_only, + use_gradient_checkpointing="unsloth", + ) + model = FastVisionModel.get_peft_model( + model, finetune_vision_layers=True, finetune_language_layers=True, + finetune_attention_modules=True, finetune_mlp_modules=True, + r=8, lora_alpha=8, lora_dropout=0, bias="none", random_state=7, + ) + FastVisionModel.for_training(model) + datasets = {name: [conversation(row, args.data) for row in rows] for name, rows in partitions.items()} + trainer = SFTTrainer( + model=model, processing_class=processor, + data_collator=UnslothVisionDataCollator(model, processor), + train_dataset=datasets["train"], eval_dataset=datasets["validation"], + args=SFTConfig( + output_dir=str(args.output / "checkpoints"), max_steps=args.steps or -1, + num_train_epochs=args.epochs, + per_device_train_batch_size=1, per_device_eval_batch_size=1, + gradient_accumulation_steps=4, learning_rate=2e-4, + bf16=False, fp16=False, report_to="none", seed=7, + remove_unused_columns=False, dataset_text_field="", + dataset_kwargs={"skip_prepare_dataset": True}, max_length=2048, + ), + ) + before = trainer.evaluate() + trainer.train(resume_from_checkpoint=str(args.resume) if args.resume else None) + after = trainer.evaluate() + args.output.mkdir(parents=True, exist_ok=True) + model.save_pretrained(str(args.output / "adapter")) + processor.save_pretrained(str(args.output / "adapter")) + (args.output / "evaluation.json").write_text(json.dumps({"before": before, "after": after, + "claim": "held-out imitation loss only; no gameplay promotion", "epochs": args.epochs, + "debug_step_cap": args.steps}, indent=2), encoding="utf-8") + + +def train_cpu(args, partitions): + """Use the native reference backend when the vision GPU loader cannot run.""" + import torch + from transformers import AutoModelForImageTextToText, AutoProcessor, Trainer, TrainingArguments + from peft import LoraConfig, get_peft_model + + torch.manual_seed(7) + model = AutoModelForImageTextToText.from_pretrained( + args.model, dtype=torch.float32, device_map="cpu", + local_files_only=args.local_files_only, attn_implementation="eager", + ) + processor = AutoProcessor.from_pretrained(args.model, local_files_only=args.local_files_only) + model = get_peft_model(model, LoraConfig( + r=8, lora_alpha=16, lora_dropout=0, bias="none", target_modules="all-linear", + )) + model.config.use_cache = False + + class RecordedImages(torch.utils.data.Dataset): + def __init__(self, rows): + self.rows = rows + + def __len__(self): + return len(self.rows) + + def __getitem__(self, index): + return conversation(self.rows[index], args.data) + + def collate(rows): + if len(rows) != 1: + raise ValueError("CPU image collator requires batch size one") + messages = rows[0]["messages"] + inputs = processor.apply_chat_template( + messages, tokenize=True, return_dict=True, return_tensors="pt", enable_thinking=False, + ) + prefix = processor.apply_chat_template( + messages[:-1], tokenize=True, add_generation_prompt=True, + return_dict=True, return_tensors="pt", enable_thinking=False, + ) + length = prefix["input_ids"].shape[1] + if not torch.equal(inputs["input_ids"][:, :length], prefix["input_ids"]): + raise ValueError("assistant prefix mismatch; refusing an incorrect loss mask") + labels = inputs["input_ids"].clone() + labels[:, :length] = -100 + if not (labels != -100).any(): + raise ValueError("empty assistant labels") + inputs["labels"] = labels + return inputs + + trainer = Trainer( + model=model, processing_class=processor, data_collator=collate, + train_dataset=RecordedImages(partitions["train"]), + eval_dataset=RecordedImages(partitions["validation"]), + args=TrainingArguments( + output_dir=str(args.output / "checkpoints"), use_cpu=True, + num_train_epochs=args.epochs, max_steps=args.steps or -1, + per_device_train_batch_size=1, per_device_eval_batch_size=1, + gradient_accumulation_steps=4, learning_rate=2e-4, + lr_scheduler_type="cosine", warmup_ratio=.03, + logging_steps=1, save_steps=100, save_total_limit=2, + bf16=False, fp16=False, report_to="none", seed=7, + remove_unused_columns=False, dataloader_pin_memory=False, + optim="adamw_torch", + ), + ) + before = trainer.evaluate() + trainer.train(resume_from_checkpoint=str(args.resume) if args.resume else None) + after = trainer.evaluate() + model.save_pretrained(str(args.output / "adapter")) + processor.save_pretrained(str(args.output / "adapter")) + (args.output / "evaluation.json").write_text(json.dumps({ + "before": before, "after": after, "epochs": args.epochs, + "debug_step_cap": args.steps, "backend": "Transformers/PEFT CPU reference", + "claim": "Full held-out imitation loss only; no gameplay promotion.", + }, indent=2), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/agent/train_corpus_model.py b/agent/train_corpus_model.py new file mode 100644 index 0000000..3ee02b8 --- /dev/null +++ b/agent/train_corpus_model.py @@ -0,0 +1,174 @@ +"""CPU LoRA training with real images, full corpus coverage and paired holdout evaluation.""" +import os +os.environ['HF_HUB_OFFLINE']='1' +os.environ['TOKENIZERS_PARALLELISM']='false' +import argparse,json,time,random,sqlite3,hashlib,math,contextlib +from pathlib import Path +from collections import Counter +import torch +from transformers import AutoModelForImageTextToText,AutoProcessor +from peft import LoraConfig,get_peft_model,PeftModel +from PIL import Image +parser=argparse.ArgumentParser() +parser.add_argument('--dataset', type=Path, required=True) +parser.add_argument('--model', type=Path, required=True) +parser.add_argument('--output', type=Path, required=True) +parser.add_argument('--resume', type=Path) +args=parser.parse_args() +BASE=args.dataset.resolve(); OUT=args.output.resolve(); OUT.mkdir(parents=True,exist_ok=True) +PUBLIC=OUT +torch.set_num_threads(4); torch.manual_seed(7); random.seed(7) +from agent.training_dataset import records, validate +validate(BASE) +db=sqlite3.connect(':memory:') +db.execute('create table examples(id integer primary key, split text, domain text, payload text)') +for split in ('train', 'validation'): + for row in records(BASE, split, verify_images=False): + if row.get('image'): row['image']=str(BASE/row['image']) + db.execute('insert into examples values(?,?,?,?)',(row['id'],split,row['domain'],json.dumps(row))) +db.commit() +audit=json.loads((BASE/'manifest.json').read_text()) +started=time.time();progress={'stage':'loading','pid':os.getpid(),'backend':'Transformers/PEFT CPU reference backend; Unsloth published base model','source_audit':'manifest.json','claim':'No gameplay improvement established.'} +def status(**fields): + progress.update(fields,updated_at=time.time()) + text=json.dumps(progress,indent=2) + (OUT/'status.json').write_text(text,encoding='utf-8');(PUBLIC/'local-training-status.json').write_text(text,encoding='utf-8') + print(json.dumps(fields),flush=True) +status() +base_model=args.model.resolve() +model=AutoModelForImageTextToText.from_pretrained(str(base_model),dtype=torch.float32,device_map='cpu',local_files_only=True,attn_implementation='eager') +processor=AutoProcessor.from_pretrained(str(base_model),local_files_only=True) +if args.resume:model=PeftModel.from_pretrained(model,str(args.resume/'adapter'),is_trainable=True) +else:model=get_peft_model(model,LoraConfig(r=8,lora_alpha=16,lora_dropout=0,bias='none',target_modules='all-linear')) +trainable={n:p for n,p in model.named_parameters() if p.requires_grad} +assert any('visual' in n for n in trainable) and any('language' in n for n in trainable) +(OUT/'trainable-parameters.json').write_text(json.dumps({n:list(p.shape) for n,p in trainable.items()},indent=2)) +optimizer=torch.optim.AdamW(list(trainable.values()),lr=2e-4,weight_decay=.01) +training_ids=[r[0] for r in db.execute("select id from examples where split='train' order by id")] +visual_ids=[r[0] for r in db.execute("select id from examples where split='train' and domain like '%gameplay_image' order by id")] +# Every eligible training row is consumed once, followed by a single image-only +# consolidation pass so the much larger engine corpus does not define the final +# deployment input. Neither phase has a maximum-step cap. +random.Random(7).shuffle(training_ids);random.Random(8).shuffle(visual_ids) +# Exercise each input schema in the first real accumulation batch while still +# consuming each training example exactly once in this pass. +first_ids=[r[0] for r in db.execute("select min(id) from examples where split='train' group by domain order by domain")] +first_set=set(first_ids);training_ids=first_ids+[identity for identity in training_ids if identity not in first_set] +assert len(training_ids)==len(set(training_ids)) +phases=[('full_corpus_epoch',training_ids),('image_consolidation_epoch',visual_ids)] +schedule={'phases':[{'name':n,'examples':len(ids),'passes':1} for n,ids in phases], + 'gradient_accumulation':4,'learning_rate':2e-4,'adapter_rank':8,'adapter_alpha':16, + 'trainable_parameters':sum(p.numel() for p in trainable.values()),'model_revision':'23c69c53358a07516b5827588b3fdb12ae78fd65', + 'eval':'After training: every held-out row, paired base versus adapter; assistant-token loss and deterministic JSON action output.', + 'promotion':'Requires held-out action checks and real gameplay against the current controller on fixed seeds; no automatic promotion.'} +(OUT/'schedule.json').write_text(json.dumps(schedule,indent=2));(PUBLIC/'faithful-training-plan.json').write_text(json.dumps(schedule,indent=2)) +audit_hash=hashlib.sha256((BASE/'manifest.json').read_bytes()).hexdigest() +state={'phase':0,'cursor':0,'updates':0,'examples_seen':0,'domains_seen':{},'audit_sha256':audit_hash} +if args.resume: + state=json.loads((args.resume/'state.json').read_text()) + assert state['audit_sha256']==audit_hash, 'Corpus changed; do not resume against different examples' + optimizer.load_state_dict(torch.load(args.resume/'optimizer.pt',map_location='cpu',weights_only=True)) + torch.set_rng_state(torch.load(args.resume/'rng.pt',weights_only=True)) +def row_for(identity):return json.loads(db.execute('select payload from examples where id=?',(identity,)).fetchone()[0]) +def encode(row): + if row['domain']=='doomsol_gameplay_image': + with Image.open(row['image']) as im:image=im.convert('RGB').copy() + prompt='Imitate the recorded DoomSol controls from this gameplay image. Return only JSON: move_x (A=-1,D=1), move_y (W=-1,S=1), look_x (mouse dx/180), look_y (mouse dy/120), axes clipped to [-1,1]; fire, use (Space), map (Tab), escape as 0 or 1. These are recorded actions, not optimal-policy labels.' + content=[{'type':'text','text':prompt},{'type':'image','image':image}] + elif row['domain']=='dwasm_hud_grounding': + with Image.open(row['image']) as im:image=im.convert('RGB').copy() + prompt='Read the currently displayed Doom health and armor from this rendered gameplay image. Return only JSON with numeric health and armor values. This task is HUD perception, not choosing an action.' + content=[{'type':'text','text':prompt},{'type':'image','image':image}] + elif row['domain']=='vizdoom_policy_gameplay_image': + with Image.open(row['image']) as im:image=im.convert('RGB').copy() + prompt='Imitate the recorded single-button ViZDoom policy action from this engine gameplay image. Return only JSON with an actions list containing one of: turn left, turn right, move forward, move backward, strafe left, strafe right, attack. This is an older policy demonstration, not an optimal or expert label.' + content=[{'type':'text','text':prompt},{'type':'image','image':image}] + elif row['domain']=='engine_observation_and_map_memory': + prompt='Auxiliary ViZDoom engine and map-memory task. This observation includes privileged engine data. Imitate the recorded controller action list; return only JSON with an actions list. This is not a camera-only task or an optimal-policy label.\n'+json.dumps(row['state'],sort_keys=True) + content=[{'type':'text','text':prompt}] + else: + content=[{'type':'text','text':'Choose the recorded browser candidate controller action (none, aim, fire). Return only JSON with action.\n'+json.dumps(row['state'],sort_keys=True)}] + user=[{'role':'user','content':content}] + complete=user+[{'role':'assistant','content':[{'type':'text','text':json.dumps(row['target'],sort_keys=True)}]}] + inputs=processor.apply_chat_template(complete,tokenize=True,return_dict=True,return_tensors='pt',enable_thinking=False) + prefix=processor.apply_chat_template(user,tokenize=True,add_generation_prompt=True,return_dict=True,return_tensors='pt',enable_thinking=False) + n=prefix['input_ids'].shape[1] + assert torch.equal(inputs['input_ids'][:,:n],prefix['input_ids']), 'Assistant mask prefix mismatch' + labels=inputs['input_ids'].clone();labels[:,:n]=-100 + assert (labels!=-100).sum()>0 + if inputs['input_ids'].shape[1]>4096:raise ValueError('Record exceeds validated context; inspect provenance rather than truncate') + return inputs,labels,prefix +def checkpoint(): + from agent.training_checkpoint import publish_checkpoint + def writer(folder): + model.save_pretrained(str(folder/'adapter'));processor.save_pretrained(str(folder/'adapter')) + torch.save(optimizer.state_dict(),folder/'optimizer.pt');torch.save(torch.get_rng_state(),folder/'rng.pt') + (folder/'state.json').write_text(json.dumps(state,indent=2)) + folder=publish_checkpoint(OUT/'checkpoints',f"update-{state['updates']:08d}",writer) + status(checkpoint=str(folder.resolve())) +def evaluation(): + model.eval();metrics={};count=0 + target_path=OUT/'paired-holdout-predictions.jsonl' + with target_path.open('w',encoding='utf-8') as handle,torch.inference_mode(): + for identity, in db.execute("select id from examples where split='validation' order by id"): + row=row_for(identity);inputs,labels,prefix=encode(row);result={'id':identity,'domain':row['domain'],'target':row['target'],'provenance':row['provenance']} + for variant in ('base','trained'): + context=model.disable_adapter() if variant=='base' else contextlib.nullcontext() + with context: + loss=model(**inputs,labels=labels,use_cache=False).loss.item() + generated=model.generate(**prefix,max_new_tokens=256,do_sample=False,use_cache=True) + text=processor.decode(generated[0,prefix['input_ids'].shape[1]:],skip_special_tokens=True) + try: + pred=json.loads(text);valid=isinstance(pred,dict) and set(pred)==set(row['target']) + if valid and row['domain']=='doomsol_gameplay_image': + valid=all(isinstance(pred[k],(int,float)) and not isinstance(pred[k],bool) and math.isfinite(pred[k]) and -1<=pred[k]<=1 for k in ('move_x','move_y','look_x','look_y')) and all(pred[k] in (0,1) for k in ('fire','use','map','escape')) + elif valid and 'actions' in row['target']:valid=isinstance(pred['actions'],list) and all(isinstance(a,str) for a in pred['actions']) + elif valid and row['domain']=='dwasm_hud_grounding':valid=all(isinstance(v,(int,float)) and not isinstance(v,bool) and math.isfinite(v) for v in pred.values()) + elif valid:valid=pred['action'] in ('none','aim','fire') + except (ValueError,TypeError):pred=None;valid=False + key=row['domain']+':'+variant;m=metrics.setdefault(key,{'rows':0,'loss_sum':0.,'valid_json':0,'exact_actions':0,'axis_abs_error_sum':0.,'axis_values':0,'button_confusion':{k:{'tp':0,'fp':0,'fn':0,'tn':0} for k in ('fire','use','map','escape')}}) + m['rows']+=1;m['loss_sum']+=loss;m['valid_json']+=valid;m['exact_actions']+=pred==row['target'] + if valid and row['domain']=='doomsol_gameplay_image': + for button in ('fire','use','map','escape'): + expected=bool(row['target'][button]);actual=bool(pred[button]);cell='tp' if expected and actual else 'fn' if expected else 'fp' if actual else 'tn';m['button_confusion'][button][cell]+=1 + for axis in ('move_x','move_y','look_x','look_y'): + value=pred.get(axis) + if isinstance(value,(int,float)) and not isinstance(value,bool) and math.isfinite(value):m['axis_abs_error_sum']+=abs(value-row['target'][axis]);m['axis_values']+=1 + result[variant]={'loss':loss,'prediction':pred,'raw_output':text} + handle.write(json.dumps(result)+'\n');handle.flush();count+=1 + if count%10==0:status(stage='full_holdout_evaluation',evaluated_rows=count,metrics=metrics) + result={'metrics':metrics,'evaluated_rows':count,'training_coverage':state,'model_revision':schedule['model_revision'],'corpus_audit_sha256':audit_hash,'gameplay_validation':'pending; model not promoted'} + text=json.dumps(result,indent=2);(OUT/'evaluation.json').write_text(text);(PUBLIC/'faithful-training-evaluation.json').write_text(text) +try: + model.train();seen=Counter(state['domains_seen']);optimizer.zero_grad(set_to_none=True) + for phase_index,(phase,identities) in enumerate(phases): + if phase_indexprevious.get('updates',0) + return advanced and (previous.get('recorded_date')!=current.get('recorded_date') + or current.get('examples_seen',0)//10000>previous.get('examples_seen',0)//10000) + +def publish(repo,status,repository): + credential=subprocess.run(['git','credential','fill'],input='protocol=https\nhost=github.com\n\n', + text=True,capture_output=True,check=True,cwd=repo) + values=dict(line.split('=',1) for line in credential.stdout.splitlines() if '=' in line) + token=values['password'] + def api(path,method='GET',data=None): + request=urllib.request.Request('https://api.github.com/'+path, + data=json.dumps(data).encode() if data is not None else None,method=method, + headers={'Authorization':'Bearer '+token,'Accept':'application/vnd.github+json','Content-Type':'application/json'}) + with urllib.request.urlopen(request,timeout=45) as response:return json.load(response) + identity=api('user') + email=subprocess.check_output(['git','config','user.email'],cwd=repo,text=True).strip() + name=subprocess.check_output(['git','config','user.name'],cwd=repo,text=True).strip() + try: + verified=api('user/emails') + associated=any(item['email']==email and item['verified'] for item in verified) + except urllib.error.HTTPError as error: + if error.code!=403:raise + # A repository token may lack email access. Verify the exact configured + # email against an existing commit already associated with this account. + head=subprocess.check_output(['git','rev-parse','HEAD'],cwd=repo,text=True).strip() + commit=api(f'repos/{repository}/commits/{head}') + associated=bool(commit.get('author') and commit['author']['login']==identity['login'] + and commit['commit']['author']['email']==email) + if not associated: + raise ValueError('Git author email is not associated with the authenticated account') + endpoint=f'repos/{repository}/contents/training/progress.json' + try: + existing=api(endpoint+'?ref=main') + previous=json.loads(base64.b64decode(existing['content'])) + except urllib.error.HTTPError as error: + if error.code!=404:raise + existing=None;previous=None + current=public_report(status) + if not should_publish(previous,current):return {'published':False,'reason':'No new daily progress or milestone'} + body={'branch':'main','message':f"Record training progress at {current.get('examples_seen',0)} examples", + 'content':base64.b64encode((json.dumps(current,indent=2,allow_nan=False)+'\n').encode()).decode(), + 'author':{'name':name,'email':email},'committer':{'name':name,'email':email}} + if existing:body['sha']=existing['sha'] + saved=api(endpoint,'PUT',body) + commit=api(f"repos/{repository}/commits/{saved['commit']['sha']}") + if not commit.get('author') or commit['author']['login']!=identity['login']: + raise ValueError('Published commit author did not resolve to the authenticated account') + return {'published':True,'sha':saved['commit']['sha'],'author':identity['login'],'url':saved['commit']['html_url']} + +def main(): + parser=argparse.ArgumentParser() + parser.add_argument('--status',type=Path,required=True) + parser.add_argument('--repository',default='nightangelflowerwin-ops/doomsolagent') + parser.add_argument('--watch',action='store_true') + args=parser.parse_args();repo=Path(__file__).resolve().parents[1] + while True: + try: + status=json.loads(args.status.read_text()) + print(json.dumps(publish(repo,status,args.repository)),flush=True) + except Exception as error: + # Credential values never enter log messages. + print(json.dumps({'published':False,'error_type':type(error).__name__}),flush=True) + if not args.watch:raise + if not args.watch:break + time.sleep(60) + +if __name__=='__main__':main() diff --git a/tools/verify_1000.py b/tools/verify_1000.py new file mode 100644 index 0000000..e6d11e5 --- /dev/null +++ b/tools/verify_1000.py @@ -0,0 +1,39 @@ +"""Run the complete offline suite 1000 times against frozen source files.""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import sys +import time +import unittest + +parser=argparse.ArgumentParser() +parser.add_argument('--output',type=Path,required=True) +args=parser.parse_args() +repo=Path(__file__).resolve().parents[1] +out=args.output.resolve();out.mkdir(parents=True,exist_ok=True) +os.chdir(repo);sys.path.insert(0,str(repo)) +def hashes(): + return {str(p.relative_to(repo)):hashlib.sha256(p.read_bytes()).hexdigest() + for directory in ('agent','tests') for p in sorted((repo/directory).glob('*.py'))} +frozen=hashes(); ledger=out/'1000-test-runs.jsonl' +if ledger.exists():raise SystemExit('Existing results must be preserved; choose a fresh output directory') +(out/'1000-tested-source-hashes.json').write_text(json.dumps(frozen,indent=2)) +started=time.time();total=0 +with ledger.open('w',encoding='utf-8') as handle: + for seed in range(1000): + if hashes()!=frozen:raise SystemExit('Source changed during verification') + os.environ['STRESS_SEED']=str(seed) + stream=io.StringIO() + result=unittest.TextTestRunner(stream=stream).run(unittest.defaultTestLoader.discover(str(repo/'tests'))) + total+=result.testsRun + row=dict(run=seed+1,seed=seed,tests=result.testsRun,failures=len(result.failures), + errors=len(result.errors),skipped=len(result.skipped),output=stream.getvalue()) + handle.write(json.dumps(row)+'\n');handle.flush() + summary=dict(completed_runs=seed+1,requested_runs=1000,test_executions=total, + elapsed_seconds=time.time()-started,scope='Offline tests; gameplay requires separate engine evaluation') + (out/'1000-test-summary.json').write_text(json.dumps(summary,indent=2)) + if not result.wasSuccessful():raise SystemExit('Failure preserved; repair and repeat into a fresh directory') + if (seed+1)%25==0:print(json.dumps(summary),flush=True) diff --git a/tools/verify_native_encoder_1000.py b/tools/verify_native_encoder_1000.py new file mode 100644 index 0000000..fe804b4 --- /dev/null +++ b/tools/verify_native_encoder_1000.py @@ -0,0 +1,50 @@ +"""Check all five task encodings 1000 times with a real local Qwen processor.""" +import argparse +import ast +import hashlib +import json +from pathlib import Path +import sys +import time +import torch +from PIL import Image +from transformers import AutoProcessor + +parser=argparse.ArgumentParser() +parser.add_argument('--dataset',type=Path,required=True) +parser.add_argument('--model',type=Path,required=True) +parser.add_argument('--output',type=Path,required=True) +args=parser.parse_args() +repo=Path(__file__).resolve().parents[1];sys.path.insert(0,str(repo)) +from agent.training_dataset import records +root=args.dataset.resolve();out=args.output.resolve();out.mkdir(parents=True,exist_ok=True) +ledger=out/'1000-native-encoder-runs.jsonl' +if ledger.exists():raise SystemExit('Choose a fresh output directory to preserve earlier results') +torch.set_num_threads(2) +processor=AutoProcessor.from_pretrained(str(args.model.resolve()),local_files_only=True) +source=repo/'agent/train_corpus_model.py' +tree=ast.parse(source.read_text()) +function=next(n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name=='encode') +scope=dict(processor=processor,torch=torch,Image=Image,json=json) +exec(compile(ast.Module(body=[function],type_ignores=[]),str(source),'exec'),scope) +all_rows=sorted((row for split in ('train','validation') for row in records(root,split)),key=lambda row:row['id']) +domains=sorted({row['domain'] for row in all_rows}) +selected={domain:[row for row in all_rows if row['domain']==domain][:1000] for domain in domains} +started=time.time() +with ledger.open('w',encoding='utf-8') as handle: + for run in range(1000): + checks=[] + for domain in domains: + row=dict(selected[domain][run%len(selected[domain])]) + digest=hashlib.sha256(json.dumps(row,sort_keys=True).encode()).hexdigest() + if row.get('image'):row['image']=str(root/row['image']) + inputs,labels,prefix=scope['encode'](row) + n=prefix['input_ids'].shape[1] + assert torch.all(labels[:,:n]==-100) and torch.all(labels[:,n:]!=-100) + checks.append(dict(domain=domain,id=row['id'],input_tokens=inputs['input_ids'].shape[1], + assistant_tokens=int((labels!=-100).sum()),example_sha256=digest)) + handle.write(json.dumps(dict(run=run+1,checks=checks))+'\n');handle.flush() + summary=dict(completed_runs=run+1,requested_runs=1000,native_domain_checks=(run+1)*len(domains), + elapsed_seconds=time.time()-started,scope='Real local processor; no model-forward or gameplay claim') + (out/'1000-native-encoder-summary.json').write_text(json.dumps(summary,indent=2)) + if (run+1)%25==0:print(json.dumps(summary),flush=True) diff --git a/training/README.md b/training/README.md new file mode 100644 index 0000000..91465e7 --- /dev/null +++ b/training/README.md @@ -0,0 +1,15 @@ +# Nightangel training progress + +Training runs on the owner computer. `progress.json` records the latest published progress without computer paths, process IDs, or credentials. Its commit history preserves previous reports. + +The local reporter publishes a commit under the configured human author when training advances on a new UTC date, crosses another 10,000 examples, changes phase, or changes stage. Unchanged status and individual optimizer steps do not produce repeated commits. GitHub verifies the author against the authenticated account. + +GitHub Actions records 1,000 complete offline suite runs on daily verification, manual verification, and a new published progress report. Results are downloadable workflow artifacts. These runs verify the project; they do not move model training onto GitHub hardware or prove gameplay improvement. + +Start the reporter locally after configuring Git credentials and the author associated with your GitHub account: + +```sh +python tools/publish_training_progress.py --status /path/to/local/training/status.json --watch +``` + +The reporter uses the existing Git credential helper. It never saves or prints the credential. Reports go to `main`, where eligible human authored commits can appear on the contribution graph.