diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f8ba93..27d4ff4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,10 +60,15 @@ jobs: python scripts\run_fixture_demo.py --experiment "experiments\ci-$protocol" --max-frames 2 --profile wide --protocol $protocol --blind-seed 12345 --dump-dir "release_dumps\$protocol" } - - name: Upload smoke report + - name: Upload failed-run evidence uses: actions/upload-artifact@v4 - if: always() + if: failure() with: - name: smoke-report-${{ matrix.python-version }} - path: experiments/ci-smoke/runs/**/report.html + name: failed-run-evidence-${{ matrix.python-version }} + path: | + experiments/ci-smoke/runs/**/report.html + experiments/ci-smoke/runs/**/report.json + experiments/ci-smoke/runs/**/results.json + experiments/ci-smoke/runs/**/processed/*.png + experiments/ci-smoke/runs/**/crops/*.png if-no-files-found: ignore diff --git a/README.md b/README.md index c9e2828..78cf71f 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ Illinois Wesleyan/American Journal of Physics single-photon video set. The IWU set is scientifically stronger but stays external/manual until redistribution terms are confirmed. +See the [Fixture Catalog](docs/fixture_catalog.md) for expected detector behavior, limitations, licenses, and provenance rules. + ## Capture Better Footage - Capture laser and control footage under matched camera settings, exposure, focus, distance, and surface geometry. @@ -193,10 +195,10 @@ Windows users can use the compatibility wrapper: Release archives are named by version, operating system, and architecture: ```text -dist/LaserLab-v0.3.0-windows-x86_64.zip -dist/LaserLab-v0.3.0-linux-x86_64.zip -dist/LaserLab-v0.3.0-macos-x86_64.zip -dist/LaserLab-v0.3.0-macos-arm64.zip +dist/LaserLab-v-windows-x86_64.zip +dist/LaserLab-v-linux-x86_64.zip +dist/LaserLab-v-macos-x86_64.zip +dist/LaserLab-v-macos-arm64.zip ``` GitHub Actions builds and smoke-tests all four native bundles. Tesseract remains optional at runtime; OCR is reported as unavailable when the external Tesseract executable is not installed. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 6e67681..d6b13f9 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -38,10 +38,10 @@ On Windows, the existing wrapper installs build dependencies and delegates to th Outputs are target-specific: ```text -dist/LaserLab-v0.3.0-windows-x86_64.zip -dist/LaserLab-v0.3.0-linux-x86_64.zip -dist/LaserLab-v0.3.0-macos-x86_64.zip -dist/LaserLab-v0.3.0-macos-arm64.zip +dist/LaserLab-v-windows-x86_64.zip +dist/LaserLab-v-linux-x86_64.zip +dist/LaserLab-v-macos-x86_64.zip +dist/LaserLab-v-macos-arm64.zip ``` The zip includes: @@ -76,11 +76,11 @@ To process every frame of a video, omit `--max-frames`: Full-frame `wide` runs generate many processed images and candidate crops. Keep them under `experiments\`, which is intentionally ignored by Git. -## v0.3 Release Checklist +## v0.3.x Release Checklist - Run the bundled demo for `diffraction`, `speckle`, `ocr`, and `anomaly` with `-MaxFrames 2`. - Confirm JSON/HTML reports include badges, q-values, protocol, detector-family summaries, and conservative interpretation text. - Export a review bundle and inspect `manifest.json`, `report.json`, `results.json`, `environment.json`, `hashes.json`, top crops, and `README.txt`. - Build all four native archives through the `Cross-platform Release` workflow. - Verify each platform's GUI `--smoke` and CLI `--help` checks pass. -- Push a `v0.3.0` tag only after tests, executable smoke, and bundled fixture smoke pass. +- Push the matching `v0.3.x` tag only after tests, executable smoke, and bundled fixture smoke pass. diff --git a/docs/fixture_catalog.md b/docs/fixture_catalog.md new file mode 100644 index 0000000..b71122e --- /dev/null +++ b/docs/fixture_catalog.md @@ -0,0 +1,26 @@ +# Fixture Catalog + +LaserLab separates redistributable bundled fixtures from external scientific references. Bundled fixtures are small workflow and detector sanity checks; none is a matched control for a user's camera setup. + +## Bundled Fixtures + +| Fixture | Phenomena | License | Intended Use | Expected Behavior | Limitations | +| --- | --- | --- | --- | --- | --- | +| Double slit experiment | interference, simulation | CC BY-SA 4.0 | Structured control | Stable fringe-like spatial frequencies; no readable text expected | Rendered simulation, not physical camera footage | +| Young's double slit experiment clip | diffraction, interference | CC BY-SA 3.0 or GFDL | Optical demo input | FFT and fringe metrics should respond more strongly than OCR | Compressed educational video with unknown capture history | +| 3D interference through two pinholes | interference, diffraction, visualization | CC BY-SA 4.0 | Structured optical positive | Repeatable spectral structure expected; text recovery is not | Tomographic visualization, not raw sensor footage | +| Focused Laguerre-Gaussian beam | structured beam, diffraction, rendered control | CC BY-SA 4.0 | Non-text false-positive control | Ring and texture metrics may respond while OCR remains null | Rendered beam field, not a matched camera control | + +Exact source URLs, attribution, filenames, SHA-256 hashes, and download URLs are stored in `sample_media/fixture_manifest.json`. Human-readable attribution is in `sample_media/ATTRIBUTION.md`. + +## External Manual Fixture + +The Illinois Wesleyan single-photon double-slit and ghost-imaging video set is listed in the in-app catalog but is not downloaded or redistributed. Its source page is . Redistribution terms are not confirmed, so users must obtain it manually and document local provenance. + +## Validation Rules + +Every catalog entry must include an ID, title, media kind, analysis label, license, attribution, source page, optical phenomena, expected detector behavior, limitations, and redistribution status. Every redistributable entry must name a bundled file. Fixture demos stop before analysis if required metadata or a bundled file is missing. + +## Interpretation + +These fixtures prove that the software can process known optical structure and preserve provenance. They do not validate a claim about a user's footage. Evidence-bearing experiments still require controls recorded with the same camera and optical conditions as the laser capture. diff --git a/gui/lab_dashboard.py b/gui/lab_dashboard.py index f682444..7e231d4 100644 --- a/gui/lab_dashboard.py +++ b/gui/lab_dashboard.py @@ -373,8 +373,10 @@ def _compare_tab(self) -> QWidget: def _fixtures_tab(self) -> QWidget: page = QWidget() layout = QVBoxLayout(page) - self.fixture_table = QTableWidget(0, 5) - self.fixture_table.setHorizontalHeaderLabels(["ID", "Title", "Label", "License", "Source"]) + self.fixture_table = QTableWidget(0, 7) + self.fixture_table.setHorizontalHeaderLabels( + ["ID", "Title", "Label", "Phenomena", "License", "Expected behavior", "Source"] + ) self.fixture_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) layout.addWidget(self.fixture_table, 1) @@ -847,7 +849,15 @@ def _load_fixtures(self) -> None: fixtures = list_fixtures(include_restricted=True) self.fixture_table.setRowCount(len(fixtures)) for row, item in enumerate(fixtures): - values = [item.get("id", ""), item.get("title", ""), item.get("label", ""), item.get("license", ""), item.get("source_page", "")] + values = [ + item.get("id", ""), + item.get("title", ""), + item.get("label", ""), + ", ".join(item.get("phenomena", [])), + item.get("license", ""), + item.get("expected_behavior", ""), + item.get("source_page", ""), + ] for column, value in enumerate(values): self.fixture_table.setItem(row, column, QTableWidgetItem(str(value))) diff --git a/laserlab/__init__.py b/laserlab/__init__.py index 207bfea..ca1f5d6 100644 --- a/laserlab/__init__.py +++ b/laserlab/__init__.py @@ -6,4 +6,4 @@ __all__ = ["__version__"] -__version__ = "0.3.0" +__version__ = "0.3.1" diff --git a/laserlab/app_api.py b/laserlab/app_api.py index 60778c6..162251f 100644 --- a/laserlab/app_api.py +++ b/laserlab/app_api.py @@ -31,6 +31,7 @@ def add_capture( all_frames: bool = False, frame_interval: int = 5, max_frames: int | None = None, + capture_metadata: dict[str, Any] | None = None, ) -> dict[str, Any]: """Ingest a video or image set into an experiment.""" return init_experiment( @@ -40,6 +41,7 @@ def add_capture( experiment_dir=Path(experiment_dir), frame_interval=1 if all_frames else frame_interval, max_frames=max_frames, + capture_metadata=capture_metadata, ) diff --git a/laserlab/fixtures.py b/laserlab/fixtures.py index a318f80..4c3adc5 100644 --- a/laserlab/fixtures.py +++ b/laserlab/fixtures.py @@ -9,6 +9,21 @@ from .artifacts import ensure_dir, sha256_file, write_json + +REQUIRED_FIXTURE_FIELDS = { + "id", + "title", + "kind", + "label", + "license", + "attribution", + "source_page", + "phenomena", + "expected_behavior", + "limitations", + "redistributable", +} + FIXTURE_CATALOG: list[dict[str, Any]] = [ { "id": "commons-double-slit-experiment", @@ -22,6 +37,9 @@ "license": "CC BY-SA 4.0", "attribution": "G. Mikaberidze", "notes": "Small simulation useful for detector sanity checks, not physical laser footage.", + "phenomena": ["interference", "simulation"], + "expected_behavior": "Stable fringe-like spatial frequencies; no readable text is expected.", + "limitations": "Rendered simulation rather than camera footage; use only as a structured control.", "redistributable": True, }, { @@ -36,6 +54,9 @@ "license": "CC BY-SA 3.0 or GFDL", "attribution": "Cookatoo.ergo.ZooM", "notes": "Educational double-slit video clip; useful as a small redistributable optical fixture.", + "phenomena": ["diffraction", "interference"], + "expected_behavior": "FFT and fringe metrics should respond more strongly than OCR metrics.", + "limitations": "Educational compressed video with unknown camera and processing history.", "redistributable": True, }, { @@ -50,6 +71,9 @@ "license": "CC BY-SA 4.0", "attribution": "BrendaEM", "notes": "Tomographic visualization from monochromatic laser light through two pinholes.", + "phenomena": ["interference", "diffraction", "visualization"], + "expected_behavior": "Repeatable spectral structure is expected; text recovery is not.", + "limitations": "Tomographic visualization, not a raw sensor recording of an experiment.", "redistributable": True, }, { @@ -64,6 +88,9 @@ "license": "CC BY-SA 4.0", "attribution": "Jack Kingsley-Smith", "notes": "Rendered optical beam fixture; useful for false-positive checks against structured non-text fields.", + "phenomena": ["structured_beam", "diffraction", "rendered_control"], + "expected_behavior": "Ring and texture metrics may respond while OCR should remain null.", + "limitations": "Rendered beam field; not physical footage and not a matched camera control.", "redistributable": True, }, { @@ -77,6 +104,9 @@ "license": "Classroom-use page; redistribution not confirmed", "attribution": "R. S. Aspden, M. J. Padgett, G. C. Spalding", "notes": "Scientifically strongest candidate footage. Keep external/manual until redistribution terms are confirmed.", + "phenomena": ["single_photon", "interference", "diffraction"], + "expected_behavior": "Accumulating interference structure should emerge over time.", + "limitations": "External manual source; redistribution and automated download are not permitted by LaserLab.", "redistributable": False, }, ] @@ -88,11 +118,60 @@ def list_fixtures(include_restricted: bool = False) -> list[dict[str, Any]]: return [item for item in FIXTURE_CATALOG if item["redistributable"]] +def get_fixture(fixture_id: str) -> dict[str, Any]: + for item in FIXTURE_CATALOG: + if item["id"] == fixture_id: + return item.copy() + raise KeyError(f"Unknown fixture: {fixture_id}") + + +def fixture_metadata(fixture_id: str) -> dict[str, Any]: + item = get_fixture(fixture_id) + return { + "fixture_id": item["id"], + "fixture_title": item["title"], + "source_page": item["source_page"], + "license": item["license"], + "attribution": item["attribution"], + "phenomena": item["phenomena"], + "expected_behavior": item["expected_behavior"], + "limitations": item["limitations"], + "redistributable": item["redistributable"], + } + + +def validate_fixture_catalog(media_dir: Path | None = None, require_bundled_files: bool = False) -> list[str]: + errors: list[str] = [] + seen_ids: set[str] = set() + media_dir = Path(media_dir) if media_dir is not None else None + for item in FIXTURE_CATALOG: + fixture_id = str(item.get("id") or "") + missing = sorted(field for field in REQUIRED_FIXTURE_FIELDS if item.get(field) in (None, "", [])) + if missing: + errors.append(f"{fixture_id}: missing {', '.join(missing)}") + if fixture_id in seen_ids: + errors.append(f"{fixture_id}: duplicate fixture id") + seen_ids.add(fixture_id) + if item.get("redistributable") and not item.get("filename"): + errors.append(f"{fixture_id}: redistributable fixture has no filename") + if require_bundled_files and item.get("redistributable"): + if media_dir is None or not (media_dir / str(item.get("filename"))).is_file(): + errors.append(f"{fixture_id}: bundled media file is missing") + return errors + + +def require_valid_fixture_catalog(media_dir: Path | None = None, require_bundled_files: bool = False) -> None: + errors = validate_fixture_catalog(media_dir, require_bundled_files=require_bundled_files) + if errors: + raise ValueError("Invalid fixture catalog:\n- " + "\n- ".join(errors)) + + def fetch_fixtures( output_dir: Path, fixture_ids: list[str] | None = None, include_restricted: bool = False, ) -> list[dict[str, Any]]: + require_valid_fixture_catalog() ensure_dir(output_dir) selected = list_fixtures(include_restricted=include_restricted) if fixture_ids: diff --git a/laserlab/ingest.py b/laserlab/ingest.py index 4aa8207..b480d26 100644 --- a/laserlab/ingest.py +++ b/laserlab/ingest.py @@ -25,6 +25,7 @@ def init_experiment( experiment_dir: Path, frame_interval: int = 5, max_frames: int | None = None, + capture_metadata: dict[str, Any] | None = None, ) -> dict[str, Any]: if kind not in {"video", "image-set"}: raise ValueError("kind must be 'video' or 'image-set'") @@ -72,6 +73,7 @@ def init_experiment( "label": label, "sha256": source_hash, "created_at": utc_now_iso(), + "metadata": dict(capture_metadata or {}), } capture_record = { "capture_id": capture_id, @@ -80,6 +82,7 @@ def init_experiment( "label": label, "frames": frame_records, "created_at": utc_now_iso(), + "metadata": dict(capture_metadata or {}), } manifest["sources"].append(source_record) diff --git a/laserlab/pipeline.py b/laserlab/pipeline.py index b45dea5..59d879b 100644 --- a/laserlab/pipeline.py +++ b/laserlab/pipeline.py @@ -133,6 +133,7 @@ def run_experiment( "primary_metric_score": 0.0, "q_value": None, "persistence_score": 0.0, + "capture_metadata": sample.get("capture_metadata", {}), } result_record["primary_metric_score"] = round(float(metric_score(result_record, primary_metric)), 6) results.append(result_record) @@ -192,6 +193,7 @@ def _build_samples( "timestamp_ms": frame.get("timestamp_ms"), "unblinded_label": capture["label"], "synthetic": False, + "capture_metadata": capture.get("metadata", {}), } ) diff --git a/laserlab/report.py b/laserlab/report.py index 41fa35c..5d2d07f 100644 --- a/laserlab/report.py +++ b/laserlab/report.py @@ -38,6 +38,7 @@ def build_report(experiment_dir: Path, run_dir: Path, run_record: dict[str, Any] "interpretation": interpretation_text(aggregate), "detector_family_explanations": detector_family_explanations(), "aggregate_statistics": aggregate, + "source_provenance": source_provenance(run_record.get("samples", [])), "top_candidates": candidates, "artifacts": { "results_json": relative_to(run_dir / "results.json", experiment_dir), @@ -82,6 +83,7 @@ def top_candidates( "control_type": item.get("control_type", ""), "q_value": item.get("q_value"), "detector_family_scores": item.get("detector_family_scores", {}), + "capture_metadata": item.get("capture_metadata", {}), } for item in candidates ] @@ -95,6 +97,8 @@ def write_html_report(path: Path, report: dict[str, Any]) -> None: summary = report["summary"] candidates = report["top_candidates"] rows = "\n".join(_candidate_row(candidate) for candidate in candidates) + provenance_rows = "\n".join(_provenance_row(record) for record in report.get("source_provenance", [])) + provenance_body = provenance_rows or 'No catalog fixture metadata was attached.' badges = " ".join(f"{html.escape(badge)}" for badge in report.get("badges", [])) interpretation = report.get("interpretation", {}) html_text = f""" @@ -132,6 +136,21 @@ def write_html_report(path: Path, report: dict[str, Any]) -> None:

What this means: {html.escape(interpretation.get('what_this_means', ''))}

What this does not mean: {html.escape(interpretation.get('what_this_does_not_mean', ''))}

+

Source Provenance

+ + + + + + + + + + + + {provenance_body} + +
FixturePhenomenaLicenseExpected behaviorLimitations

Top Candidates

@@ -175,6 +194,25 @@ def _candidate_row(candidate: dict[str, Any]) -> str: """ +def _provenance_row(record: dict[str, Any]) -> str: + title = record.get("fixture_title") or record.get("fixture_id") or "Unknown fixture" + source_page = record.get("source_page") + if source_page: + title_html = f'{html.escape(str(title))}' + else: + title_html = html.escape(str(title)) + phenomena = ", ".join(str(item) for item in record.get("phenomena", [])) + return f""" + + + + + + + +""" + + def _relative_from_run(path: str) -> str: parts = path.split("/") if "runs" in parts: @@ -200,9 +238,22 @@ def _source_label(candidate: dict[str, Any]) -> str: control_type = candidate.get("control_type") if control_type: parts.append(str(control_type)) + fixture_title = candidate.get("capture_metadata", {}).get("fixture_title") + if fixture_title: + parts.append(str(fixture_title)) return " | ".join(str(part) for part in parts if part) +def source_provenance(samples: list[dict[str, Any]]) -> list[dict[str, Any]]: + records: dict[str, dict[str, Any]] = {} + for sample in samples: + metadata = sample.get("capture_metadata") or {} + fixture_id = metadata.get("fixture_id") + if fixture_id: + records[str(fixture_id)] = dict(metadata) + return [records[key] for key in sorted(records)] + + def _candidate_score(result: dict[str, Any], score_key: str) -> float: value = result.get(score_key) if value is None: diff --git a/pyproject.toml b/pyproject.toml index c4ef5f1..9436779 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "laseranalysisai" -version = "0.3.0" +version = "0.3.1" description = "Blinded signal validation pipeline for laser capture analysis." readme = "README.md" requires-python = ">=3.10" diff --git a/sample_media/fixture_manifest.json b/sample_media/fixture_manifest.json index 8972618..17c6c80 100644 --- a/sample_media/fixture_manifest.json +++ b/sample_media/fixture_manifest.json @@ -10,7 +10,13 @@ "label": "control", "license": "CC BY-SA 4.0", "notes": "Small simulation useful for detector sanity checks, not physical laser footage.", - "path": "sample_media\\commons-double-slit-experiment.webm", + "phenomena": [ + "interference", + "simulation" + ], + "expected_behavior": "Stable fringe-like spatial frequencies; no readable text is expected.", + "limitations": "Rendered simulation rather than camera footage; use only as a structured control.", + "path": "sample_media/commons-double-slit-experiment.webm", "redistributable": true, "sha256": "747618e45cfb031d2e3fca1b155b29455fef15077f672641a57773f7f38747f7", "source_page": "https://commons.wikimedia.org/wiki/File:Double_slit_experiment.webm", @@ -27,7 +33,13 @@ "label": "laser", "license": "CC BY-SA 3.0 or GFDL", "notes": "Educational double-slit video clip; useful as a small redistributable optical fixture.", - "path": "sample_media\\commons-young-double-slit.ogv", + "phenomena": [ + "diffraction", + "interference" + ], + "expected_behavior": "FFT and fringe metrics should respond more strongly than OCR metrics.", + "limitations": "Educational compressed video with unknown camera and processing history.", + "path": "sample_media/commons-young-double-slit.ogv", "redistributable": true, "sha256": "56427bcdc563fa8722dcd504d81f8dfb5fcbf8cb977283f0dfb19d800e8ac51c", "source_page": "https://commons.wikimedia.org/wiki/File:Double_slit.theora.ogv", @@ -44,7 +56,14 @@ "label": "laser", "license": "CC BY-SA 4.0", "notes": "Tomographic visualization from monochromatic laser light through two pinholes.", - "path": "sample_media\\commons-two-pinhole-laser-interference.webm", + "phenomena": [ + "interference", + "diffraction", + "visualization" + ], + "expected_behavior": "Repeatable spectral structure is expected; text recovery is not.", + "limitations": "Tomographic visualization, not a raw sensor recording of an experiment.", + "path": "sample_media/commons-two-pinhole-laser-interference.webm", "redistributable": true, "sha256": "f6ab13b18fb0e160933437e7ac461eea3a30ccba22779e82b1cc67f63722e2e1", "source_page": "https://commons.wikimedia.org/wiki/File:3D_Interference_of_Laser_Light_Through_2_Pinholes_Animation.webm", @@ -61,7 +80,14 @@ "label": "control", "license": "CC BY-SA 4.0", "notes": "Rendered optical beam fixture; useful for false-positive checks against structured non-text fields.", - "path": "sample_media\\commons-focused-laguerre-gaussian.webm", + "phenomena": [ + "structured_beam", + "diffraction", + "rendered_control" + ], + "expected_behavior": "Ring and texture metrics may respond while OCR should remain null.", + "limitations": "Rendered beam field; not physical footage and not a matched camera control.", + "path": "sample_media/commons-focused-laguerre-gaussian.webm", "redistributable": true, "sha256": "2b6995a30a157cd49092bd2ffb76672d7976290cc756d9e9c5da9871fe35d50c", "source_page": "https://commons.wikimedia.org/wiki/File:Focused_Laguerre-Gaussian_beam.webm", diff --git a/scripts/run_fixture_demo.py b/scripts/run_fixture_demo.py index 1eff5c7..444757b 100644 --- a/scripts/run_fixture_demo.py +++ b/scripts/run_fixture_demo.py @@ -12,6 +12,7 @@ sys.path.insert(0, str(ROOT)) from laserlab import app_api +from laserlab.fixtures import fixture_metadata, require_valid_fixture_catalog LASER_FIXTURE = ROOT / "sample_media" / "commons-young-double-slit.ogv" @@ -29,6 +30,7 @@ def main() -> int: args = parser.parse_args() experiment_dir = Path(args.experiment) + require_valid_fixture_catalog(ROOT / "sample_media", require_bundled_files=True) manifest = app_api.create_experiment(experiment_dir) labels = {capture.get("label") for capture in manifest.get("captures", [])} if "laser" not in labels: @@ -39,6 +41,7 @@ def main() -> int: "laser", all_frames=True, max_frames=args.max_frames, + capture_metadata=fixture_metadata("commons-young-double-slit"), ) if "control" not in labels: app_api.add_capture( @@ -48,6 +51,7 @@ def main() -> int: "control", all_frames=True, max_frames=args.max_frames, + capture_metadata=fixture_metadata("commons-double-slit-experiment"), ) preset = next((item for item in app_api.list_protocol_presets() if item["id"] == args.protocol), None) diff --git a/tests/golden/scientific_summary.json b/tests/golden/scientific_summary.json new file mode 100644 index 0000000..0c39ba1 --- /dev/null +++ b/tests/golden/scientific_summary.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "scientific_metrics": { + "fft_peak_prominence_min": 8.0, + "phase_registration_response_min": 0.5, + "phase_shift_tolerance_px": 0.5, + "speckle_contrast_max": 1.5, + "speckle_contrast_min": 0.3 + }, + "null_run": { + "allowed_evidence_levels": [ + "no signal", + "artifact", + "anomaly" + ], + "forbidden_evidence_levels": [ + "above-control candidate", + "repeatable candidate" + ] + } +} diff --git a/tests/test_laserlab.py b/tests/test_laserlab.py index 9a4855d..dbf2001 100644 --- a/tests/test_laserlab.py +++ b/tests/test_laserlab.py @@ -38,13 +38,20 @@ def test_sha256_json_is_stable(self) -> None: self.assertEqual(sha256_json(left), sha256_json(right)) def test_fixture_catalog_separates_redistributable_sources(self) -> None: - from laserlab.fixtures import list_fixtures + from laserlab.fixtures import list_fixtures, validate_fixture_catalog redistributable = list_fixtures() full_catalog = list_fixtures(include_restricted=True) self.assertGreaterEqual(len(redistributable), 3) self.assertGreater(len(full_catalog), len(redistributable)) self.assertTrue(all(item["redistributable"] for item in redistributable)) + media_dir = Path(__file__).resolve().parents[1] / "sample_media" + self.assertEqual(validate_fixture_catalog(media_dir, require_bundled_files=True), []) + manifest = json.loads((media_dir / "fixture_manifest.json").read_text(encoding="utf-8")) + self.assertEqual( + {item["id"] for item in manifest["fixtures"]}, + {item["id"] for item in redistributable}, + ) class ProtocolPresetTests(unittest.TestCase): @@ -78,6 +85,9 @@ def test_scientific_metrics_detect_synthetic_fixtures(self) -> None: create_synthetic_speckle, ) + golden = json.loads( + (Path(__file__).parent / "golden" / "scientific_summary.json").read_text(encoding="utf-8") + )["scientific_metrics"] with tempfile.TemporaryDirectory() as temp: root = Path(temp) grating = cv2.imread(str(create_synthetic_grating(root / "grating.png")), cv2.IMREAD_COLOR) @@ -93,13 +103,15 @@ def test_scientific_metrics_detect_synthetic_fixtures(self) -> None: cv2.imread(str(shifted), cv2.IMREAD_COLOR), ) - self.assertGreater(fft["peak_prominence"], 8.0) + self.assertGreater(fft["peak_prominence"], golden["fft_peak_prominence_min"]) self.assertGreater(fft["ring_energy_ratio"], 0.0) - self.assertGreater(speckle_metrics["spatial_contrast_mean"], 0.3) + self.assertGreater(speckle_metrics["spatial_contrast_mean"], golden["speckle_contrast_min"]) + self.assertLess(speckle_metrics["spatial_contrast_mean"], golden["speckle_contrast_max"]) self.assertIn("entropy", texture) - self.assertAlmostEqual(registration["shift_x"], 7.0, delta=0.5) - self.assertAlmostEqual(registration["shift_y"], 4.0, delta=0.5) - self.assertGreater(registration["response"], 0.5) + tolerance = golden["phase_shift_tolerance_px"] + self.assertAlmostEqual(registration["shift_x"], 7.0, delta=tolerance) + self.assertAlmostEqual(registration["shift_y"], 4.0, delta=tolerance) + self.assertGreater(registration["response"], golden["phase_registration_response_min"]) self.assertEqual(benjamini_hochberg_q_values([0.01, 0.04, 0.03]), [0.03, 0.04, 0.04]) def test_image_set_ingest_run_and_report(self) -> None: @@ -173,6 +185,29 @@ def test_tesseract_is_optional(self) -> None: self.assertIn("available", result) self.assertIn("text", result) + def test_matched_null_fixture_cannot_promote_above_control(self) -> None: + from laserlab.ingest import init_experiment + from laserlab.pipeline import run_experiment + from laserlab.synthetic import create_synthetic_negative + + golden = json.loads( + (Path(__file__).parent / "golden" / "scientific_summary.json").read_text(encoding="utf-8") + )["null_run"] + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + laser = root / "laser" + control = root / "control" + experiment = root / "experiment" + create_synthetic_negative(laser / "null.png") + create_synthetic_negative(control / "null.png") + init_experiment(laser, "image-set", "laser", experiment, max_frames=1) + init_experiment(control, "image-set", "control", experiment, max_frames=1) + run = run_experiment(experiment, profile_name="baseline", blind_seed=314) + + ladder = run["aggregate_statistics"]["evidence_ladder"] + self.assertIn(ladder, golden["allowed_evidence_levels"]) + self.assertNotIn(ladder, golden["forbidden_evidence_levels"]) + def test_wide_profile_has_parameter_sweep_variants(self) -> None: from laserlab.manifest import WIDE_PROFILE from laserlab.preprocessing import apply_profile_variants @@ -204,13 +239,29 @@ def test_app_api_fixture_style_flow(self) -> None: manifest = app_api.create_experiment(experiment) self.assertIn("experiment_id", manifest) - app_api.add_capture(experiment, laser, "image-set", "laser", max_frames=1) + app_api.add_capture( + experiment, + laser, + "image-set", + "laser", + max_frames=1, + capture_metadata={ + "fixture_id": "synthetic-test-positive", + "fixture_title": "Synthetic test positive", + "limitations": "Unit-test fixture only", + }, + ) app_api.add_capture(experiment, control, "image-set", "control", max_frames=1) run = app_api.run_analysis(experiment, profile="baseline", blind_seed=99) report = app_api.load_latest_report(experiment) self.assertEqual(report["run_id"], run["run_id"]) self.assertIn("local_paths", report) + self.assertEqual(report["source_provenance"][0]["fixture_id"], "synthetic-test-positive") + self.assertIn( + "Unit-test fixture only", + Path(report["local_paths"]["report_html"]).read_text(encoding="utf-8"), + ) manifest = app_api.update_analysis_plan( experiment,
{title_html}{html.escape(phenomena)}{html.escape(str(record.get('license', '')))}{html.escape(str(record.get('expected_behavior', '')))}{html.escape(str(record.get('limitations', '')))}