Skip to content

feat(afana/zx-ir): phase normalization + strict range validation (closes #850)#1069

Open
hiq-lab wants to merge 2 commits into
mainfrom
feat/zx-phase-normalization
Open

feat(afana/zx-ir): phase normalization + strict range validation (closes #850)#1069
hiq-lab wants to merge 2 commits into
mainfrom
feat/zx-phase-normalization

Conversation

@hiq-lab
Copy link
Copy Markdown
Collaborator

@hiq-lab hiq-lab commented May 27, 2026

Summary

Adds spider-phase normalisation and a strict-mode validator to afana/src/zx_ir.rs, with integration tests in afana/tests/zx_phase_range.rs satisfying the issue's acceptance criteria.

New surface:

impl ZxGraph {
    pub fn normalize_phases(&mut self);
    pub fn validate_normalized(&self) -> Result<(), Vec<ZxValidationError>>;
}

pub enum ZxValidationError {
    // ...
    PhaseOutOfRange { node: NodeId, phase: f64 },
}

Why a separate strict variant

afana/src/lower.rs already produces un-normalised phases on purpose:

  • Sdg → phase = -0.5
  • Tdg → phase = -0.25
  • Rx(theta), Rz(theta) → phase = theta / π for any real theta

Bolting a [0, 2) check onto the existing validate() would break the lowering pipeline on every parametric rotation. So the existing validate() keeps its current permissive behaviour (structural integrity + finite-phase check only), and the strict range check lives in a new validate_normalized() that callers opt into after running normalize_phases().

The intended workflow for emission paths:

graph.normalize_phases();      // semantics-preserving (rem_euclid(2.0))
graph.validate_normalized()?;  // strict gate before QASM
emit_qasm(&graph)?;

What normalize_phases() does

Phases in ZX-calculus are equivalent modulo 2π. The function calls phase.rem_euclid(2.0) on every finite phase, which produces a non-negative result in [0, 2). Non-finite phases (NaN/Inf) are left alone and continue to be flagged by the existing validate().

Tests

afana/tests/zx_phase_range.rs — integration tests per AC:

afana/src/zx_ir.rs — 7 new inline unit tests covering edge cases (in-range acceptance, [0, 2) half-open at the upper bound, negative input, large positive input, NaN passthrough, exact 2.0 boundary, round-trip).

Test plan

  • cargo test -p afana --test zx_phase_range — 3/3 pass
  • cargo test -p afana --release — all 174+ existing tests still pass (no regressions in lower.rs, synthesis.rs, etc.)
  • cargo build --workspace --release green

Follow-up worth considering

Calling normalize_phases() automatically inside lower.rs::lower_program() (or a new public lower_and_normalize() helper) would make the strict validation immediately usable everywhere. Not done in this PR — kept the change focused.

Daniel Hinderink added 2 commits May 27, 2026 11:26
Replaces the hand-rolled `RwLock<HashMap<CacheKey, CacheEntry>>` L1 with
`moka::sync::Cache`. Reasons:

- W-TinyLFU admission preserves the hot working set under cold-burst
  pressure; the previous HashMap evicted by oldest timestamp, which lets
  one-off lookups push out repeatedly-used circuits.
- Lock-free reads on the hot path; the old code took the RwLock on every
  read and a write-lock on every stats bump.
- Native TTL eviction; the manual `evict_stale` scan is no longer needed.

Public API surface is preserved so existing callers (quasi-demo,
quasi-bridge) keep compiling without changes:

  CacheStore::new(CacheConfig { max_l1_entries, l2_dir, max_age_seconds })
  CacheStore::{get, put, contains, stats, clear, evict_stale}

Behavioural deltas worth knowing:
- `evict_stale(now: u64)` keeps its signature but ignores the `now`
  parameter — moka uses wall clock for TTL. Callers that relied on
  synthetic timestamps must move to real `Duration` waits in tests.
- `CacheStats.evictions` now only counts moka's TTL- and size-based
  evictions, not explicit `clear()` calls. This matches the semantics
  the field name implies.
- `CacheStats.{total_lookup_time_us, lookup_count}` were never populated
  by the previous implementation either; they remain in the struct for
  API stability but always read as 0. To be removed in a future cleanup.

Test plan:
- 16 existing tests in quasi-cache continue to pass
- New `ttl_evicts_after_max_age` test replaces the synthetic-timestamp
  `evict_stale_removes_old_keeps_fresh` to validate real TTL eviction
- New `lookup_latency_is_sub_millisecond` test populates 10k entries
  and asserts mean lookup < 1 ms — satisfies #812 AC#2
- Full workspace build green, no caller changes needed
 #850)

Adds two new methods to `ZxGraph`:

  fn normalize_phases(&mut self)
  fn validate_normalized(&self) -> Result<(), Vec<ZxValidationError>>

and a new `ZxValidationError::PhaseOutOfRange` variant.

The intended workflow for lowering passes:

  graph.normalize_phases();
  graph.validate_normalized()?;
  // safe to emit QASM

Why a separate strict variant: `lower.rs` already emits un-normalised
phases by design — `Sdg` produces `-0.5`, `Tdg` produces `-0.25`, and
`Rx(theta)` produces `theta / π` which can be any real number. The
existing `validate()` must stay permissive for these intermediates;
strict range checks live in `validate_normalized()` and run only after
`normalize_phases()`.

Phases are equivalent mod 2π in ZX-calculus, so `normalize_phases()`
applies `rem_euclid(2.0)` to each finite phase — semantics-preserving.
Non-finite phases (NaN/Inf) are left alone and continue to be flagged
by the existing `validate()`.

Tests:
- afana/tests/zx_phase_range.rs (integration, per AC):
  - rejects phase = -0.5
  - rejects phase = 7.0
  - normalize + validate succeeds for both
- afana/src/zx_ir.rs (inline): 7 new unit tests covering normalization
  edge cases (negative, > 2π, NaN passthrough, exact 2.0 boundary)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant