dadl-quantum 0.0.2__tar.gz

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.
@@ -0,0 +1,36 @@
1
+ # Rust
2
+ /target/
3
+ **/target/
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.egg-info/
9
+ .eggs/
10
+ build/
11
+ dist/
12
+ .venv/
13
+ venv/
14
+ .pytest_cache/
15
+ .coverage
16
+ htmlcov/
17
+
18
+ # Data: raw time tags and replay files are never committed
19
+ *.h5
20
+ *.hdf5
21
+ *.ptu
22
+ *.ttbin
23
+ data/
24
+
25
+ # Keys: site signing keys are never committed
26
+ *.key
27
+ *.pem
28
+ keys/
29
+
30
+ # Docs build
31
+ /site/
32
+
33
+ # Editors and OS
34
+ .idea/
35
+ .vscode/
36
+ .DS_Store
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.5
2
+ Name: dadl-quantum
3
+ Version: 0.0.2
4
+ Summary: Detector-Aware Data Layer for quantum optics experiments
5
+ Project-URL: Homepage, https://github.com/usecommon/dadl
6
+ Project-URL: Documentation, https://github.com/usecommon/dadl/blob/main/docs/design.md
7
+ Project-URL: Issues, https://github.com/usecommon/dadl/issues
8
+ License: MIT OR Apache-2.0
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Physics
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: numpy>=1.24
17
+ Requires-Dist: pyyaml>=6.0
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest>=7.0; extra == 'test'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # dadl (Python package)
23
+
24
+ Detector-Aware Data Layer for quantum optics experiments.
25
+
26
+ Phase 1 is pure NumPy: a time-tag simulator with injected detector
27
+ imperfections, fixed-grid coincidence counting, CHSH and two-photon
28
+ visibility analyses, the guardrail policy, and the labelled result type.
29
+
30
+ ```bash
31
+ pip install -e ".[test]"
32
+ dadl quickstart chsh-spad
33
+ pytest
34
+ ```
35
+
36
+ See the repository README and `docs/design.md` for the full design.
@@ -0,0 +1,15 @@
1
+ # dadl (Python package)
2
+
3
+ Detector-Aware Data Layer for quantum optics experiments.
4
+
5
+ Phase 1 is pure NumPy: a time-tag simulator with injected detector
6
+ imperfections, fixed-grid coincidence counting, CHSH and two-photon
7
+ visibility analyses, the guardrail policy, and the labelled result type.
8
+
9
+ ```bash
10
+ pip install -e ".[test]"
11
+ dadl quickstart chsh-spad
12
+ pytest
13
+ ```
14
+
15
+ See the repository README and `docs/design.md` for the full design.
@@ -0,0 +1,18 @@
1
+ """DADL: Detector-Aware Data Layer for quantum optics experiments."""
2
+
3
+ from dadl.errors import ConfigError, DadlError, GuardrailRefusal, InsufficientCounts
4
+ from dadl.guardrails import Policy
5
+ from dadl.result import Estimate, LabelledValue
6
+
7
+ __version__ = "0.0.2"
8
+
9
+ __all__ = [
10
+ "ConfigError",
11
+ "DadlError",
12
+ "Estimate",
13
+ "GuardrailRefusal",
14
+ "InsufficientCounts",
15
+ "LabelledValue",
16
+ "Policy",
17
+ "__version__",
18
+ ]
@@ -0,0 +1,6 @@
1
+ """Analyses. Each returns a LabelledValue with the raw value first."""
2
+
3
+ from dadl.analysis.chsh import Window, chsh
4
+ from dadl.analysis.visibility import two_photon_visibility
5
+
6
+ __all__ = ["Window", "chsh", "two_photon_visibility"]
@@ -0,0 +1,69 @@
1
+ """CHSH from fixed-grid coincidences.
2
+
3
+ Headline S uses raw counts only. Every coincidence-normalized S is labelled
4
+ "fair sampling assumed", because normalizing by coincidences post-selects on
5
+ detection.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass, field
12
+
13
+ import numpy as np
14
+
15
+ from dadl.coincidences import grid_coincidences
16
+ from dadl.errors import InsufficientCounts
17
+ from dadl.guardrails import Policy
18
+ from dadl.result import Estimate, LabelledValue
19
+
20
+ FAIR_SAMPLING = "fair sampling assumed (post-selected on coincidences)"
21
+
22
+ #: Below this many coincidences per setting the Gaussian error is not trusted.
23
+ MIN_COUNTS_PER_SETTING = 100
24
+
25
+ SIGNS: dict[tuple[str, str], int] = {("a", "b"): 1, ("a", "b'"): -1, ("a'", "b"): 1, ("a'", "b'"): 1}
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Window:
30
+ """A coincidence window. `source` must be "plan" for Bell-type analyses."""
31
+
32
+ width_ps: int
33
+ source: str = "plan"
34
+ delays_ps: dict[str, int] = field(default_factory=dict)
35
+
36
+
37
+ def correlator(tags: dict[str, np.ndarray], window: Window) -> tuple[float, float, int]:
38
+ """E = (N++ + N-- - N+- - N-+) / N and its variance (1 - E^2) / N."""
39
+ d = window.delays_ps
40
+
41
+ def n(a: str, b: str) -> int:
42
+ return grid_coincidences(tags[a], tags[b], window.width_ps, d.get(a, 0), d.get(b, 0))
43
+
44
+ npp, nmm, npm, nmp = n("A+", "B+"), n("A-", "B-"), n("A+", "B-"), n("A-", "B+")
45
+ total = npp + nmm + npm + nmp
46
+ if total < MIN_COUNTS_PER_SETTING:
47
+ raise InsufficientCounts(f"{total} coincidences in a setting; need at least {MIN_COUNTS_PER_SETTING}.")
48
+ e = (npp + nmm - npm - nmp) / total
49
+ return e, (1.0 - e * e) / total, total
50
+
51
+
52
+ def chsh(
53
+ runs: dict[tuple[str, str], dict[str, np.ndarray]],
54
+ window: Window,
55
+ policy: Policy | None = None,
56
+ ) -> LabelledValue:
57
+ """Raw CHSH S from one run per setting pair, with fixed windows from the analysis plan."""
58
+ policy = policy or Policy.default()
59
+ policy.check_window("chsh", window.source)
60
+ s = 0.0
61
+ var = 0.0
62
+ for key, sign in SIGNS.items():
63
+ if key not in runs:
64
+ raise ValueError(f"missing run for setting pair {key}")
65
+ e, v, _ = correlator(runs[key], window)
66
+ s += sign * e
67
+ var += v
68
+ labels = (FAIR_SAMPLING,) + policy.labels_for("chsh")
69
+ return LabelledValue(analysis="chsh", raw=Estimate(s, math.sqrt(var)), labels=labels)
@@ -0,0 +1,47 @@
1
+ """Two-photon fringe visibility, with optional accidental subtraction.
2
+
3
+ For Bell-type visibility claims (analysis "bell_visibility") accidental
4
+ subtraction is refused: it can move a non-violating dataset past the 71%
5
+ threshold. For HOM it is allowed, as a labelled secondary value.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+ from dadl.guardrails import Policy, threshold_labels
13
+ from dadl.result import Estimate, LabelledValue
14
+
15
+
16
+ def _visibility(n_max: float, n_min: float) -> tuple[float, float]:
17
+ total = n_max + n_min
18
+ if total <= 0:
19
+ raise ValueError("counts must be positive")
20
+ v = (n_max - n_min) / total
21
+ # Poisson counts: var(V) = 4 n_max n_min / (n_max + n_min)^3
22
+ return v, math.sqrt(4.0 * n_max * n_min / total**3)
23
+
24
+
25
+ def two_photon_visibility(
26
+ n_max: float,
27
+ n_min: float,
28
+ *,
29
+ analysis: str,
30
+ accidentals: float | None = None,
31
+ policy: Policy | None = None,
32
+ ) -> LabelledValue:
33
+ """Raw visibility, and the accidental-subtracted value only where the policy allows it."""
34
+ policy = policy or Policy.default()
35
+ raw_v, raw_err = _visibility(n_max, n_min)
36
+ corrected: Estimate | None = None
37
+ if accidentals is not None:
38
+ policy.check_correction(analysis, "accidental_subtraction")
39
+ denom = n_max + n_min - 2.0 * accidentals
40
+ if denom <= 0:
41
+ raise ValueError("accidentals exceed the measured counts")
42
+ cor_v = (n_max - n_min) / denom
43
+ corrected = Estimate(cor_v, raw_err * (n_max + n_min) / denom)
44
+ labels = policy.labels_for(analysis) + threshold_labels(
45
+ analysis, raw_v, None if corrected is None else corrected.value
46
+ )
47
+ return LabelledValue(analysis=analysis, raw=Estimate(raw_v, raw_err), corrected=corrected, labels=labels)
@@ -0,0 +1,86 @@
1
+ """Command-line interface: `dadl quickstart <example>` and `dadl version`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ import numpy as np
9
+
10
+ from dadl import __version__
11
+ from dadl.analysis.chsh import Window, chsh
12
+ from dadl.closed_form import chsh_expected_grid
13
+ from dadl.sim import CHANNELS, DetectorParams, PairSource, simulate_chsh
14
+
15
+ #: Phase 1 keeps the example parameters in code; examples/chsh-spad/setup.yaml mirrors them.
16
+ CHSH_SPAD = {
17
+ "seed": 2026,
18
+ "duration_s": 1.0,
19
+ "pair_rate": 1e5,
20
+ "visibility": 0.95,
21
+ "detector": {
22
+ "efficiency": 0.5,
23
+ "dark_rate": 500.0,
24
+ "dead_time_ps": 22_000,
25
+ "jitter_ps": 350.0,
26
+ "afterpulse_prob": 0.005,
27
+ "afterpulse_tau_ps": 50_000.0,
28
+ },
29
+ "window_ps": 2_000,
30
+ }
31
+
32
+
33
+ def quickstart_chsh_spad() -> int:
34
+ p = CHSH_SPAD
35
+ det = DetectorParams(**p["detector"])
36
+ rng = np.random.default_rng(p["seed"])
37
+ runs = simulate_chsh(
38
+ PairSource(pair_rate=p["pair_rate"], visibility=p["visibility"]),
39
+ {ch: det for ch in CHANNELS},
40
+ p["duration_s"],
41
+ rng,
42
+ )
43
+ result = chsh(runs, Window(width_ps=p["window_ps"], source="plan"))
44
+ expected = chsh_expected_grid(
45
+ pair_rate=p["pair_rate"],
46
+ duration_s=p["duration_s"],
47
+ efficiency=det.efficiency,
48
+ dark_rate=det.dark_rate,
49
+ jitter_sigma_ps=det.jitter_ps,
50
+ width_ps=p["window_ps"],
51
+ visibility=p["visibility"],
52
+ )
53
+ print(f"DADL {__version__} quickstart: chsh-spad (simulated data, seed {p['seed']})")
54
+ print(f" Source: {p['pair_rate']:.0e} pairs/s, visibility {p['visibility']}, {p['duration_s']} s per setting")
55
+ print(
56
+ f" Detectors: eta {det.efficiency}, dark {det.dark_rate:.0f} cps, dead time {det.dead_time_ps / 1000:.0f} ns, "
57
+ f"jitter {det.jitter_ps:.0f} ps, afterpulsing {det.afterpulse_prob:.1%}"
58
+ )
59
+ print(f" Fixed window from the analysis plan: {p['window_ps'] / 1000:.1f} ns")
60
+ print()
61
+ print(f" Raw S = {result.raw}")
62
+ print(f" Labels: {'; '.join(result.labels)}")
63
+ print(f" Closed-form expectation (ignores dead time and afterpulsing): S = {expected:.4f}")
64
+ print()
65
+ print(" Next: calibration fits and corrected values arrive in phase 2. See docs/design.md.")
66
+ return 0
67
+
68
+
69
+ EXAMPLES = {"chsh-spad": quickstart_chsh_spad}
70
+
71
+
72
+ def main(argv: list[str] | None = None) -> int:
73
+ parser = argparse.ArgumentParser(prog="dadl", description="Detector-Aware Data Layer for quantum optics experiments")
74
+ sub = parser.add_subparsers(dest="command", required=True)
75
+ qs = sub.add_parser("quickstart", help="run an example end to end on simulated data")
76
+ qs.add_argument("example", choices=sorted(EXAMPLES))
77
+ sub.add_parser("version", help="print the version")
78
+ args = parser.parse_args(argv)
79
+ if args.command == "version":
80
+ print(__version__)
81
+ return 0
82
+ return EXAMPLES[args.example]()
83
+
84
+
85
+ if __name__ == "__main__":
86
+ sys.exit(main())
@@ -0,0 +1,72 @@
1
+ """Closed-form results used as model-independent test oracles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+
8
+ def nonparalyzable_measured_rate(true_rate: float, tau_s: float) -> float:
9
+ """m = n / (1 + n tau)."""
10
+ return true_rate / (1.0 + true_rate * tau_s)
11
+
12
+
13
+ def paralyzable_measured_rate(true_rate: float, tau_s: float) -> float:
14
+ """m = n exp(-n tau)."""
15
+ return true_rate * math.exp(-true_rate * tau_s)
16
+
17
+
18
+ def series_hybrid_measured_rate(true_rate: float, tau_p_s: float, tau_np_s: float) -> float:
19
+ """m = n exp(-n tau_p) / (1 + n tau_np)."""
20
+ return true_rate * math.exp(-true_rate * tau_p_s) / (1.0 + true_rate * tau_np_s)
21
+
22
+
23
+ def chsh_werner(visibility: float) -> float:
24
+ """S = 2 sqrt(2) V for a Werner-like Phi+ state at the optimal settings."""
25
+ return 2.0 * math.sqrt(2.0) * visibility
26
+
27
+
28
+ def chsh_with_accidentals(visibility: float, true_coinc: float, accidentals: float) -> float:
29
+ """Uniform accidentals scale every correlator, so S = 2 sqrt(2) V C / (C + A)."""
30
+ return chsh_werner(visibility) * true_coinc / (true_coinc + accidentals)
31
+
32
+
33
+ def grid_capture_probability(jitter_sigma_ps: float, width_ps: float) -> float:
34
+ """Probability that both photons of a pair land in the same fixed slot.
35
+
36
+ Each arm has independent Gaussian jitter of sigma `jitter_sigma_ps`, the
37
+ declared delays are exact, and the pair time is uniform within a slot.
38
+ With D the arrival difference (sigma s = sigma * sqrt(2)) and w the width,
39
+ P = E[(1 - |D| / w)+] = erf(u / sqrt 2) - (2 s / (w sqrt(2 pi))) (1 - exp(-u^2 / 2)), u = w / s.
40
+ """
41
+ if width_ps <= 0:
42
+ raise ValueError("width must be positive")
43
+ s = jitter_sigma_ps * math.sqrt(2.0)
44
+ if s == 0:
45
+ return 1.0
46
+ u = width_ps / s
47
+ return math.erf(u / math.sqrt(2.0)) - (2.0 * s / (width_ps * math.sqrt(2.0 * math.pi))) * (
48
+ 1.0 - math.exp(-0.5 * u * u)
49
+ )
50
+
51
+
52
+ def chsh_expected_grid(
53
+ *,
54
+ pair_rate: float,
55
+ duration_s: float,
56
+ efficiency: float,
57
+ dark_rate: float,
58
+ jitter_sigma_ps: float,
59
+ width_ps: float,
60
+ visibility: float,
61
+ ) -> float:
62
+ """Expected raw S for the simulator with identical detectors, no dead time or afterpulsing.
63
+
64
+ True coincidences per setting: C = N T eta^2 P_capture (summed over the four
65
+ outcome combinations). Accidentals per combination: r^2 w T with singles
66
+ rate r = N eta / 2 + d; uniform across combinations, so they add nothing to
67
+ the correlator numerator.
68
+ """
69
+ c = pair_rate * duration_s * efficiency**2 * grid_capture_probability(jitter_sigma_ps, width_ps)
70
+ r = pair_rate * efficiency / 2.0 + dark_rate
71
+ a = 4.0 * r * r * (width_ps * 1e-12) * duration_s
72
+ return chsh_with_accidentals(visibility, c, a)
@@ -0,0 +1,32 @@
1
+ """Fixed-grid (clock-locked) coincidence counting.
2
+
3
+ Time is cut into slots of a width declared before the data is seen. A
4
+ coincidence is a slot occupied by both channels after each channel's declared
5
+ delay is removed. This is the only coincidence method allowed for Bell-type
6
+ analyses: windows centred on detection events open the coincidence-time
7
+ loophole.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+
14
+
15
+ def occupied_slots(times: np.ndarray, width_ps: int, delay_ps: int = 0) -> np.ndarray:
16
+ if width_ps <= 0:
17
+ raise ValueError("slot width must be positive")
18
+ times = np.asarray(times, dtype=np.int64)
19
+ return np.unique(np.floor_divide(times - np.int64(delay_ps), np.int64(width_ps)))
20
+
21
+
22
+ def grid_coincidences(
23
+ a: np.ndarray,
24
+ b: np.ndarray,
25
+ width_ps: int,
26
+ delay_a_ps: int = 0,
27
+ delay_b_ps: int = 0,
28
+ ) -> int:
29
+ """Number of slots occupied by both channels."""
30
+ slots_a = occupied_slots(a, width_ps, delay_a_ps)
31
+ slots_b = occupied_slots(b, width_ps, delay_b_ps)
32
+ return int(np.intersect1d(slots_a, slots_b, assume_unique=True).size)
@@ -0,0 +1,42 @@
1
+ """Loading setup.yaml files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ from dadl.errors import ConfigError
11
+
12
+ SUPPORTED_SCHEMA_VERSIONS = {1}
13
+ REQUIRED_KEYS = ("schema_version", "name", "source", "detectors", "analysis")
14
+
15
+
16
+ def load_setup(path: str | Path) -> dict[str, Any]:
17
+ """Load and minimally validate a setup file.
18
+
19
+ Full JSON Schema validation (dadl check-config) and migrations
20
+ (dadl migrate) arrive in a later phase.
21
+ """
22
+ path = Path(path)
23
+ try:
24
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
25
+ except FileNotFoundError as exc:
26
+ raise ConfigError(f"setup file not found: {path}") from exc
27
+ except yaml.YAMLError as exc:
28
+ raise ConfigError(f"setup file is not valid YAML: {exc}") from exc
29
+ if not isinstance(data, dict):
30
+ raise ConfigError("setup file must be a mapping at the top level")
31
+ missing = [k for k in REQUIRED_KEYS if k not in data]
32
+ if missing:
33
+ raise ConfigError(f"setup file is missing keys: {', '.join(missing)}")
34
+ if data["schema_version"] not in SUPPORTED_SCHEMA_VERSIONS:
35
+ raise ConfigError(
36
+ f"schema_version {data['schema_version']!r} is not supported; "
37
+ f"supported: {sorted(SUPPORTED_SCHEMA_VERSIONS)}"
38
+ )
39
+ window = data["analysis"].get("window", {})
40
+ if window.get("source") != "plan":
41
+ raise ConfigError("analysis.window.source must be 'plan': windows are declared before acquisition")
42
+ return data
@@ -0,0 +1,38 @@
1
+ """Dead-time filters (pure-Python reference; the Rust core has the fast versions).
2
+
3
+ Timestamps are int64 picoseconds, sorted ascending.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+
10
+
11
+ def apply_nonparalyzable(times: np.ndarray, tau_ps: int) -> np.ndarray:
12
+ """Drop arrivals within `tau_ps` of the last recorded event."""
13
+ if tau_ps < 0:
14
+ raise ValueError("dead time must be non-negative")
15
+ times = np.asarray(times, dtype=np.int64)
16
+ if tau_ps == 0 or times.size == 0:
17
+ return times.copy()
18
+ out = np.empty_like(times)
19
+ n = 0
20
+ next_allowed: int | None = None
21
+ for t in times.tolist():
22
+ if next_allowed is None or t >= next_allowed:
23
+ out[n] = t
24
+ n += 1
25
+ next_allowed = t + tau_ps
26
+ return out[:n]
27
+
28
+
29
+ def apply_paralyzable(times: np.ndarray, tau_ps: int) -> np.ndarray:
30
+ """Record an arrival only if the previous arrival (recorded or not) is at least `tau_ps` earlier."""
31
+ if tau_ps < 0:
32
+ raise ValueError("dead time must be non-negative")
33
+ times = np.asarray(times, dtype=np.int64)
34
+ if times.size == 0:
35
+ return times.copy()
36
+ gaps = np.diff(times)
37
+ keep = np.concatenate(([True], gaps >= tau_ps))
38
+ return times[keep]
@@ -0,0 +1,17 @@
1
+ """Typed errors. The same types are raised by the library and, later, over MCP."""
2
+
3
+
4
+ class DadlError(Exception):
5
+ """Base class for all DADL errors."""
6
+
7
+
8
+ class GuardrailRefusal(DadlError):
9
+ """A correction, window or analysis choice is invalid for the analysis type."""
10
+
11
+
12
+ class InsufficientCounts(DadlError):
13
+ """Counts are below the estimator's validity threshold."""
14
+
15
+
16
+ class ConfigError(DadlError):
17
+ """A setup or policy file is missing, malformed or of an unsupported version."""
@@ -0,0 +1,155 @@
1
+ """Guardrail policy.
2
+
3
+ Guardrails are refusals in code, not warnings. Each analysis type declares
4
+ which corrections may appear (always as a labelled secondary value, never as
5
+ the headline) and where its coincidence window may come from.
6
+
7
+ Users may loosen a rule, but never silently: an override needs a written
8
+ reason, and any policy whose content differs from the shipped default is
9
+ labelled "policy modified" on every result. In later phases an override also
10
+ needs a human approval token, and agents cannot write policy files.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import math
18
+ from dataclasses import dataclass, field
19
+
20
+ from dadl.errors import GuardrailRefusal
21
+
22
+ POLICY_MODIFIED = "policy modified"
23
+ THRESHOLD_AFTER_CORRECTION = "threshold crossed only after correction"
24
+ UNVALIDATED_ANALYSIS = "unvalidated analysis"
25
+
26
+ #: Thresholds that carry a physical claim, per analysis type.
27
+ THRESHOLDS: dict[str, float] = {
28
+ "chsh": 2.0,
29
+ "bell_visibility": 1.0 / math.sqrt(2.0),
30
+ "hom_visibility": 0.5,
31
+ }
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class AnalysisRule:
36
+ allowed_corrections: frozenset[str]
37
+ window_sources: frozenset[str]
38
+
39
+ def to_json(self) -> dict[str, list[str]]:
40
+ return {
41
+ "allowed_corrections": sorted(self.allowed_corrections),
42
+ "window_sources": sorted(self.window_sources),
43
+ }
44
+
45
+
46
+ #: Applied to any analysis type the policy does not know, e.g. plugin analyses.
47
+ STRICTEST = AnalysisRule(allowed_corrections=frozenset(), window_sources=frozenset({"plan"}))
48
+
49
+ DEFAULT_RULES: dict[str, AnalysisRule] = {
50
+ # Bell-type: raw counts only, fixed windows declared in the analysis plan.
51
+ "chsh": STRICTEST,
52
+ "bell_visibility": STRICTEST,
53
+ # HOM and tomography: corrections allowed as labelled secondary values.
54
+ "hom_visibility": AnalysisRule(
55
+ allowed_corrections=frozenset({"accidental_subtraction", "afterpulse"}),
56
+ window_sources=frozenset({"plan"}),
57
+ ),
58
+ "tomography": AnalysisRule(
59
+ allowed_corrections=frozenset({"accidental_subtraction"}),
60
+ window_sources=frozenset({"plan"}),
61
+ ),
62
+ }
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Override:
67
+ analysis: str
68
+ correction: str
69
+ reason: str
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Policy:
74
+ rules: dict[str, AnalysisRule] = field(default_factory=lambda: dict(DEFAULT_RULES))
75
+ overrides: tuple[Override, ...] = ()
76
+
77
+ @classmethod
78
+ def default(cls) -> Policy:
79
+ return cls()
80
+
81
+ # -- content hashing ---------------------------------------------------
82
+
83
+ def fingerprint(self) -> str:
84
+ """SHA-256 of the canonical rule content (overrides applied)."""
85
+ content = {name: rule.to_json() for name, rule in sorted(self._effective_rules().items())}
86
+ blob = json.dumps(content, sort_keys=True, separators=(",", ":")).encode()
87
+ return hashlib.sha256(blob).hexdigest()
88
+
89
+ @property
90
+ def is_modified(self) -> bool:
91
+ return self.fingerprint() != _DEFAULT_FINGERPRINT
92
+
93
+ # -- loosening ---------------------------------------------------------
94
+
95
+ def allow(self, analysis: str, correction: str, reason: str) -> Policy:
96
+ """Return a new policy that allows `correction` for `analysis`.
97
+
98
+ A written reason is mandatory. Results under the new policy carry the
99
+ "policy modified" label.
100
+ """
101
+ if not reason or not reason.strip():
102
+ raise ValueError("A policy override needs a written reason.")
103
+ return Policy(rules=dict(self.rules), overrides=self.overrides + (Override(analysis, correction, reason.strip()),))
104
+
105
+ # -- checks ------------------------------------------------------------
106
+
107
+ def rule_for(self, analysis: str) -> AnalysisRule:
108
+ return self._effective_rules().get(analysis, STRICTEST)
109
+
110
+ def check_correction(self, analysis: str, correction: str) -> None:
111
+ if correction not in self.rule_for(analysis).allowed_corrections:
112
+ raise GuardrailRefusal(
113
+ f"Correction '{correction}' is not allowed for '{analysis}'. "
114
+ "Bell-type headline numbers use raw counts only; see docs/design.md, "
115
+ "'Scientific guardrails'."
116
+ )
117
+
118
+ def check_window(self, analysis: str, window_source: str) -> None:
119
+ if window_source not in self.rule_for(analysis).window_sources:
120
+ raise GuardrailRefusal(
121
+ f"A coincidence window from '{window_source}' is not allowed for '{analysis}'. "
122
+ "Windows must be fixed and declared in the analysis plan before acquisition; "
123
+ "windows chosen from the data open the coincidence-time loophole."
124
+ )
125
+
126
+ def labels_for(self, analysis: str) -> tuple[str, ...]:
127
+ labels: list[str] = []
128
+ if self.is_modified:
129
+ labels.append(POLICY_MODIFIED)
130
+ if analysis not in DEFAULT_RULES:
131
+ labels.append(UNVALIDATED_ANALYSIS)
132
+ return tuple(labels)
133
+
134
+ def _effective_rules(self) -> dict[str, AnalysisRule]:
135
+ rules = dict(self.rules)
136
+ for o in self.overrides:
137
+ base = rules.get(o.analysis, STRICTEST)
138
+ rules[o.analysis] = AnalysisRule(
139
+ allowed_corrections=base.allowed_corrections | {o.correction},
140
+ window_sources=base.window_sources,
141
+ )
142
+ return rules
143
+
144
+
145
+ _DEFAULT_FINGERPRINT = Policy().fingerprint()
146
+
147
+
148
+ def threshold_labels(analysis: str, raw: float, corrected: float | None) -> tuple[str, ...]:
149
+ """Label a result whose corrected value crosses a physical threshold that raw does not."""
150
+ threshold = THRESHOLDS.get(analysis)
151
+ if threshold is None or corrected is None:
152
+ return ()
153
+ if raw < threshold <= corrected:
154
+ return (THRESHOLD_AFTER_CORRECTION,)
155
+ return ()
@@ -0,0 +1,59 @@
1
+ """The labelled result type.
2
+
3
+ A DADL result always carries its raw value, an optional corrected value and
4
+ the labels that qualify it. It deliberately refuses to become a bare float, so
5
+ a corrected number cannot be quoted without its raw value and labels by
6
+ accident (see docs/design.md, "Labels travel with numbers").
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Estimate:
16
+ """A value with its one-sigma standard error."""
17
+
18
+ value: float
19
+ stderr: float
20
+
21
+ def __str__(self) -> str:
22
+ return f"{self.value:.4f} ± {self.stderr:.4f}"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class LabelledValue:
27
+ """Raw value first, optional corrected value, and labels."""
28
+
29
+ analysis: str
30
+ raw: Estimate
31
+ corrected: Estimate | None = None
32
+ labels: tuple[str, ...] = field(default_factory=tuple)
33
+ artifact_ids: tuple[str, ...] = field(default_factory=tuple)
34
+
35
+ def __float__(self) -> float:
36
+ raise TypeError(
37
+ "A DADL result is not a bare number: use .raw.value or .corrected.value "
38
+ "explicitly, and keep .labels with it."
39
+ )
40
+
41
+ def __str__(self) -> str:
42
+ parts = [f"{self.analysis}: raw {self.raw}"]
43
+ if self.corrected is not None:
44
+ parts.append(f"corrected {self.corrected}")
45
+ if self.labels:
46
+ parts.append("[" + "; ".join(self.labels) + "]")
47
+ return " ".join(parts)
48
+
49
+ def as_record(self) -> dict[str, object]:
50
+ """Flat record for CSV or DataFrame export. Labels are a mandatory column."""
51
+ return {
52
+ "analysis": self.analysis,
53
+ "raw_value": self.raw.value,
54
+ "raw_stderr": self.raw.stderr,
55
+ "corrected_value": None if self.corrected is None else self.corrected.value,
56
+ "corrected_stderr": None if self.corrected is None else self.corrected.stderr,
57
+ "labels": "; ".join(self.labels),
58
+ "artifact_ids": "; ".join(self.artifact_ids),
59
+ }
@@ -0,0 +1,133 @@
1
+ """Time-tag simulator with known, injected detector imperfections.
2
+
3
+ Every parameter that the calibration engine will later estimate is set
4
+ explicitly here, so tests can compare estimates against the truth.
5
+
6
+ Units: time tags are int64 picoseconds; rates are per second.
7
+
8
+ Phase 1 approximations, to be replaced by the self-exciting detection model
9
+ in docs/design.md:
10
+ - afterpulses are generated for one generation only (no cascades);
11
+ - crosstalk and breakdown flash are not simulated yet.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from dataclasses import dataclass
18
+
19
+ import numpy as np
20
+
21
+ from dadl.deadtime import apply_nonparalyzable
22
+
23
+ PS_PER_S = 10**12
24
+
25
+ #: Analyzer angles in degrees for the standard CHSH settings on a Phi+ state.
26
+ CHSH_SETTINGS: dict[str, float] = {"a": 0.0, "a'": 45.0, "b": 22.5, "b'": 67.5}
27
+ CHSH_PAIRS: tuple[tuple[str, str], ...] = (("a", "b"), ("a", "b'"), ("a'", "b"), ("a'", "b'"))
28
+ CHANNELS: tuple[str, ...] = ("A+", "A-", "B+", "B-")
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class DetectorParams:
33
+ """Ground truth for one detector channel."""
34
+
35
+ efficiency: float = 0.5 # system efficiency (detector x coupling)
36
+ dark_rate: float = 100.0 # counts per second
37
+ dead_time_ps: int = 22_000 # non-paralyzable
38
+ jitter_ps: float = 350.0 # Gaussian sigma
39
+ afterpulse_prob: float = 0.0 # per recorded avalanche
40
+ afterpulse_tau_ps: float = 50_000.0 # exponential delay after the dead time
41
+ delay_ps: int = 0 # fixed channel delay
42
+
43
+ def __post_init__(self) -> None:
44
+ if not 0.0 <= self.efficiency <= 1.0:
45
+ raise ValueError("efficiency must be in [0, 1]")
46
+ if self.dark_rate < 0 or self.dead_time_ps < 0 or self.jitter_ps < 0:
47
+ raise ValueError("rates, dead time and jitter must be non-negative")
48
+ if not 0.0 <= self.afterpulse_prob < 1.0:
49
+ raise ValueError("afterpulse probability must be in [0, 1)")
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class PairSource:
54
+ """A CW polarization-entangled SPDC source producing a Werner-like Phi+ state."""
55
+
56
+ pair_rate: float = 1e5 # pairs per second at the crystal
57
+ visibility: float = 0.95 # E(alpha, beta) = V cos 2(alpha - beta)
58
+
59
+ def __post_init__(self) -> None:
60
+ if self.pair_rate < 0:
61
+ raise ValueError("pair rate must be non-negative")
62
+ if not 0.0 <= self.visibility <= 1.0:
63
+ raise ValueError("visibility must be in [0, 1]")
64
+
65
+
66
+ def poisson_times(rate: float, duration_s: float, rng: np.random.Generator) -> np.ndarray:
67
+ """Sorted float64 arrival times (ps) of a homogeneous Poisson process."""
68
+ n = rng.poisson(rate * duration_s)
69
+ return np.sort(rng.uniform(0.0, duration_s * PS_PER_S, n))
70
+
71
+
72
+ def detect(
73
+ photon_times_ps: np.ndarray,
74
+ det: DetectorParams,
75
+ duration_s: float,
76
+ rng: np.random.Generator,
77
+ ) -> np.ndarray:
78
+ """Turn photon arrival times into recorded int64 time tags for one detector."""
79
+ photons = np.asarray(photon_times_ps, dtype=np.float64)
80
+ kept = photons[rng.random(photons.size) < det.efficiency]
81
+ if det.jitter_ps > 0:
82
+ kept = kept + rng.normal(0.0, det.jitter_ps, kept.size)
83
+ kept = kept + det.delay_ps
84
+ darks = poisson_times(det.dark_rate, duration_s, rng)
85
+ arrivals = np.sort(np.rint(np.concatenate([kept, darks]))).astype(np.int64)
86
+ recorded = apply_nonparalyzable(arrivals, det.dead_time_ps)
87
+
88
+ if det.afterpulse_prob > 0 and recorded.size:
89
+ mask = rng.random(recorded.size) < det.afterpulse_prob
90
+ n_ap = int(mask.sum())
91
+ if n_ap:
92
+ delays = det.dead_time_ps + rng.exponential(det.afterpulse_tau_ps, n_ap)
93
+ afterpulses = np.rint(recorded[mask] + delays).astype(np.int64)
94
+ merged = np.sort(np.concatenate([recorded, afterpulses]))
95
+ recorded = apply_nonparalyzable(merged, det.dead_time_ps)
96
+ return recorded
97
+
98
+
99
+ def simulate_chsh_setting(
100
+ source: PairSource,
101
+ alpha_deg: float,
102
+ beta_deg: float,
103
+ detectors: dict[str, DetectorParams],
104
+ duration_s: float,
105
+ rng: np.random.Generator,
106
+ ) -> dict[str, np.ndarray]:
107
+ """Recorded tags on channels A+, A-, B+, B- for one pair of analyzer angles."""
108
+ missing = set(CHANNELS) - set(detectors)
109
+ if missing:
110
+ raise ValueError(f"missing detector parameters for {sorted(missing)}")
111
+ t = poisson_times(source.pair_rate, duration_s, rng)
112
+ n = t.size
113
+ a_plus = rng.random(n) < 0.5
114
+ p_same = 0.5 * (1.0 + source.visibility * math.cos(2.0 * math.radians(alpha_deg - beta_deg)))
115
+ same = rng.random(n) < p_same
116
+ b_plus = np.where(same, a_plus, ~a_plus)
117
+ photons = {"A+": t[a_plus], "A-": t[~a_plus], "B+": t[b_plus], "B-": t[~b_plus]}
118
+ return {ch: detect(photons[ch], detectors[ch], duration_s, rng) for ch in CHANNELS}
119
+
120
+
121
+ def simulate_chsh(
122
+ source: PairSource,
123
+ detectors: dict[str, DetectorParams],
124
+ duration_s: float,
125
+ rng: np.random.Generator,
126
+ settings: dict[str, float] | None = None,
127
+ ) -> dict[tuple[str, str], dict[str, np.ndarray]]:
128
+ """One acquisition per CHSH setting pair, keyed like ("a", "b'")."""
129
+ angles = settings or CHSH_SETTINGS
130
+ return {
131
+ (sa, sb): simulate_chsh_setting(source, angles[sa], angles[sb], detectors, duration_s, rng)
132
+ for sa, sb in CHSH_PAIRS
133
+ }
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "dadl-quantum"
7
+ version = "0.0.2"
8
+ description = "Detector-Aware Data Layer for quantum optics experiments"
9
+ readme = "README.md"
10
+ license = { text = "MIT OR Apache-2.0" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "numpy>=1.24",
14
+ "pyyaml>=6.0",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 2 - Pre-Alpha",
18
+ "Intended Audience :: Science/Research",
19
+ "License :: OSI Approved :: MIT License",
20
+ "License :: OSI Approved :: Apache Software License",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Scientific/Engineering :: Physics",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/usecommon/dadl"
27
+ Documentation = "https://github.com/usecommon/dadl/blob/main/docs/design.md"
28
+ Issues = "https://github.com/usecommon/dadl/issues"
29
+
30
+ [project.optional-dependencies]
31
+ test = ["pytest>=7.0"]
32
+
33
+ [project.scripts]
34
+ dadl = "dadl.cli:main"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["dadl"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
@@ -0,0 +1,53 @@
1
+ """CHSH from the simulator against closed-form expectations.
2
+
3
+ Werner-like Phi+ state with Poisson accidentals: S = 2 sqrt(2) V C / (C + A).
4
+ """
5
+
6
+ import math
7
+
8
+ import numpy as np
9
+ import pytest
10
+
11
+ from dadl.analysis.chsh import FAIR_SAMPLING, Window, chsh
12
+ from dadl.closed_form import chsh_expected_grid, chsh_werner, grid_capture_probability
13
+ from dadl.sim import CHANNELS, DetectorParams, PairSource, simulate_chsh
14
+
15
+
16
+ def run(dark_rate: float, seed: int):
17
+ params = dict(pair_rate=1e5, duration_s=0.5, efficiency=0.5, jitter_sigma_ps=50.0, width_ps=10_000, visibility=0.95)
18
+ det = DetectorParams(
19
+ efficiency=params["efficiency"],
20
+ dark_rate=dark_rate,
21
+ dead_time_ps=0,
22
+ jitter_ps=params["jitter_sigma_ps"],
23
+ )
24
+ runs = simulate_chsh(
25
+ PairSource(pair_rate=params["pair_rate"], visibility=params["visibility"]),
26
+ {ch: det for ch in CHANNELS},
27
+ params["duration_s"],
28
+ np.random.default_rng(seed),
29
+ )
30
+ result = chsh(runs, Window(width_ps=params["width_ps"]))
31
+ expected = chsh_expected_grid(dark_rate=dark_rate, **params)
32
+ return result, expected
33
+
34
+
35
+ def test_capture_probability_limits():
36
+ assert grid_capture_probability(0.0, 1000) == 1.0
37
+ assert grid_capture_probability(50.0, 10_000) == pytest.approx(1 - 70.71 * math.sqrt(2 / math.pi) / 10_000, rel=1e-3)
38
+ assert 0.0 < grid_capture_probability(350.0, 2_000) < 1.0
39
+
40
+
41
+ def test_negligible_accidentals_gives_werner_value():
42
+ result, expected = run(dark_rate=0.0, seed=1)
43
+ assert expected == pytest.approx(chsh_werner(0.95), rel=0.01)
44
+ assert result.raw.value == pytest.approx(expected, abs=5 * result.raw.stderr + 0.01)
45
+ assert FAIR_SAMPLING in result.labels
46
+
47
+
48
+ def test_accidentals_reduce_s_as_predicted():
49
+ # Dark counts chosen so accidentals are ~30% of true coincidences: S falls from ~2.69 to ~2.08.
50
+ result, expected = run(dark_rate=4e5, seed=2)
51
+ assert 1.9 < expected < 2.2
52
+ assert result.raw.value == pytest.approx(expected, abs=5 * result.raw.stderr + 0.01)
53
+ assert result.corrected is None, "Bell headline numbers are raw only"
@@ -0,0 +1,28 @@
1
+ """Fixed-grid coincidences: same cases as the Rust core tests."""
2
+
3
+ import numpy as np
4
+
5
+ from dadl.coincidences import grid_coincidences
6
+
7
+
8
+ def a(*xs):
9
+ return np.array(xs, dtype=np.int64)
10
+
11
+
12
+ def test_counts_shared_slots():
13
+ assert grid_coincidences(a(0, 1000, 2500), a(100, 1900, 9000), 1000) == 2
14
+
15
+
16
+ def test_declared_delay_shifts_slots():
17
+ assert grid_coincidences(a(50), a(950), 1000) == 1
18
+ assert grid_coincidences(a(50), a(1050), 1000) == 0
19
+ assert grid_coincidences(a(50), a(1050), 1000, delay_b_ps=1000) == 1
20
+
21
+
22
+ def test_several_events_in_one_slot_count_once():
23
+ assert grid_coincidences(a(10, 20, 30), a(40), 1000) == 1
24
+
25
+
26
+ def test_negative_times_use_floor_division():
27
+ assert grid_coincidences(a(-1), a(-999), 1000) == 1
28
+ assert grid_coincidences(a(-1), a(0), 1000) == 0
@@ -0,0 +1,47 @@
1
+ """Example setup file and CLI smoke tests."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from dadl.cli import main
8
+ from dadl.config import load_setup
9
+ from dadl.errors import ConfigError
10
+
11
+ EXAMPLE = Path(__file__).resolve().parents[2] / "examples" / "chsh-spad" / "setup.yaml"
12
+
13
+
14
+ def test_example_setup_loads():
15
+ setup = load_setup(EXAMPLE)
16
+ assert setup["schema_version"] == 1
17
+ assert setup["analysis"]["window"]["source"] == "plan"
18
+
19
+
20
+ def test_window_from_data_rejected_in_config(tmp_path):
21
+ bad = tmp_path / "setup.yaml"
22
+ bad.write_text(
23
+ "schema_version: 1\nname: x\nsource: {}\ndetectors: {}\n"
24
+ "analysis: {type: chsh, window: {width_ps: 1000, source: data}}\n",
25
+ encoding="utf-8",
26
+ )
27
+ with pytest.raises(ConfigError):
28
+ load_setup(bad)
29
+
30
+
31
+ def test_unsupported_schema_version(tmp_path):
32
+ bad = tmp_path / "setup.yaml"
33
+ bad.write_text("schema_version: 99\nname: x\nsource: {}\ndetectors: {}\nanalysis: {}\n", encoding="utf-8")
34
+ with pytest.raises(ConfigError):
35
+ load_setup(bad)
36
+
37
+
38
+ def test_version_command(capsys):
39
+ assert main(["version"]) == 0
40
+ assert capsys.readouterr().out.strip()
41
+
42
+
43
+ def test_quickstart_runs(capsys):
44
+ assert main(["quickstart", "chsh-spad"]) == 0
45
+ out = capsys.readouterr().out
46
+ assert "Raw S =" in out
47
+ assert "fair sampling assumed" in out
@@ -0,0 +1,46 @@
1
+ """Dead-time filters against closed-form Poisson results."""
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from dadl.closed_form import nonparalyzable_measured_rate, paralyzable_measured_rate
7
+ from dadl.deadtime import apply_nonparalyzable, apply_paralyzable
8
+ from dadl.sim import poisson_times
9
+
10
+ TRUE_RATE = 1e6 # per second
11
+ DURATION_S = 0.1
12
+ TAU_S = 500e-9 # n * tau = 0.5, so the two models differ by ~10%
13
+
14
+
15
+ @pytest.fixture(scope="module")
16
+ def arrivals():
17
+ rng = np.random.default_rng(7)
18
+ return np.rint(poisson_times(TRUE_RATE, DURATION_S, rng)).astype(np.int64)
19
+
20
+
21
+ def test_small_examples_match_rust_core():
22
+ times = np.array([0, 5, 10, 25, 30], dtype=np.int64)
23
+ assert apply_nonparalyzable(times, 10).tolist() == [0, 10, 25]
24
+ assert apply_paralyzable(times, 10).tolist() == [0, 25]
25
+
26
+
27
+ def test_nonparalyzable_rate_matches_closed_form(arrivals):
28
+ measured = apply_nonparalyzable(arrivals, int(TAU_S * 1e12)).size / DURATION_S
29
+ expected = nonparalyzable_measured_rate(TRUE_RATE, TAU_S)
30
+ assert measured == pytest.approx(expected, rel=0.015)
31
+
32
+
33
+ def test_paralyzable_rate_matches_closed_form(arrivals):
34
+ measured = apply_paralyzable(arrivals, int(TAU_S * 1e12)).size / DURATION_S
35
+ expected = paralyzable_measured_rate(TRUE_RATE, TAU_S)
36
+ assert measured == pytest.approx(expected, rel=0.015)
37
+
38
+
39
+ def test_models_are_distinguishable(arrivals):
40
+ tau_ps = int(TAU_S * 1e12)
41
+ assert apply_paralyzable(arrivals, tau_ps).size < 0.95 * apply_nonparalyzable(arrivals, tau_ps).size
42
+
43
+
44
+ def test_negative_dead_time_rejected():
45
+ with pytest.raises(ValueError):
46
+ apply_nonparalyzable(np.array([0, 1], dtype=np.int64), -1)
@@ -0,0 +1,79 @@
1
+ """Guardrail regression cases from published results.
2
+
3
+ These must keep passing. Changing them needs two maintainer approvals
4
+ (see CONTRIBUTING.md).
5
+ """
6
+
7
+ import numpy as np
8
+ import pytest
9
+
10
+ from dadl.analysis.chsh import Window, chsh
11
+ from dadl.analysis.visibility import two_photon_visibility
12
+ from dadl.errors import GuardrailRefusal
13
+ from dadl.guardrails import (
14
+ POLICY_MODIFIED,
15
+ THRESHOLD_AFTER_CORRECTION,
16
+ UNVALIDATED_ANALYSIS,
17
+ Policy,
18
+ )
19
+
20
+ # Thompson, quant-ph/9903066: with ~30% accidentals, subtraction moved
21
+ # visibility from about 45% to 82%, past the 71% Bell threshold.
22
+ N_MAX, N_MIN = 145.0, 55.0 # raw V = 0.45
23
+ ACCIDENTALS = 45.12 # corrected V = 90 / (200 - 2 * 45.12) = 0.82
24
+
25
+
26
+ def test_raw_visibility_matches_published_case():
27
+ result = two_photon_visibility(N_MAX, N_MIN, analysis="bell_visibility")
28
+ assert result.raw.value == pytest.approx(0.45)
29
+ assert result.corrected is None
30
+
31
+
32
+ def test_accidental_subtraction_refused_for_bell_visibility():
33
+ with pytest.raises(GuardrailRefusal):
34
+ two_photon_visibility(N_MAX, N_MIN, analysis="bell_visibility", accidentals=ACCIDENTALS)
35
+
36
+
37
+ def test_loosened_policy_is_never_silent():
38
+ policy = Policy.default().allow(
39
+ "bell_visibility", "accidental_subtraction", reason="reproducing Thompson's example"
40
+ )
41
+ result = two_photon_visibility(
42
+ N_MAX, N_MIN, analysis="bell_visibility", accidentals=ACCIDENTALS, policy=policy
43
+ )
44
+ assert result.raw.value == pytest.approx(0.45)
45
+ assert result.corrected.value == pytest.approx(0.82, abs=1e-3)
46
+ assert POLICY_MODIFIED in result.labels
47
+ assert THRESHOLD_AFTER_CORRECTION in result.labels
48
+
49
+
50
+ def test_override_needs_a_reason():
51
+ with pytest.raises(ValueError):
52
+ Policy.default().allow("bell_visibility", "accidental_subtraction", reason=" ")
53
+
54
+
55
+ def test_default_policy_is_not_modified():
56
+ assert not Policy.default().is_modified
57
+ assert Policy.default().fingerprint() == Policy().fingerprint()
58
+
59
+
60
+ def test_hom_allows_accidental_subtraction_as_labelled_secondary():
61
+ result = two_photon_visibility(60.0, 40.0, analysis="hom_visibility", accidentals=15.0)
62
+ assert result.raw.value == pytest.approx(0.2)
63
+ assert result.corrected is not None
64
+ assert POLICY_MODIFIED not in result.labels
65
+
66
+
67
+ def test_unknown_analysis_gets_strictest_policy():
68
+ with pytest.raises(GuardrailRefusal):
69
+ Policy.default().check_correction("my_plugin_analysis", "accidental_subtraction")
70
+ assert UNVALIDATED_ANALYSIS in Policy.default().labels_for("my_plugin_analysis")
71
+
72
+
73
+ def test_window_chosen_from_data_is_refused():
74
+ # Coincidence-time loophole (arXiv:1503.07573): windows chosen from the data
75
+ # can fake a violation, so Bell analyses accept only planned windows.
76
+ empty = {ch: np.array([], dtype=np.int64) for ch in ("A+", "A-", "B+", "B-")}
77
+ runs = {key: empty for key in (("a", "b"), ("a", "b'"), ("a'", "b"), ("a'", "b'"))}
78
+ with pytest.raises(GuardrailRefusal):
79
+ chsh(runs, Window(width_ps=1000, source="data"))
@@ -0,0 +1,31 @@
1
+ """The labelled result type keeps raw values and labels attached."""
2
+
3
+ import pytest
4
+
5
+ from dadl.result import Estimate, LabelledValue
6
+
7
+
8
+ def make():
9
+ return LabelledValue(
10
+ analysis="hom_visibility",
11
+ raw=Estimate(0.45, 0.02),
12
+ corrected=Estimate(0.82, 0.04),
13
+ labels=("policy modified",),
14
+ )
15
+
16
+
17
+ def test_refuses_bare_float():
18
+ with pytest.raises(TypeError):
19
+ float(make())
20
+
21
+
22
+ def test_string_puts_raw_first_and_keeps_labels():
23
+ text = str(make())
24
+ assert text.index("raw") < text.index("corrected")
25
+ assert "policy modified" in text
26
+
27
+
28
+ def test_export_record_has_mandatory_label_column():
29
+ record = make().as_record()
30
+ assert record["raw_value"] == 0.45
31
+ assert record["labels"] == "policy modified"