dadl-quantum 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dadl/__init__.py ADDED
@@ -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"]
dadl/analysis/chsh.py ADDED
@@ -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)
dadl/cli.py ADDED
@@ -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())
dadl/closed_form.py ADDED
@@ -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)
dadl/coincidences.py ADDED
@@ -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)
dadl/config.py ADDED
@@ -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
dadl/deadtime.py ADDED
@@ -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]
dadl/errors.py ADDED
@@ -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."""
dadl/guardrails.py ADDED
@@ -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 ()
dadl/result.py ADDED
@@ -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
+ }
dadl/sim.py ADDED
@@ -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,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,17 @@
1
+ dadl/__init__.py,sha256=EnugLErrkt54qQdtO_nrBD_GR2A5YkqPOLXxd2E4bDw,433
2
+ dadl/cli.py,sha256=ovXAJjjkRjXLC984Pb4Zvri97wA8XSZgdjOxY9BghFU,2987
3
+ dadl/closed_form.py,sha256=G5j9p9zAG27qigOd0AGCHFjGFfF-9JNXXjB-gI4cJng,2728
4
+ dadl/coincidences.py,sha256=UKSmS3d87QbhLqnO0vmXcUguKRj883LDBaUo-tZP1QA,1094
5
+ dadl/config.py,sha256=pV25pF4R_kXiPBrDd9xJEOKrQN3YaWq3ByRAnYPVLJY,1523
6
+ dadl/deadtime.py,sha256=wbLHBXoYMB2RTvLxXUxa6C2ZaeUhqo3Z5MYjU3_E-cw,1241
7
+ dadl/errors.py,sha256=q7_9FN3LlfkKu9OkdExjhRES06PqvEKlfjTiSA3wV3k,495
8
+ dadl/guardrails.py,sha256=mCaA5P2DP4JUY0hVPDkAr2_i-jSd-YpTbxRAwIpAlnQ,5748
9
+ dadl/result.py,sha256=pqJlfcMaUJsMDFoXwDH5Ic_AyEMn7OgKpV5rj03V6OQ,2000
10
+ dadl/sim.py,sha256=tN4CMeCwQgAzJRsNjNRi1UTVfSnsWTPqyddHzkJjWzw,5225
11
+ dadl/analysis/__init__.py,sha256=wbeH0n_KKHBEfYZsuviZFpqKD7pDx5kYRs8YCwpUh84,230
12
+ dadl/analysis/chsh.py,sha256=CMW7eu2cqlW9sD99qgW7uhEVQ1N85kdt7j83H0SlK0c,2390
13
+ dadl/analysis/visibility.py,sha256=mcvjIGYFMrn0-3Uhml4vbI1cu0ZgNhPnJwMjH9ISEc4,1764
14
+ dadl_quantum-0.0.2.dist-info/METADATA,sha256=7m36g-y50w-CRQ1bDuJ_2HBbs81LR53QJUDqo_bDWkU,1286
15
+ dadl_quantum-0.0.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
16
+ dadl_quantum-0.0.2.dist-info/entry_points.txt,sha256=Gl-09w4WNOdJJnnBKjbVGWvzAwVK1iJT5AFyhPrWEJA,39
17
+ dadl_quantum-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dadl = dadl.cli:main