diff --git a/.gitignore b/.gitignore index c9a1d18..2d197bb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,17 +17,28 @@ notes/* # Literate-generated example pages (source: docs/literate/examples/*.jl) /docs/src/examples/*.md -# Keep the hand-written instrument-sequences stub until it is Literate-migrated. -!/docs/src/examples/instrument_sequences.md # Example figures copied from scripts/figures during docs/make.jl /docs/src/assets/examples/ +# LaTeX products from docs/tikz/build_svgs.sh +/docs/tikz/*.aux +/docs/tikz/*.log +/docs/tikz/*.out +/docs/tikz/*.pdf +/docs/tikz/*.dvi +/docs/tikz/*.toc +/docs/tikz/*.build.log + # Local throwaway project environments at any depth (e.g. .bench_env, .plot_env, # scripts/.plot_examples_env). Trailing slash limits the match to directories. .*env/ **/.*env/ +# Manim render cache and draft outputs from docs/animations/*.py +/docs/animations/media/ +__pycache__/ + # Serialized process-tensor caches from companion scripts scripts/.cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0220e2d..a26c1be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Removed + +* **Boundary-driven XXZ example.** The Literate page, companion script, and + transport figure are no longer part of the documentation. +* **Unitary TFIM TEBD and TDVP examples.** The closed-system comparison pages, + companion scripts, and figures are removed. A short treatment remains in the + unitary-dynamics tutorial. + ### Added +* **`thermal_mode`.** Build a bosonic or spin bath mode whose Liouville `rho0` + is the Gibbs state of the local mode Hamiltonian at temperature `T`, the + ground-state projector at `T = 0`, or the maximally mixed state at `T = Inf`. + Replacement methods keep Hamiltonian, sites, coupling, and `n_max`. +* **Ramsey POVM example.** A Literate walkthrough and companion ACE script + contract three unsharp ``X`` measure-and-reset instruments with a thermal + dephasing process tensor, then compare the joint record to a product of + single-round marginals. The companion script is `scripts/ramsey_povm.jl` + and writes separate protocol and record figures. * **Testers and noisy quantum qubits example:** A Literate walkthrough and companion ACE script demonstrate store–wait–retrieve control, tester-only phase operations, joint trajectories, and system–tester mutual information. diff --git a/README.md b/README.md index 4ed2121..84f0797 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,35 @@ The same package can also be used before process tensors enter the story: closed | **Run experiments on a process** | Assemble preparations, controls, measurements, left/right actions, trace-outs, and open legs into an `InstrumentSeq`, then contract it with `evaluate_process`. | | **Reuse the same environment** | Once a process tensor is built, evolve new initial states with `evolve`, evaluate different protocols, or probe multi-time correlations without rebuilding the bath. | +In each clip below, time runs from right to left. + +**Construct a process tensor:** Combine the influence of independent environmental modes and compress the resulting temporal bonds using ACE. + +

+ + + Time runs right to left. Two environment modes over three time steps are absorbed column by column and compressed into a three-core process tensor with downward system legs. + +

+ +**Customize your instruments:** Define the interventions you wish to perform on the system at chosen times; unspecified slots default to identity. + +

+ + + Time runs right to left. A preparation, a custom map, and an open output snap into the slots of a three-step instrument tape; the unspecified slot is filled by an identity, leaving a filled instrument tape. + +

+ +**Evaluate and reuse:** Contract the same process tensor with different instruments to obtain reduced states and expectation values. + +

+ + + Time runs right to left. Two identical process tensors are contracted with instruments; the left ends as an open reduced state (triangle) and the right as a closed scalar (circle). + +

+ ## Installation Install the latest tagged release from GitHub: @@ -135,8 +164,8 @@ The documentation is written as a progression rather than an API dump. - **New to the tensor-network conventions?** Start with [ITensor Basics](https://Gauthameshwar.github.io/ProcessTensors.jl/stable/tutorials/itensor_basics/) and [MPS and MPO Basics](https://Gauthameshwar.github.io/ProcessTensors.jl/stable/tutorials/mps_mpo_basics/). - **Want open-system dynamics first?** Go to [Liouville-Space Basics](https://Gauthameshwar.github.io/ProcessTensors.jl/stable/tutorials/liouville_basics/) and [Dissipative Dynamics](https://Gauthameshwar.github.io/ProcessTensors.jl/stable/tutorials/dissipative_dynamics/). -- **Here for process tensors?** Start with the [Single-Mode Process Tensor tutorial](https://Gauthameshwar.github.io/ProcessTensors.jl/stable/tutorials/process_tensor_singlemode/), then move to the ACE examples. -- **Already know the theory?** Jump straight to the [Examples](https://Gauthameshwar.github.io/ProcessTensors.jl/stable/examples/tebd_time_evolution/) or the [API Reference](https://Gauthameshwar.github.io/ProcessTensors.jl/stable/api/). +- **Here for process tensors?** Start with [Construct your first process tensor](https://Gauthameshwar.github.io/ProcessTensors.jl/stable/tutorials/process_tensor_singlemode/), then [Explore a process with instruments](https://Gauthameshwar.github.io/ProcessTensors.jl/stable/tutorials/process_tensor_instruments/). +- **Already know the theory?** Jump straight to the [Examples](https://Gauthameshwar.github.io/ProcessTensors.jl/stable/examples/spin_bath_process_tensor/) or the [API Reference](https://Gauthameshwar.github.io/ProcessTensors.jl/stable/api/). ## Contributing diff --git a/docs/Project.toml b/docs/Project.toml index aa0d9ef..646e806 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,6 +1,7 @@ [deps] CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +HDF5 = "f67ccb44-e63f-5c2f-98bd-6dc0ccc4ba2f" ITensorMPS = "0d1a4710-d33b-49a5-8f18-73bdf49b47e2" ITensors = "9136182c-28ba-11e9-034c-db9fb085ebd5" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" @@ -9,6 +10,7 @@ ProcessTensors = "359c2eb2-a0cb-447a-868d-92de414ddc78" [compat] CairoMakie = "0.15" Documenter = "1.17" +HDF5 = "0.17" ITensorMPS = "0.3.44, 0.4" ITensors = "0.9.25" Literate = "2" diff --git a/docs/animations/ace_compression.py b/docs/animations/ace_compression.py new file mode 100644 index 0000000..5ad2cd4 --- /dev/null +++ b/docs/animations/ace_compression.py @@ -0,0 +1,1217 @@ +# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors +# SPDX-License-Identifier: MIT +# +# File: docs/animations/ace_compression.py +# Contributor: Gauthameshwar S. +# +# Generates the schematic ACE animation: two bath modes over three time steps are +# joined and compressed right to left into a three-core process tensor. +# +# Run with: +# PT_ANIM_THEME=light docs/animations/.manim_env/bin/python docs/animations/ace_compression.py +"""ACE compression animation for the ProcessTensors.jl homepage and README. + +Schematic only: no SVDs, no package calls. Time runs right to left; ``k = 0`` is +the rightmost (earliest) column. Single wires are Liouville-space legs, so the +initial-condition triangles are vectorised bath density operators: they point +right, and the bath leg meets the base on the left. The latest-time bath legs +are closed by filled markers as soon as those propagators are drawn. The clip shows the initial join/compress +sweep only, not every compression schedule or canonical gauge. + +Tested with Manim Community v0.21.0 (Cairo renderer), Python 3.12, ffmpeg 8.0.1. + +Render from the repository root:: + + PT_ANIM_THEME=light docs/animations/.manim_env/bin/python docs/animations/ace_compression.py + PT_ANIM_THEME=dark docs/animations/.manim_env/bin/python docs/animations/ace_compression.py + +``PT_ANIM_PRESET=draft`` writes an MP4 and a poster PNG into +``docs/animations/media/drafts/``. The final preset writes a transparent GIF +and a transparent poster PNG into ``docs/src/assets/animations/``. +""" + +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Callable + +import numpy as np +from manim import ( + DOWN, + LEFT, + RIGHT, + UP, + AnimationGroup, + CapStyleType, + Circle, + Create, + DrawBorderThenFill, + FadeOut, + GrowFromCenter, + MovingCameraScene, + Polygon, + ReplacementTransform, + RoundedRectangle, + Succession, + Text, + UpdateFromAlphaFunc, + VGroup, + VMobject, + config, + smooth, + tempconfig, +) +from manim.utils.exceptions import EndSceneEarlyException + +# -------------------------------------------------------------------------- +# Settings +# -------------------------------------------------------------------------- + +SCENE_STEM = "ace" +MODES = ("E1", "E2") + +STYLE = { + "outline_px": 5.0, + "wire_px": 4.5, + "marker_px": 4.5, + "emphasis_px": 7.5, + "reference_pixel_width": 960, +} + +PALETTES = { + "light": { + "wire": "#3a3a3a", + "input": "#138a8a", + "output": "#c27c0e", + "mode_1": ("#cfe3f5", "#2f6fa8"), + "mode_2": ("#f3d3df", "#a8406b"), + "bath_ic": ("#d4ead4", "#2f7a3e"), + "work": ("#dcd6f2", "#4f46a0"), + "carry": ("#dcd6f2", "#4f46a0"), + "pt": ("#d4ead4", "#217a3e"), + "glow": "#f2a900", + "debug": "#7a7a7a", + }, + "dark": { + "wire": "#cfcfcf", + "input": "#4fd1c5", + "output": "#f2b544", + "mode_1": ("#2b6ea6", "#9ccbf2"), + "mode_2": ("#8a3a5c", "#f2a7c4"), + "bath_ic": ("#2d6b3e", "#8fd9a0"), + "work": ("#4a4594", "#b9b3f2"), + "carry": ("#4a4594", "#b9b3f2"), + "pt": ("#1e6b38", "#8fd9a0"), + "glow": "#ffd166", + "debug": "#9e9e9e", + }, +} + +TIMING = { + "blank_hold": 0.15, + "ic_draw": 0.5, + "column_draw": 0.6, + "camera_move": 0.75, + "emphasis": 0.2, + "retract": 0.3, + "merge": 0.65, + "split": 0.65, + "carry_advance": 0.55, + "bend": 1.0, + "marker_draw": 0.35, + "reframe": 0.7, + "beat_gap": 0.1, + "result_hold": 1.8, + "loop_fade": 0.4, +} + +LAYOUT = { + "frame_height": 4.5, + "n_steps": 3, + "pitch": 2.0, + "x_right": 2.0, + "row_y": {"E1": 0.7, "E2": -0.7}, + "core_side": 0.66, + "corner": 0.09, + "ic_gap": 1.3, + "ic_side": 0.56, + "stub_len": 0.52, + "terminal_len": 0.55, + "work_size": (0.8, 1.6), + "carry_size": (0.46, 0.9), + "carry_split_dx": 0.62, + "carry_wait_dx": 0.85, + "socket_drop": 0.66, + "marker_r": 0.085, + "camera_padding": 0.35, +} + +DEBUG = { + "show_ids": False, + "show_port_names": False, + "show_bounds": False, + "highlight_moving": False, + "log_beats": True, + "save_checkpoints": False, + "stop_after": None, +} + +EXPORT = { + "final": {"pixel_width": 960, "pixel_height": 540, "fps": 24}, + "draft": {"pixel_width": 480, "pixel_height": 270, "fps": 12}, + "gif_dither": "sierra2_4a", + "gif_alpha_threshold": 128, + "mp4_background": {"light": "#ffffff", "dark": "#1f2424"}, + "poster_offset": 0.5, +} + +Z = {"wire": 1, "emphasis": 1.5, "closure": 1.75, "body": 2, "marker": 3, "glow": 4, "debug": 5} + + +def active_theme() -> str: + theme = os.environ.get("PT_ANIM_THEME", "light").strip().lower() + if theme not in PALETTES: + raise ValueError(f"PT_ANIM_THEME must be one of {sorted(PALETTES)}, got {theme!r}") + return theme + + +def screen_stroke(px: float, frame_width: float) -> float: + """Manim stroke width that renders as ``px`` pixels at the reference export width. + + Cairo strokes live in scene units, so the camera width enters explicitly. + """ + return px * frame_width / (STYLE["reference_pixel_width"] * 0.01) + + +def set_screen_stroke(mob: VMobject, px: float, frame_width: float) -> VMobject: + mob.pt_px = px + mob.set_stroke(width=screen_stroke(px, frame_width)) + return mob + + +# -------------------------------------------------------------------------- +# Small local records +# -------------------------------------------------------------------------- + + +class Status(Enum): + HIDDEN = "hidden" + LIVE = "live" + CONSUMED = "consumed" + + +class PortKind(Enum): + PHYSICAL_IN = "physical_in" + PHYSICAL_OUT = "physical_out" + VIRTUAL = "virtual" + OPEN_RESULT = "open_result" + + +class Route(Enum): + STRAIGHT = "straight" + FRONTIER = "frontier" + DOWN_SOCKET = "down_socket" + + +@dataclass(frozen=True) +class PortRef: + owner_id: str + port_name: str + semantic_kind: PortKind + + +@dataclass +class TensorGlyph: + id: str + body: VMobject + kind: str + port_specs: dict[str, np.ndarray] = field(default_factory=dict) + markers: dict[str, VMobject] = field(default_factory=dict) + label: VMobject | None = None + status: Status = Status.HIDDEN + + def port(self, name: str, centre: np.ndarray | None = None) -> np.ndarray: + base = self.body.get_center() if centre is None else centre + return base + self.port_specs[name] + + +@dataclass +class WireRecord: + id: str + a: PortRef + b: PortRef + path: VMobject + route_kind: Route + status: Status = Status.HIDDEN + + +@dataclass +class SceneState: + glyphs: dict[str, TensorGlyph] = field(default_factory=dict) + wires: dict[str, WireRecord] = field(default_factory=dict) + fixed: dict[str, np.ndarray] = field(default_factory=dict) + cell: dict[tuple[str, int], str] = field(default_factory=dict) + completed: dict[int, str] = field(default_factory=dict) + carry_id: str | None = None + working_id: str | None = None + closure_markers: dict[str, VMobject] = field(default_factory=dict) + transient_ids: set[str] = field(default_factory=set) + camera_width: float = 1.0 + + +@dataclass +class Beat: + name: str + animations: list + moving_ids: tuple[str, ...] = () + consumed_ids: tuple[str, ...] = () + created_ids: tuple[str, ...] = () + commit: Callable[[], None] = lambda: None + prepare: Callable[[MovingCameraScene], None] = lambda scene: None + finish: Callable[[MovingCameraScene], None] = lambda scene: None + discard: list[VMobject] = field(default_factory=list) + + +# -------------------------------------------------------------------------- +# Pure geometry factories +# -------------------------------------------------------------------------- + + +def point(x: float, y: float) -> np.ndarray: + return np.array([x, y, 0.0]) + + +def column_x(k: int) -> float: + return LAYOUT["x_right"] - k * LAYOUT["pitch"] + + +def split_cubic(p0, p1, p2, p3) -> np.ndarray: + """One cubic as two cubic halves (8 control points) so every wire interpolates.""" + a, b, c = (p0 + p1) / 2, (p1 + p2) / 2, (p2 + p3) / 2 + d, e = (a + b) / 2, (b + c) / 2 + m = (d + e) / 2 + return np.array([p0, a, d, m, m, e, c, p3]) + + +def route_points(route: Route, a: np.ndarray, b: np.ndarray) -> np.ndarray: + if route == Route.DOWN_SOCKET: + # Quarter-ellipse: horizontal tangent on the core, vertical tangent on the + # marker. Both controls sit in the same bounding box, so the bend is convex. + kappa = 0.551915024494 + dx, dy = b[0] - a[0], b[1] - a[1] + return split_cubic(a, a + point(kappa * dx, 0.0), b - point(0.0, kappa * dy), b) + if route == Route.FRONTIER: + # a: bath input on the next column (enters from the east); b: carry's top/bottom virtual port. + dx, dy = b[0] - a[0], a[1] - b[1] + return split_cubic(a, a + RIGHT * max(0.12, 0.55 * dx), b + UP * np.sign(dy) * max(0.08, 0.8 * abs(dy)), b) + return split_cubic(a, a + (b - a) / 3, a + 2 * (b - a) / 3, b) + + +def styled_body(mob: VMobject, colors: tuple[str, str], width: float) -> VMobject: + mob.set_fill(colors[0], opacity=1.0) + mob.set_stroke(colors[1]) + set_screen_stroke(mob, STYLE["outline_px"], width) + mob.set_z_index(Z["body"]) + return mob + + +def make_box(center: np.ndarray, size: tuple[float, float], colors: tuple[str, str], width: float) -> VMobject: + box = RoundedRectangle(width=size[0], height=size[1], corner_radius=LAYOUT["corner"]) + box.move_to(center) + return styled_body(box, colors, width) + + +def make_right_triangle(center: np.ndarray, side: float, colors: tuple[str, str], width: float) -> VMobject: + """Apex on the right, base on the left, so the connecting leg meets the base.""" + h = side * np.sqrt(3) / 2 + tri = Polygon(center + point(2 * h / 3, 0), center + point(-h / 3, side / 2), center + point(-h / 3, -side / 2)) + tri.round_corners(radius=0.12 * side) + return styled_body(tri, colors, width) + + +def make_wire_path(points: np.ndarray, color: str, width: float) -> VMobject: + wire = VMobject() + wire.set_points(points) + wire.set_fill(opacity=0) + wire.set_stroke(color) + set_screen_stroke(wire, STYLE["wire_px"], width) + wire.set_cap_style(CapStyleType.ROUND) + wire.set_z_index(Z["wire"]) + return wire + + +def make_closure_marker(position: np.ndarray, pal: dict) -> VMobject: + """Filled disc in the leg colour. It sits behind the rectangle that absorbs it.""" + marker = Circle(radius=LAYOUT["marker_r"], stroke_width=0) + marker.set_fill(pal["wire"], opacity=1.0) + marker.set_stroke(width=0) + marker.move_to(position) + marker.set_z_index(Z["closure"]) + return marker + + +def side_attachment(role: str) -> np.ndarray: + """Open-leg port on the left or right edge, three quarters of the way down.""" + side = LAYOUT["core_side"] + return point(-side / 2 if role == "out" else side / 2, -side / 4) + + +def open_socket(core_centre: np.ndarray, role: str) -> tuple[np.ndarray, np.ndarray]: + """Marker centre, and the point where a downward leg meets the marker's top edge. + + Markers sit a quarter-pitch either side of the core, so the pair under one + core and the pair between neighbouring cores are equally spaced. + """ + sign = -1.0 if role == "out" else 1.0 + centre = core_centre + point(sign * LAYOUT["pitch"] / 4, -LAYOUT["socket_drop"]) + return centre, centre + UP * LAYOUT["marker_r"] + + +def make_marker(role: str, filled: bool, position: np.ndarray, pal: dict, width: float) -> VMobject: + """Input sockets are teal circles, output sockets amber rounded squares, everywhere.""" + r = LAYOUT["marker_r"] + color = pal["input"] if role == "in" else pal["output"] + marker = Circle(radius=r) if role == "in" else RoundedRectangle(width=2 * r, height=2 * r, corner_radius=0.35 * r) + marker.set_stroke(color) + set_screen_stroke(marker, STYLE["marker_px"], width) + marker.set_fill(color, opacity=1.0 if filled else 0.0) + marker.move_to(position) + marker.set_z_index(Z["marker"]) + return marker + + +def box_ports(size: tuple[float, float], extra: dict[str, tuple[float, float]] | None = None) -> dict[str, np.ndarray]: + w, h = size + ports = {"n": point(0, h / 2), "s": point(0, -h / 2), "e": point(w / 2, 0), "w": point(-w / 2, 0)} + for name, (x, y) in (extra or {}).items(): + ports[name] = point(x, y) + return ports + + +def work_ports() -> dict[str, np.ndarray]: + """Fused column: physical legs top/bottom, row-aligned bath ports on both sides, memory bond east.""" + w, h = LAYOUT["work_size"] + y1 = LAYOUT["row_y"]["E1"] + return box_ports(LAYOUT["work_size"], { + "e1": (w / 2, y1), "e2": (w / 2, -y1), "w1": (-w / 2, y1), "w2": (-w / 2, -y1), "mem_e": (w / 2, 0), + }) + + +def carry_ports() -> dict[str, np.ndarray]: + """Transfer factor: top/bottom ports are VIRTUAL frontier bonds, not physical legs.""" + w, h = LAYOUT["carry_size"] + return {"f1": point(0, h / 2), "f2": point(0, -h / 2), "mem_e": point(w / 2, 0)} + + +def core_ports() -> dict[str, np.ndarray]: + s = LAYOUT["core_side"] + ports = box_ports((s, s), {"mem_w": (-s / 2, 0), "mem_e": (s / 2, 0)}) + ports["out"] = side_attachment("out") + ports["in"] = side_attachment("in") + return ports + + +def mobject_bounds(mobs: list[VMobject]) -> tuple[np.ndarray, np.ndarray]: + group = VGroup(*mobs) + return group.get_corner(DOWN + LEFT), group.get_corner(UP + RIGHT) + + +def fit_frame(mobs: list[VMobject], padding: float) -> tuple[np.ndarray, float]: + """Centre and width that contain ``mobs`` in both dimensions.""" + lo, hi = mobject_bounds(mobs) + aspect = config.frame_width / config.frame_height + width = max(hi[0] - lo[0] + 2 * padding, aspect * (hi[1] - lo[1] + 2 * padding)) + return (lo + hi) / 2, width + + +# -------------------------------------------------------------------------- +# Hidden geometry +# -------------------------------------------------------------------------- + + +def port_position(state: SceneState, ref: PortRef) -> np.ndarray: + if ref.owner_id == "fixed": + return state.fixed[ref.port_name] + return state.glyphs[ref.owner_id].port(ref.port_name) + + +def wire_points(state: SceneState, wire: WireRecord) -> np.ndarray: + return route_points(wire.route_kind, port_position(state, wire.a), port_position(state, wire.b)) + + +def register_wire(state: SceneState, wire_id: str, a: PortRef, b: PortRef, route: Route, pal: dict) -> WireRecord: + record = WireRecord(wire_id, a, b, make_wire_path(np.zeros((8, 3)), pal["wire"], state.camera_width), route) + record.path.set_points(wire_points(state, record)) + state.wires[wire_id] = record + return record + + +def add_glyph(state: SceneState, glyph: TensorGlyph, pal: dict) -> TensorGlyph: + if glyph.id in state.glyphs: + raise AssertionError(f"duplicate glyph id {glyph.id}") + state.glyphs[glyph.id] = glyph + if DEBUG["show_ids"]: + glyph.label = Text(glyph.id, font_size=10, color=pal["debug"]).next_to(glyph.body, UP, buff=0.05) + glyph.label.set_z_index(Z["debug"]) + return glyph + + +def make_bath_initial_conditions(state: SceneState, pal: dict) -> None: + """Single-wire triangles: vectorised bath density operators, one per mode.""" + for m in MODES: + centre = point(column_x(0) + LAYOUT["ic_gap"], LAYOUT["row_y"][m]) + body = make_right_triangle(centre, LAYOUT["ic_side"], pal["bath_ic"], state.camera_width) + base = point(body.get_left()[0], body.get_center()[1]) + add_glyph(state, TensorGlyph(f"ic:{m}", body, "bath_ic", {"base": base - body.get_center()}), pal) + + +def make_propagator_grid(state: SceneState, pal: dict) -> None: + s = LAYOUT["core_side"] + n = LAYOUT["n_steps"] + for k in range(n): + for m in MODES: + gid = f"prop:{m}:k{k}" + colors = pal["mode_1"] if m == "E1" else pal["mode_2"] + body = make_box(point(column_x(k), LAYOUT["row_y"][m]), (s, s), colors, state.camera_width) + add_glyph(state, TensorGlyph(gid, body, "propagator", box_ports((s, s))), pal) + state.cell[(m, k)] = gid + for k in range(n): + x = column_x(k) + top = LAYOUT["row_y"]["E1"] + s / 2 + LAYOUT["stub_len"] + bottom = LAYOUT["row_y"]["E2"] - s / 2 - LAYOUT["stub_len"] + state.fixed[f"open:out[{k}]"] = point(x, top) + state.fixed[f"open:in[{k}]"] = point(x, bottom) + # E1's top leg is out[k] and E2's bottom leg is in[k] through every transformation. + register_wire(state, f"phys:out[{k}]", PortRef(f"prop:E1:k{k}", "n", PortKind.PHYSICAL_OUT), + PortRef("fixed", f"open:out[{k}]", PortKind.PHYSICAL_OUT), Route.STRAIGHT, pal) + register_wire(state, f"phys:in[{k}]", PortRef(f"prop:E2:k{k}", "s", PortKind.PHYSICAL_IN), + PortRef("fixed", f"open:in[{k}]", PortKind.PHYSICAL_IN), Route.STRAIGHT, pal) + register_wire(state, f"vert:k{k}", PortRef(f"prop:E1:k{k}", "s", PortKind.VIRTUAL), + PortRef(f"prop:E2:k{k}", "n", PortKind.VIRTUAL), Route.STRAIGHT, pal) + for m in MODES: + east = PortRef(f"prop:{m}:k{k}", "e", PortKind.VIRTUAL) + far = PortRef(f"ic:{m}", "base", PortKind.VIRTUAL) if k == 0 else PortRef(f"prop:{m}:k{k - 1}", "w", PortKind.VIRTUAL) + register_wire(state, f"bath:{m}:k{k}", east, far, Route.STRAIGHT, pal) + for m in MODES: + # The closure sits on the wire from the moment the last propagator exists. + centre = point(column_x(n - 1) - s / 2 - LAYOUT["terminal_len"], LAYOUT["row_y"][m]) + state.fixed[f"terminal:{m}"] = centre + RIGHT * LAYOUT["marker_r"] + state.closure_markers[m] = make_closure_marker(centre, pal) + register_wire(state, f"term:{m}", PortRef(f"prop:{m}:k{n - 1}", "w", PortKind.VIRTUAL), + PortRef("fixed", f"terminal:{m}", PortKind.VIRTUAL), Route.STRAIGHT, pal) + + +def ids_revealed_at(k: int) -> tuple[list[str], list[str]]: + glyphs = [f"prop:{m}:k{k}" for m in MODES] + wires = [f"phys:out[{k}]", f"phys:in[{k}]", f"vert:k{k}"] + [f"bath:{m}:k{k}" for m in MODES] + if k == LAYOUT["n_steps"] - 1: + wires += [f"term:{m}" for m in MODES] + return glyphs, wires + + +# -------------------------------------------------------------------------- +# Animation helpers (never call scene.play) +# -------------------------------------------------------------------------- + + +def retract(mob: VMobject, toward: str, run_time: float) -> UpdateFromAlphaFunc: + """Consumed connection writes out towards ``start``, ``end`` or its ``middle``.""" + original = mob.copy() + + def update(m: VMobject, alpha: float) -> None: + a = float(np.clip(alpha, 0.0, 1.0)) + lo, hi = {"start": (0.0, 1.0 - a), "end": (a, 1.0), "middle": (a / 2, 1.0 - a / 2)}[toward] + m.pointwise_become_partial(original, lo, max(hi, lo)) + m.set_stroke(opacity=0.0 if a > 0.92 else 1.0) + + return UpdateFromAlphaFunc(mob, update, run_time=run_time, rate_func=smooth) + + +def glide_into(mob: VMobject, destination: np.ndarray, run_time: float) -> UpdateFromAlphaFunc: + """Carry a closure marker onto the body edge, then let it sink into that edge.""" + original = mob.copy() + origin = original.get_center() + + def update(m: VMobject, alpha: float) -> None: + a = float(np.clip(alpha, 0.0, 1.0)) + centre = origin + (destination - origin) * a + scale = 1.0 if a < 0.78 else max((1.0 - a) / 0.22, 1e-4) + moved = original.copy() + moved.scale(scale, about_point=origin) + moved.move_to(centre) + m.become(moved) + if a > 0.92: + m.set_fill(opacity=0.0) + m.set_stroke(opacity=0.0) + + return UpdateFromAlphaFunc(mob, update, run_time=run_time, rate_func=smooth) + + +def interpolate_path(mob: VMobject, start: np.ndarray, end: np.ndarray, run_time: float) -> UpdateFromAlphaFunc: + def update(m: VMobject, alpha: float) -> None: + m.set_points(start + (end - start) * alpha) + + return UpdateFromAlphaFunc(mob, update, run_time=run_time, rate_func=smooth) + + +def emphasis_overlay(path: VMobject, pal: dict, width: float) -> VMobject: + overlay = path.copy() + overlay.set_stroke(pal["glow"], opacity=1.0) + set_screen_stroke(overlay, STYLE["emphasis_px"], width) + overlay.set_z_index(Z["emphasis"]) + return overlay + + +def survivor_animations(state: SceneState, port_map: dict[str, tuple[str, PortRef]], + new_ports: dict[str, np.ndarray], run_time: float, + new_routes: dict[str, Route] | None = None) -> list: + """Interpolate surviving wires onto ports given by ``new_ports[owner_id:port]``.""" + anims = [] + for wid, (end, ref) in port_map.items(): + wire = state.wires[wid] + a, b = (ref, wire.b) if end == "a" else (wire.a, ref) + resolve = lambda r: new_ports.get(f"{r.owner_id}:{r.port_name}", None) + pa = resolve(a) if resolve(a) is not None else port_position(state, a) + pb = resolve(b) if resolve(b) is not None else port_position(state, b) + route = (new_routes or {}).get(wid, wire.route_kind) + anims.append(interpolate_path(wire.path, wire.path.points.copy(), route_points(route, pa, pb), run_time)) + return anims + + +def apply_port_map(state: SceneState, port_map: dict[str, tuple[str, PortRef]], new_routes: dict[str, Route] | None = None) -> None: + for wid, (end, ref) in port_map.items(): + wire = state.wires[wid] + if end == "a": + wire.a = ref + else: + wire.b = ref + if new_routes and wid in new_routes: + wire.route_kind = new_routes[wid] + + +def glyph_port_lookup(glyph: TensorGlyph, centre: np.ndarray | None = None) -> dict[str, np.ndarray]: + return {f"{glyph.id}:{name}": glyph.port(name, centre) for name in glyph.port_specs} + + +# -------------------------------------------------------------------------- +# Choreography: each plan_* returns a Beat +# -------------------------------------------------------------------------- + + +def plan_draw_initial_conditions(state: SceneState) -> Beat: + glyphs = [state.glyphs[f"ic:{m}"] for m in MODES] + + def commit() -> None: + for g in glyphs: + g.status = Status.LIVE + + return Beat("draw_initial_conditions", [DrawBorderThenFill(g.body, run_time=TIMING["ic_draw"]) for g in glyphs], + created_ids=tuple(g.id for g in glyphs), commit=commit) + + +def plan_reveal_column(scene: "ACECompressionScene", k: int, visible: list[VMobject]) -> Beat: + """Draw column k, join its bath wires to the region on the right, and widen the camera.""" + state = scene.state + glyph_ids, wire_ids = ids_revealed_at(k) + drawn = [state.glyphs[g].body for g in glyph_ids] + [state.wires[w].path for w in wire_ids] + closures = list(state.closure_markers.values()) if k == LAYOUT["n_steps"] - 1 else [] + new_mobs = drawn + closures + centre, width = fit_frame(visible + new_mobs, LAYOUT["camera_padding"]) + old_mobs = list(visible) + for mob in drawn: + set_screen_stroke(mob, mob.pt_px, width) + frame = scene.camera.frame + camera = frame.animate(run_time=TIMING["camera_move"], rate_func=smooth).move_to(centre).set(width=width) + + def compensate(_, alpha: float) -> None: + current = frame.width + for mob in old_mobs: + mob.set_stroke(width=screen_stroke(mob.pt_px, current)) + + holder = VMobject() + draw = [DrawBorderThenFill(state.glyphs[g].body, run_time=TIMING["column_draw"]) for g in glyph_ids] + draw += [Create(state.wires[w].path, run_time=TIMING["column_draw"]) for w in wire_ids] + draw += [GrowFromCenter(m, run_time=TIMING["column_draw"]) for m in closures] + + def commit() -> None: + state.camera_width = width + for g in glyph_ids: + state.glyphs[g].status = Status.LIVE + for w in wire_ids: + state.wires[w].status = Status.LIVE + + return Beat( + f"reveal_column_k{k}", + [camera, UpdateFromAlphaFunc(holder, compensate, run_time=TIMING["camera_move"]), AnimationGroup(*draw)], + created_ids=tuple(glyph_ids) + tuple(wire_ids), + commit=commit, + discard=[holder, frame], + ) + + +def plan_merge( + state: SceneState, + name: str, + source_ids: list[str], + consumed_wire_ids: list[tuple[str, str]], + target: TensorGlyph, + port_map: dict[str, tuple[str, PortRef]], + pal: dict, + on_commit: Callable[[], None] | None = None, + vanish: list[tuple[VMobject, np.ndarray]] | None = None, +) -> Beat: + """Local contraction: emphasise and write out internal edges, then morph the bodies. + + Surviving wires in ``port_map`` are re-attached to ``target`` ports during + the morph. Each ``vanish`` marker glides to the body point it is absorbed by. + """ + width = state.camera_width + consumed_paths = [(state.wires[w].path, toward) for w, toward in consumed_wire_ids] + overlays = [emphasis_overlay(p, pal, width) for p, _ in consumed_paths] + emphasis = AnimationGroup(*[Create(o, run_time=TIMING["emphasis"]) for o in overlays]) + write_out = AnimationGroup( + *[retract(p, t, TIMING["retract"]) for p, t in consumed_paths], + *[retract(o, t, TIMING["retract"]) for o, (_, t) in zip(overlays, consumed_paths)], + *[glide_into(m, dest, TIMING["retract"]) for m, dest in (vanish or [])], + ) + # One coincident target copy per source body so every body visibly slides and + # morphs onto the target; the copies are swapped for the real target after play. + composite = VGroup(*[state.glyphs[g].body for g in source_ids]) + target_copies = VGroup(*[target.body.copy() for _ in source_ids]) + survivors = survivor_animations(state, port_map, glyph_port_lookup(target), TIMING["merge"]) + morph = AnimationGroup(ReplacementTransform(composite, target_copies, run_time=TIMING["merge"]), *survivors) + steps = [emphasis, write_out, morph] if consumed_paths else [morph] + + def prepare(scene) -> None: + scene.remove(*composite.submobjects) + scene.add(composite) + + def finish(scene) -> None: + scene.remove(target_copies, composite) + scene.add(target.body) + + def commit() -> None: + for gid in source_ids: + state.glyphs[gid].status = Status.CONSUMED + for wid, _ in consumed_wire_ids: + state.wires[wid].status = Status.CONSUMED + apply_port_map(state, port_map) + target.status = Status.LIVE + add_glyph(state, target, pal) + if on_commit is not None: + on_commit() + + return Beat( + name, + [Succession(*steps)], + moving_ids=tuple(source_ids) + tuple(port_map), + consumed_ids=tuple(source_ids) + tuple(w for w, _ in consumed_wire_ids), + created_ids=(target.id,), + commit=commit, + prepare=prepare, + finish=finish, + discard=[p for p, _ in consumed_paths] + overlays + [m for m, _ in (vanish or [])] + [composite], + ) + + +def incident_port_map(state: SceneState, old_id: str, mapping: dict[str, str], target_id: str) -> dict[str, tuple[str, PortRef]]: + """Re-attach every live wire on ``old_id`` port ``p`` to ``target_id`` port ``mapping[p]``.""" + out = {} + for wire in state.wires.values(): + if wire.status != Status.LIVE: + continue + for end in ("a", "b"): + ref = getattr(wire, end) + if ref.owner_id == old_id and ref.port_name in mapping: + out[wire.id] = (end, PortRef(target_id, mapping[ref.port_name], ref.semantic_kind)) + return out + + +def plan_absorb_initial_condition(state: SceneState, mode: str, pal: dict) -> Beat: + old = state.cell[(mode, 0)] + s = LAYOUT["core_side"] + colors = pal["mode_1"] if mode == "E1" else pal["mode_2"] + target = TensorGlyph(f"{old}:ic", make_box(state.glyphs[old].body.get_center(), (s, s), colors, state.camera_width), "propagator", box_ports((s, s))) + port_map = incident_port_map(state, old, {p: p for p in "nsew"}, target.id) + port_map.pop(f"bath:{mode}:k0", None) + + def on_commit() -> None: + state.cell[(mode, 0)] = target.id + + return plan_merge(state, f"absorb_ic:{mode}", [f"ic:{mode}", old], [(f"bath:{mode}:k0", "start")], target, port_map, pal, on_commit) + + +def plan_join_column(state: SceneState, k: int, pal: dict) -> Beat: + """Two stacked propagators and their vertical bond fuse into one working rectangle.""" + top, bottom = state.cell[("E1", k)], state.cell[("E2", k)] + target = TensorGlyph(f"work:k{k}", make_box(point(column_x(k), 0), LAYOUT["work_size"], pal["work"], state.camera_width), "work", work_ports()) + port_map = incident_port_map(state, top, {"n": "n", "e": "e1", "w": "w1"}, target.id) + port_map.update(incident_port_map(state, bottom, {"s": "s", "e": "e2", "w": "w2"}, target.id)) + + def on_commit() -> None: + state.working_id = target.id + + return plan_merge(state, f"join_column_k{k}", [top, bottom], [(f"vert:k{k}", "middle")], target, port_map, pal, on_commit) + + +def plan_absorb_carry(state: SceneState, k: int, pal: dict) -> Beat: + """Carry + fused column: the frontier bath connections between them close.""" + work, carry = state.working_id, state.carry_id + target = TensorGlyph(f"work:k{k}:carry", make_box(point(column_x(k), 0), LAYOUT["work_size"], pal["work"], state.camera_width), "work", work_ports()) + port_map = incident_port_map(state, work, {p: p for p in ("n", "s", "w1", "w2")}, target.id) + port_map.update(incident_port_map(state, carry, {"mem_e": "mem_e"}, target.id)) + consumed = [(f"bath:{m}:k{k}", "middle") for m in MODES] + + def on_commit() -> None: + state.working_id = target.id + state.carry_id = None + + return plan_merge(state, f"absorb_carry_k{k}", [carry, work], consumed, target, port_map, pal, on_commit) + + +def plan_split_completed_core(state: SceneState, k: int, pal: dict) -> Beat: + """Working rectangle -> completed core (stays at k, keeps the physical legs) + carry (moves on). + + The completed core stays on the RIGHT of the carry moving LEFT; canonicality is not drawn. + """ + work = state.glyphs[state.working_id] + width = state.camera_width + core = TensorGlyph(f"core:k{k}", make_box(point(column_x(k), 0), (LAYOUT["core_side"],) * 2, pal["pt"], width), "pt_core", core_ports()) + carry_centre = point(column_x(k) - LAYOUT["carry_split_dx"], 0) + carry = TensorGlyph(f"carry:k{k}", make_box(carry_centre, LAYOUT["carry_size"], pal["carry"], width), "carry", carry_ports()) + + port_map = incident_port_map(state, work.id, {"n": "n", "s": "s", "mem_e": "mem_e"}, core.id) + port_map.update(incident_port_map(state, work.id, {"w1": "f1", "w2": "f2"}, carry.id)) + new_routes = {wid: Route.FRONTIER for wid, (_, ref) in port_map.items() if ref.owner_id == carry.id} + lookup = {**glyph_port_lookup(core), **glyph_port_lookup(carry)} + survivors = survivor_animations(state, port_map, lookup, TIMING["split"], new_routes) + + seam = point(column_x(k) - LAYOUT["work_size"][0] / 2, 0) + bond = WireRecord( + f"mem:k{k}", PortRef(core.id, "mem_w", PortKind.VIRTUAL), PortRef(carry.id, "mem_e", PortKind.VIRTUAL), + make_wire_path(route_points(Route.STRAIGHT, seam, seam), pal["wire"], width), Route.STRAIGHT, + ) + bond_end = route_points(Route.STRAIGHT, core.port("mem_w"), carry.port("mem_e")) + work_copy = work.body.copy() + split = AnimationGroup( + ReplacementTransform(work.body, core.body, run_time=TIMING["split"]), + ReplacementTransform(work_copy, carry.body, run_time=TIMING["split"]), + interpolate_path(bond.path, bond.path.points.copy(), bond_end, TIMING["split"]), + *survivors, + ) + def prepare(scene) -> None: + scene.add(work_copy, bond.path) + + def commit() -> None: + work.status = Status.CONSUMED + apply_port_map(state, port_map, new_routes) + for glyph in (core, carry): + glyph.status = Status.LIVE + add_glyph(state, glyph, pal) + bond.status = Status.LIVE + state.wires[bond.id] = bond + state.completed[k] = core.id + state.carry_id = carry.id + state.working_id = None + + return Beat( + f"split_completed_core_k{k}", [split], moving_ids=(work.id,) + tuple(port_map), + consumed_ids=(work.id,), created_ids=(core.id, carry.id, bond.id), commit=commit, prepare=prepare, + ) + + +def plan_advance_carry(state: SceneState, next_k: int) -> Beat: + """Only the carry and its incident routes move; the completed core keeps its legs.""" + carry = state.glyphs[state.carry_id] + new_centre = point(column_x(next_k) + LAYOUT["carry_wait_dx"], 0) + port_map = incident_port_map(state, carry.id, {p: p for p in carry.port_specs}, carry.id) + survivors = survivor_animations(state, port_map, glyph_port_lookup(carry, new_centre), TIMING["carry_advance"]) + move = carry.body.animate(run_time=TIMING["carry_advance"], rate_func=smooth).move_to(new_centre) + return Beat(f"advance_carry_to_k{next_k}", [move, *survivors], moving_ids=(carry.id,) + tuple(port_map)) + + +def plan_trace_final_bath_boundary(state: SceneState, pal: dict) -> Beat: + """Bath boundary indices, already capped, are traced out. The system leg out[2] stays.""" + k = LAYOUT["n_steps"] - 1 + work = state.working_id + core = TensorGlyph(f"core:k{k}", make_box(point(column_x(k), 0), (LAYOUT["core_side"],) * 2, pal["pt"], state.camera_width), "pt_core", core_ports()) + port_map = incident_port_map(state, work, {"n": "n", "s": "s", "mem_e": "mem_e"}, core.id) + consumed = [(f"term:{m}", "start") for m in MODES] + if any(w.startswith("phys") for w, _ in consumed): + raise AssertionError("trace must not consume a system leg") + # The cap rides into the rectangle's west edge instead of shrinking where it sits. + markers = [ + (state.closure_markers[m], state.wires[f"term:{m}"].path.points[0].copy() + RIGHT * 0.22) + for m in MODES + ] + + def on_commit() -> None: + state.completed[k] = core.id + state.working_id = None + state.closure_markers.clear() + + return plan_merge(state, "trace_final_bath_boundary", [work], consumed, core, port_map, pal, on_commit, markers) + + +def bend_leg_animation(wire: WireRecord, core_centre: np.ndarray, role: str, run_time: float) -> UpdateFromAlphaFunc: + """Attachment slides to the side edge while the open end swings down to the marker boundary.""" + half = LAYOUT["core_side"] / 2 + attach = core_centre + side_attachment(role) + _, end_final = open_socket(core_centre, role) + start_attach = wire.path.points[0] - core_centre + start_end = wire.path.points[-1] - core_centre + th0 = np.arctan2(start_attach[1], start_attach[0]) + th1 = np.arctan2((attach - core_centre)[1], (attach - core_centre)[0]) + phi0 = np.arctan2(start_end[1], start_end[0]) + phi1 = np.arctan2((end_final - core_centre)[1], (end_final - core_centre)[0]) + if role == "out": + if th1 < th0: + th1 += 2 * np.pi + if phi1 < phi0: + phi1 += 2 * np.pi + r0, r1 = np.linalg.norm(start_end), np.linalg.norm(end_final - core_centre) + + def update(m: VMobject, alpha: float) -> None: + if alpha >= 1.0: + m.set_points(route_points(Route.DOWN_SOCKET, attach, end_final)) + return + th = th0 + (th1 - th0) * alpha + direction = point(np.cos(th), np.sin(th)) + p = core_centre + direction * half / max(abs(direction[0]), abs(direction[1])) + phi = phi0 + (phi1 - phi0) * alpha + e = core_centre + point(np.cos(phi), np.sin(phi)) * (r0 + (r1 - r0) * alpha) + straight = e - p + chord = split_cubic(p, p + straight / 3, e - straight / 3, e) + arc = route_points(Route.DOWN_SOCKET, p, e) + m.set_points(chord + (arc - chord) * alpha) + + return UpdateFromAlphaFunc(wire.path, update, run_time=run_time, rate_func=smooth) + + +def plan_bend_all_physical_legs(state: SceneState, pal: dict) -> Beat: + """Diagram rerouting only: port IDs and ordering are preserved.""" + bends, markers, port_map = [], [], {} + for k, core_id in state.completed.items(): + core = state.glyphs[core_id] + centre = core.body.get_center() + for role in ("out", "in"): + wire = state.wires[f"phys:{role}[{k}]"] + bends.append(bend_leg_animation(wire, centre, role, TIMING["bend"])) + socket = f"socket:{role}[{k}]" + marker_centre, boundary = open_socket(centre, role) + state.fixed[socket] = boundary + marker = make_marker(role, False, marker_centre, pal, state.camera_width) + core.markers[role] = marker + markers.append(GrowFromCenter(marker, run_time=TIMING["marker_draw"])) + port_map[wire.id] = (wire.a, wire.b, socket, role) + + def commit() -> None: + for wid, (a, b, socket, role) in port_map.items(): + wire = state.wires[wid] + wire.a = PortRef(a.owner_id, role, a.semantic_kind) + wire.b = PortRef("fixed", socket, b.semantic_kind) + wire.route_kind = Route.DOWN_SOCKET + if np.linalg.norm(wire.path.points[-1] - state.fixed[socket]) > 1e-6: + raise AssertionError(f"{wid} does not end on its socket") + + return Beat("bend_all_physical_legs", [Succession(AnimationGroup(*bends), AnimationGroup(*markers))], + moving_ids=tuple(port_map), commit=commit) + + +def plan_final_reframe(scene: "ACECompressionScene") -> Beat: + state = scene.state + live = scene.live_mobjects() + centre, width = fit_frame(live, LAYOUT["camera_padding"]) + frame = scene.camera.frame + + def compensate(_, alpha: float) -> None: + for mob in live: + mob.set_stroke(width=screen_stroke(mob.pt_px, frame.width)) + + holder = VMobject() + + def commit() -> None: + state.camera_width = width + + return Beat( + "final_reframe", + [frame.animate(run_time=TIMING["reframe"], rate_func=smooth).move_to(centre).set(width=width), + UpdateFromAlphaFunc(holder, compensate, run_time=TIMING["reframe"])], + commit=commit, discard=[holder, frame], + ) + + +# -------------------------------------------------------------------------- +# Scene +# -------------------------------------------------------------------------- + + +class ACECompressionScene(MovingCameraScene): + def setup(self) -> None: + super().setup() + self.theme = active_theme() + self.pal = PALETTES[self.theme] + self.state = SceneState() + self.poster_time: float | None = None + + # -- lifecycle -------------------------------------------------------- + + def play_beat(self, beat: Beat) -> None: + self.play_parallel_beats(beat) + + def play_parallel_beats(self, *beats: Beat) -> None: + moving = {i for beat in beats for i in (*beat.moving_ids, *beat.consumed_ids)} + fixed = {gid: g.body.get_center().copy() for gid, g in self.state.glyphs.items() + if g.status == Status.LIVE and gid not in moving} + for beat in beats: + beat.prepare(self) + anims = [a for beat in beats for a in beat.animations] + if anims: + self.play(*anims) + for beat in beats: + beat.finish(self) + if beat.discard: + self.remove(*beat.discard) + for mob in beat.discard: + mob.clear_updaters() + beat.commit() + if DEBUG["log_beats"]: + print(f"[beat] {beat.name}: moving={list(beat.moving_ids)} consumed={list(beat.consumed_ids)} created={list(beat.created_ids)}") + for gid, centre in fixed.items(): + if np.linalg.norm(self.state.glyphs[gid].body.get_center() - centre) > 1e-6: + raise AssertionError(f"untouched glyph {gid} moved during {[b.name for b in beats]}") + self.assert_invariants() + + def checkpoint(self, name: str) -> None: + if DEBUG["save_checkpoints"]: + out = Path(config.media_dir) / "checkpoints" + out.mkdir(parents=True, exist_ok=True) + self.renderer.update_frame(self) + self.renderer.get_image().save(out / f"{SCENE_STEM}-{self.theme}-{name}.png") + if DEBUG["log_beats"]: + print(f"[checkpoint] {name}") + if DEBUG["stop_after"] == name: + raise EndSceneEarlyException() + + def live_mobjects(self) -> list[VMobject]: + state = self.state + mobs = [] + for g in state.glyphs.values(): + if g.status == Status.LIVE: + mobs += [g.body, *g.markers.values()] + if g.label is not None: + mobs.append(g.label) + mobs += [w.path for w in state.wires.values() if w.status == Status.LIVE] + for mode, marker in state.closure_markers.items(): + if state.wires[f"term:{mode}"].status == Status.LIVE: + mobs.append(marker) + return mobs + + def assert_invariants(self) -> None: + state = self.state + live = {id(m) for m in self.get_mobject_family_members()} + for glyph in state.glyphs.values(): + if glyph.status == Status.LIVE and id(glyph.body) not in live: + raise AssertionError(f"live glyph {glyph.id} missing from scene") + if glyph.status == Status.CONSUMED and id(glyph.body) in live: + raise AssertionError(f"consumed glyph {glyph.id} still drawn") + for wire in state.wires.values(): + if wire.status == Status.LIVE: + for ref in (wire.a, wire.b): + if ref.owner_id != "fixed" and state.glyphs[ref.owner_id].status != Status.LIVE: + raise AssertionError(f"wire {wire.id} references non-live {ref.owner_id}") + if np.linalg.norm(wire.path.points[0] - port_position(state, wire.a)) > 1e-6: + raise AssertionError(f"wire {wire.id} detached from {wire.a.owner_id}:{wire.a.port_name}") + if np.linalg.norm(wire.path.points[-1] - port_position(state, wire.b)) > 1e-6: + raise AssertionError(f"wire {wire.id} detached from {wire.b.owner_id}:{wire.b.port_name}") + elif wire.status == Status.CONSUMED and id(wire.path) in live: + raise AssertionError(f"consumed wire {wire.id} still drawn") + expected = {id(m) for mob in self.live_mobjects() for m in mob.get_family()} + ghosts = [m for m in self.get_mobject_family_members() if m.has_points() and id(m) not in expected] + if ghosts: + raise AssertionError(f"{len(ghosts)} unowned mobjects left in the scene: {ghosts[:3]}") + + def assert_final_pt(self) -> None: + state = self.state + live = [g for g in state.glyphs.values() if g.status == Status.LIVE] + wires = [w for w in state.wires.values() if w.status == Status.LIVE] + cores = [g for g in live if g.kind == "pt_core"] + bonds = [w for w in wires if w.id.startswith("mem:")] + phys = [w for w in wires if w.id.startswith("phys:")] + if (len(cores), len(live), len(bonds), len(phys), len(wires)) != (3, 3, 2, 6, 8): + raise AssertionError(f"final PT has {len(cores)} cores, {len(live)} glyphs, {len(bonds)} bonds, {len(phys)} legs, {len(wires)} wires") + if any(w.id.startswith(("term:", "bath:")) for w in wires): + raise AssertionError("bath terminals survived into the final PT") + + def mark_poster(self) -> None: + self.poster_time = self.renderer.time + EXPORT["poster_offset"] + + def reset_loop(self) -> None: + mobs = list(self.mobjects) + if mobs: + self.play(*[FadeOut(m, scale=0.96) for m in mobs], run_time=TIMING["loop_fade"]) + for m in mobs: + m.clear_updaters() + self.clear() + if self.mobjects: + raise AssertionError("reset_loop left mobjects in the scene") + self.camera.frame.move_to(self.opening_centre).set(width=self.opening_width) + self.wait(TIMING["blank_hold"]) + + def pause(self) -> None: + self.wait(TIMING["beat_gap"]) + + # -- story ------------------------------------------------------------ + + def construct(self) -> None: + state, pal = self.state, self.pal + make_bath_initial_conditions(state, pal) + ics = [state.glyphs[f"ic:{m}"].body for m in MODES] + self.opening_centre, self.opening_width = fit_frame(ics, LAYOUT["camera_padding"]) + self.camera.frame.move_to(self.opening_centre).set(width=self.opening_width) + state.camera_width = self.opening_width + for mob in ics: + set_screen_stroke(mob, mob.pt_px, self.opening_width) + make_propagator_grid(state, pal) + self.wait(TIMING["blank_hold"]) + + self.play_beat(plan_draw_initial_conditions(state)) + visible = list(ics) + for k in range(LAYOUT["n_steps"]): + self.play_beat(plan_reveal_column(self, k, visible)) + visible = self.live_mobjects() + self.checkpoint("ace_grid") + self.pause() + + self.play_parallel_beats(*[plan_absorb_initial_condition(state, m, pal) for m in MODES]) + for k in range(LAYOUT["n_steps"]): + self.play_beat(plan_join_column(state, k, pal)) + if state.carry_id is not None: + self.play_beat(plan_absorb_carry(state, k, pal)) + if k < LAYOUT["n_steps"] - 1: + self.play_beat(plan_split_completed_core(state, k, pal)) + self.play_beat(plan_advance_carry(state, k + 1)) + else: + self.play_beat(plan_trace_final_bath_boundary(state, pal)) + self.checkpoint(f"ace_after_k{k}") + + self.assert_final_pt() + self.play_beat(plan_bend_all_physical_legs(state, pal)) + self.play_beat(plan_final_reframe(self)) + self.assert_final_pt() + self.checkpoint("ace_final_pt") + + self.mark_poster() + self.wait(TIMING["result_hold"]) + self.reset_loop() + + +# -------------------------------------------------------------------------- +# Render entry point +# -------------------------------------------------------------------------- + + +def vertical_content_window(movie: Path, width: int, height: int) -> tuple[int, int]: + """Return the top row and height of a crop that keeps every opaque pixel. + + Rows that stay empty for the whole clip are dropped. A 22px margin at a + 540px-tall frame (scaled with height) is kept, and the window is expanded + outward to an even height. The horizontal extent is never changed. + """ + raw = subprocess.check_output( + [ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(movie), + "-vf", "format=gbrap,unpremultiply=inplace=1:planes=7,alphaextract,format=gray", + "-f", "rawvideo", "-pix_fmt", "gray", "-", + ] + ) + frame = width * height + n = len(raw) // frame + if n == 0: + return 0, height + alpha = np.frombuffer(raw[: n * frame], dtype=np.uint8).reshape(n, height, width) + rows = np.where((alpha > 8).any(axis=(0, 2)))[0] + if rows.size == 0: + return 0, height + top, bot = int(rows.min()), int(rows.max()) + pad = max(8, round(22 * height / 540)) + y0 = top - pad + y1 = bot + pad + if (y1 - y0 + 1) % 2: + if y0 > 0: + y0 -= 1 + else: + y1 += 1 + y0 = max(0, y0) + y1 = min(height - 1, y1) + if y0 > top or y1 < bot: + raise AssertionError(f"vertical crop would cut content: y={top}-{bot}, window={y0}-{y1}") + return y0, y1 - y0 + 1 + + +def encode_outputs(movie: Path, out_dir: Path, stem: str, theme: str, poster_time: float | None, fps: int, size: tuple[int, int], preset: str) -> None: + out_dir.mkdir(parents=True, exist_ok=True) + ff = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"] + # Cairo renders premultiplied RGBA but Manim writes it as straight alpha; + # without this, fades and antialiased edges darken. + unpremultiply = "format=gbrap,unpremultiply=inplace=1:planes=7" + y0, crop_h = vertical_content_window(movie, size[0], size[1]) + crop = "" if y0 == 0 and crop_h == size[1] else f"crop={size[0]}:{crop_h}:0:{y0}," + if preset == "final": + gif_filter = ( + f"{unpremultiply},{crop}fps={fps},split[a][b];[a]palettegen=reserve_transparent=1:stats_mode=full[p];" + f"[b][p]paletteuse=dither={EXPORT['gif_dither']}:alpha_threshold={EXPORT['gif_alpha_threshold']}" + ) + subprocess.run([*ff, "-i", str(movie), "-vf", gif_filter, "-loop", "0", str(out_dir / f"{stem}.gif")], check=True) + else: + background = EXPORT["mp4_background"][theme] + subprocess.run( + [*ff, "-f", "lavfi", "-i", f"color=c={background}:s={size[0]}x{crop_h}:r={fps}", "-i", str(movie), + "-filter_complex", f"[1]{unpremultiply}{',' + crop.rstrip(',') if crop else ''}[fg];[0][fg]overlay=shortest=1,format=yuv420p", "-c:v", "libx264", + "-crf", "18", "-movflags", "+faststart", str(out_dir / f"{stem}.mp4")], + check=True, + ) + if poster_time is not None: + subprocess.run( + [*ff, "-ss", f"{poster_time:.3f}", "-i", str(movie), "-vf", f"{unpremultiply},{crop}format=rgba", + "-frames:v", "1", str(out_dir / f"{stem}-poster.png")], + check=True, + ) + + +def render_assets(scene_cls: type[MovingCameraScene] = ACECompressionScene) -> None: + theme = active_theme() + preset = os.environ.get("PT_ANIM_PRESET", "final").strip().lower() + spec = EXPORT[preset] + repo = Path(__file__).resolve().parents[2] + media = repo / "docs" / "animations" / "media" + out_dir = repo / "docs" / "src" / "assets" / "animations" if preset == "final" else media / "drafts" + stem = f"{SCENE_STEM}-{theme}" + aspect = spec["pixel_width"] / spec["pixel_height"] + with tempconfig({ + "pixel_width": spec["pixel_width"], + "pixel_height": spec["pixel_height"], + "frame_rate": spec["fps"], + "frame_height": LAYOUT["frame_height"], + "frame_width": LAYOUT["frame_height"] * aspect, + "background_opacity": 0.0, + "format": "mov", + "media_dir": str(media), + "output_file": stem, + "disable_caching": True, + "verbosity": "WARNING", + "progress_bar": "none", + }): + scene = scene_cls() + scene.render() + movie = Path(scene.renderer.file_writer.movie_file_path) + encode_outputs(movie, out_dir, stem, theme, scene.poster_time, spec["fps"], (spec["pixel_width"], spec["pixel_height"]), preset) + print(f"[export] {stem}: {out_dir}") + + +if __name__ == "__main__": + render_assets() diff --git a/docs/animations/instrument_sequence.py b/docs/animations/instrument_sequence.py new file mode 100644 index 0000000..b28a89a --- /dev/null +++ b/docs/animations/instrument_sequence.py @@ -0,0 +1,964 @@ +# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors +# SPDX-License-Identifier: MIT +# +# File: docs/animations/instrument_sequence.py +# Contributor: Gauthameshwar S. +# +# Generates the schematic InstrumentSeq animation (explicit pieces, then a scan +# that fills the one unspecified intervention with an identity). +# +# Run with: +# PT_ANIM_THEME=light docs/animations/.manim_env/bin/python docs/animations/instrument_sequence.py +"""Instrument-sequence animation for the ProcessTensors.jl homepage and README. + +Schematic only: no package calls, no numerics. Time runs right to left. The tape +has three propagation intervals; each grey divider carries the PT-facing output +marker ``out[k]`` on its left and input marker ``in[k]`` on its right. + +Tested with Manim Community v0.21.0 (Cairo renderer), Python 3.12, ffmpeg 8.0.1. + +Render from the repository root:: + + PT_ANIM_THEME=light docs/animations/.manim_env/bin/python docs/animations/instrument_sequence.py + PT_ANIM_THEME=dark docs/animations/.manim_env/bin/python docs/animations/instrument_sequence.py + +``PT_ANIM_PRESET=draft`` writes an MP4 and a poster PNG into +``docs/animations/media/drafts/``. The final preset writes a transparent GIF +and a transparent poster PNG into ``docs/src/assets/animations/``. +""" + +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Callable + +import numpy as np +from manim import ( + DOWN, + UP, + AnimationGroup, + CapStyleType, + Circle, + Create, + DrawBorderThenFill, + FadeOut, + GrowFromCenter, + Line, + Polygon, + Rectangle, + RoundedRectangle, + Scene, + Succession, + Text, + Uncreate, + UpdateFromAlphaFunc, + Wait, + VGroup, + VMobject, + config, + linear, + rate_functions, + smooth, + tempconfig, +) +from manim.utils.exceptions import EndSceneEarlyException + +# -------------------------------------------------------------------------- +# Settings +# -------------------------------------------------------------------------- + +SCENE_STEM = "instruments" + +STYLE = { + "outline_px": 5.0, + "wire_px": 4.5, + "marker_px": 4.5, + "tape_px": 3.0, + "pulse_px": 4.0, + "reference_pixel_width": 960, +} + +PALETTES = { + "light": { + "outline": "#2b2b2b", + "wire": "#3a3a3a", + "muted": "#a3a3a3", + "tape": "#c4c4c4", + "input": "#138a8a", + "output": "#c27c0e", + "instrument": ("#f7d2c4", "#c0533a"), + "identity": ("#d5e2f0", "#4a6f96"), + "state": ("#c8ecea", "#138a8a"), + "adapter": ("#fdebc8", "#c27c0e"), + "glow": "#f2a900", + "debug": "#7a7a7a", + }, + "dark": { + "outline": "#e6e6e6", + "wire": "#cfcfcf", + "muted": "#7d8a8a", + "tape": "#566262", + "input": "#4fd1c5", + "output": "#f2b544", + "instrument": ("#a84e36", "#ffb59e"), + "identity": ("#345a80", "#a9c8ea"), + "state": ("#1f7a76", "#8ff0e6"), + "adapter": ("#8a5f12", "#f2b544"), + "glow": "#ffd166", + "debug": "#9e9e9e", + }, +} + +TIMING = { + "blank_hold": 0.15, + "tape_draw": 0.6, + "marker_draw": 0.45, + "piece_draw": 0.5, + "snap_approach": 0.35, + "snap_settle": 0.12, + "snap_pulse": 0.3, + "notice_hold": 0.9, + "wave_speed": 2.6, + "highlight": 0.35, + "identity_draw": 0.45, + "result_hold": 1.8, + "loop_fade": 0.4, +} + +LAYOUT = { + "frame_height": 4.5, + "n_steps": 3, + "pitch": 2.2, + "x_right": 2.2, + "tape_pad": 0.34, + "tape_half_width": 3.4, + "separator_width": 0.13, + "separator_inset": 0.1, + "marker_dx": 0.36, + "dock_y": 0.15, + "marker_r": 0.085, + "spawn_drop": 0.55, + "hover_gap": 0.07, + "corner": 0.09, + "tri_side": 0.56, + "leg_len": 0.42, + "leg_stub": 0.2, + "wave_sigma": 0.11, + "wave_half_width": 0.55, +} + + +def _fit_tape_to_instruments() -> None: + """Rails sit ``tape_pad`` outside the unitary square, the tallest instrument. + + The square is centred on ``dock_y``. The previous rails left that square + hanging below the tape, so the added room is on the bottom. + """ + span = LAYOUT["pitch"] - 2 * LAYOUT["marker_dx"] + side = span - 2 * LAYOUT["marker_r"] - 2 * LAYOUT["leg_stub"] + half = side / 2 + LAYOUT["tape_pad"] + LAYOUT["tape_top"] = LAYOUT["dock_y"] + half + LAYOUT["tape_bottom"] = LAYOUT["dock_y"] - half + + +_fit_tape_to_instruments() + +DEBUG = { + "show_ids": False, + "show_port_names": False, + "show_bounds": False, + "highlight_moving": False, + "log_beats": True, + "save_checkpoints": False, + "stop_after": None, +} + +EXPORT = { + "final": {"pixel_width": 960, "pixel_height": 540, "fps": 24}, + "draft": {"pixel_width": 480, "pixel_height": 270, "fps": 12}, + "gif_dither": "sierra2_4a", + "gif_alpha_threshold": 128, + "mp4_background": {"light": "#ffffff", "dark": "#1f2424"}, + "poster_offset": 0.5, +} + +Z = {"tape": 0, "wave": 0.4, "wire": 1, "body": 2, "marker": 3, "glow": 4, "debug": 5} + + +def active_theme() -> str: + theme = os.environ.get("PT_ANIM_THEME", "light").strip().lower() + if theme not in PALETTES: + raise ValueError(f"PT_ANIM_THEME must be one of {sorted(PALETTES)}, got {theme!r}") + return theme + + +def screen_stroke(px: float) -> float: + """Manim stroke width that renders as ``px`` pixels at the reference export width.""" + return px * config.frame_width / (STYLE["reference_pixel_width"] * 0.01) + + +# -------------------------------------------------------------------------- +# Small local records +# -------------------------------------------------------------------------- + + +class Status(Enum): + HIDDEN = "hidden" + LIVE = "live" + CONSUMED = "consumed" + + +class PortKind(Enum): + PHYSICAL_IN = "physical_in" + PHYSICAL_OUT = "physical_out" + VIRTUAL = "virtual" + OPEN_RESULT = "open_result" + + +class EntryKind(Enum): + PREPARE = "prepare" + UNSPECIFIED = "unspecified" + CUSTOM_MAP = "custom_map" + OPEN_OUTPUT = "open_output" + DEFAULT_IDENTITY = "default_identity" + + +@dataclass +class PortRef: + owner_id: str + port_name: str + semantic_kind: PortKind + + +@dataclass +class TensorGlyph: + id: str + body: VMobject + kind: str + markers: dict[str, VMobject] = field(default_factory=dict) + legs: dict[str, VMobject] = field(default_factory=dict) + port_kinds: dict[str, PortKind] = field(default_factory=dict) + label: VMobject | None = None + status: Status = Status.HIDDEN + + def group(self) -> VGroup: + parts = [self.body, *self.legs.values(), *self.markers.values()] + if self.label is not None: + parts.append(self.label) + return VGroup(*parts) + + +@dataclass +class ProtocolEntry: + id: str + endpoints: list[str] + kind: EntryKind + event_x: float = 0.0 + piece_id: str | None = None + generated: bool = False + + +@dataclass +class SceneState: + glyphs: dict[str, TensorGlyph] = field(default_factory=dict) + sockets: dict[str, VMobject] = field(default_factory=dict) + socket_centres: dict[str, np.ndarray] = field(default_factory=dict) + occupancy: dict[str, str | None] = field(default_factory=dict) + protocol: list[ProtocolEntry] = field(default_factory=list) + tape_parts: list[VMobject] = field(default_factory=list) + transient_ids: set[str] = field(default_factory=set) + wave: VGroup | None = None + wave_x: float = 0.0 + + +@dataclass +class Beat: + name: str + animations: list + moving_ids: tuple[str, ...] = () + consumed_ids: tuple[str, ...] = () + created_ids: tuple[str, ...] = () + commit: Callable[[], None] = lambda: None + discard: list[VMobject] = field(default_factory=list) + + +# -------------------------------------------------------------------------- +# Pure geometry factories +# -------------------------------------------------------------------------- + + +def point(x: float, y: float) -> np.ndarray: + return np.array([x, y, 0.0]) + + +def split_cubic(p0, p1, p2, p3) -> np.ndarray: + """One cubic as two cubic halves (8 control points) so every wire interpolates.""" + a, b, c = (p0 + p1) / 2, (p1 + p2) / 2, (p2 + p3) / 2 + d, e = (a + b) / 2, (b + c) / 2 + m = (d + e) / 2 + return np.array([p0, a, d, m, m, e, c, p3]) + + +def straight_points(a: np.ndarray, b: np.ndarray) -> np.ndarray: + return split_cubic(a, a + (b - a) / 3, a + 2 * (b - a) / 3, b) + + +def make_wire(points: np.ndarray, color: str) -> VMobject: + wire = VMobject() + wire.set_points(points) + wire.set_fill(opacity=0) + wire.set_stroke(color, width=screen_stroke(STYLE["wire_px"])) + wire.set_cap_style(CapStyleType.ROUND) + wire.set_z_index(Z["wire"]) + return wire + + +def make_marker(role: str, filled: bool, position: np.ndarray, pal: dict) -> VMobject: + """Input sockets are teal circles, output sockets amber rounded squares, everywhere.""" + r = LAYOUT["marker_r"] + color = pal["input"] if role == "in" else pal["output"] + if role == "in": + marker = Circle(radius=r) + else: + marker = RoundedRectangle(width=2 * r, height=2 * r, corner_radius=0.35 * r) + marker.set_stroke(color, width=screen_stroke(STYLE["marker_px"])) + marker.set_fill(color, opacity=1.0 if filled else 0.0) + marker.move_to(position) + marker.set_z_index(Z["marker"]) + return marker + + +def make_box(center: np.ndarray, w: float, h: float, colors: tuple[str, str], corner: float) -> VMobject: + box = RoundedRectangle(width=w, height=h, corner_radius=corner) + box.set_fill(colors[0], opacity=1.0) + box.set_stroke(colors[1], width=screen_stroke(STYLE["outline_px"])) + box.move_to(center) + box.set_z_index(Z["body"]) + return box + + +def make_arrow_body(center: np.ndarray, side: float, colors: tuple[str, str], pointing_right: bool) -> VMobject: + """Equilateral arrow. The leg meets the vertical base, opposite the apex.""" + h = side * np.sqrt(3) / 2 + sign = 1.0 if pointing_right else -1.0 + tri = Polygon( + center + point(sign * 2 * h / 3, 0), + center + point(-sign * h / 3, side / 2), + center + point(-sign * h / 3, -side / 2), + ) + tri.round_corners(radius=0.12 * side) + tri.set_fill(colors[0], opacity=1.0) + tri.set_stroke(colors[1], width=screen_stroke(STYLE["outline_px"])) + tri.set_z_index(Z["body"]) + return tri + + +def marker_boundary(centre: np.ndarray, toward_x: float) -> np.ndarray: + """Point on the marker where a horizontal leg, coming from ``toward_x``, meets it.""" + sign = 1.0 if toward_x >= centre[0] else -1.0 + return point(centre[0] + sign * LAYOUT["marker_r"], centre[1]) + + +def debug_label(text: str, position: np.ndarray, pal: dict) -> VMobject: + label = Text(text, font_size=14, color=pal["debug"]) + label.move_to(position) + label.set_z_index(Z["debug"]) + return label + + +# -------------------------------------------------------------------------- +# Tape and protocol +# -------------------------------------------------------------------------- + + +def separator_x(k: int) -> float: + return LAYOUT["x_right"] - k * LAYOUT["pitch"] + + +def socket_position(name: str) -> np.ndarray: + role, k = name[:-3].rstrip("["), int(name[-2]) + dx = LAYOUT["marker_dx"] + x = separator_x(k) + (dx if role == "in" else -dx) + return point(x, LAYOUT["dock_y"]) + + +def make_tape(state: SceneState, pal: dict) -> None: + half = LAYOUT["tape_half_width"] + top, bottom = LAYOUT["tape_top"], LAYOUT["tape_bottom"] + for y in (top, bottom): + line = Line(point(-half, y), point(half, y)) + line.set_stroke(pal["tape"], width=screen_stroke(STYLE["tape_px"])) + line.set_cap_style(CapStyleType.ROUND) + line.set_z_index(Z["tape"]) + state.tape_parts.append(line) + inset = LAYOUT["separator_inset"] + for k in range(LAYOUT["n_steps"]): + bar = RoundedRectangle( + width=LAYOUT["separator_width"], + height=top - bottom - 2 * inset, + corner_radius=LAYOUT["separator_width"] / 2, + ) + bar.set_fill(pal["muted"], opacity=1.0) + bar.set_stroke(width=0) + bar.move_to(point(separator_x(k), (top + bottom) / 2)) + bar.set_z_index(Z["tape"]) + state.tape_parts.append(bar) + for role in ("out", "in"): + name = f"{role}[{k}]" + centre = socket_position(name) + state.sockets[name] = make_marker(role, False, centre, pal) + state.socket_centres[name] = centre + state.occupancy[name] = None + if DEBUG["show_port_names"]: + state.tape_parts.append(debug_label(name, centre + point(0, 0.22), pal)) + + +def make_protocol_positions() -> list[ProtocolEntry]: + """Protocol positions, earliest (rightmost) to latest (leftmost).""" + protocol = [ + ProtocolEntry("initial", ["in[0]"], EntryKind.PREPARE), + ProtocolEntry("gap_0", ["out[0]", "in[1]"], EntryKind.UNSPECIFIED), + ProtocolEntry("gap_1", ["out[1]", "in[2]"], EntryKind.CUSTOM_MAP), + ProtocolEntry("final", ["out[2]"], EntryKind.OPEN_OUTPUT), + ] + for entry in protocol: + entry.event_x = float(np.mean([socket_position(e)[0] for e in entry.endpoints])) + return protocol + + +# -------------------------------------------------------------------------- +# Piece factories (built at their docked position; spawn offset applied later) +# -------------------------------------------------------------------------- + + +def make_initial_triangle(piece_id: str, endpoint: str, pal: dict) -> TensorGlyph: + """Preparation: right-pointing arrow to the right of its input, leg running left into the marker.""" + socket = socket_position(endpoint) + y = LAYOUT["dock_y"] + h = LAYOUT["tri_side"] * np.sqrt(3) / 2 + base_x = socket[0] + LAYOUT["marker_r"] + LAYOUT["leg_len"] + body = make_arrow_body(point(base_x + h / 3, y), LAYOUT["tri_side"], pal["state"], pointing_right=True) + attach = point(body.get_left()[0], y) + leg = make_wire(straight_points(attach, marker_boundary(socket, attach[0])), pal["wire"]) + marker = make_marker("in", True, socket, pal) + return TensorGlyph( + piece_id, body, "state", + markers={endpoint: marker}, legs={endpoint: leg}, + port_kinds={endpoint: PortKind.PHYSICAL_IN}, + ) + + +def make_unitary_square(piece_id: str, endpoints: list[str], pal: dict) -> TensorGlyph: + """Two-leg map: a square on the marker line, with a short straight leg to each marker.""" + sockets = {e: socket_position(e) for e in endpoints} + y = LAYOUT["dock_y"] + xs = sorted(s[0] for s in sockets.values()) + inner = (xs[1] - xs[0]) - 2 * LAYOUT["marker_r"] + side = inner - 2 * LAYOUT["leg_stub"] + cx = (xs[0] + xs[1]) / 2 + body = make_box(point(cx, y), side, side, pal["instrument"], LAYOUT["corner"]) + legs, markers = {}, {} + for endpoint, socket in sockets.items(): + direction = np.sign(socket[0] - cx) + attach = point(cx + direction * side / 2, y) + legs[endpoint] = make_wire(straight_points(attach, marker_boundary(socket, attach[0])), pal["wire"]) + markers[endpoint] = make_marker(endpoint[:-3].rstrip("["), True, socket, pal) + return TensorGlyph( + piece_id, body, "custom", markers=markers, legs=legs, + port_kinds={endpoints[0]: PortKind.PHYSICAL_OUT, endpoints[1]: PortKind.PHYSICAL_IN}, + ) + + +def make_identity_line(piece_id: str, endpoints: list[str], pal: dict) -> TensorGlyph: + """Identity: the straight wire between the output square and the input circle, and nothing else.""" + sockets = {e: socket_position(e) for e in endpoints} + ordered = sorted(endpoints, key=lambda e: sockets[e][0]) + left, right = sockets[ordered[0]], sockets[ordered[1]] + start = marker_boundary(left, right[0]) + end = marker_boundary(right, left[0]) + body = make_wire(straight_points(start, end), pal["wire"]) + markers = {e: make_marker(e[:-3].rstrip("["), True, sockets[e], pal) for e in endpoints} + return TensorGlyph( + piece_id, body, "identity", markers=markers, + port_kinds={endpoints[0]: PortKind.PHYSICAL_OUT, endpoints[1]: PortKind.PHYSICAL_IN}, + ) + + +def make_final_arrow(piece_id: str, endpoint: str, pal: dict) -> TensorGlyph: + """Open output: left-pointing arrow to the left of its marker, leg running right into the marker.""" + socket = socket_position(endpoint) + y = LAYOUT["dock_y"] + h = LAYOUT["tri_side"] * np.sqrt(3) / 2 + base_x = socket[0] - LAYOUT["marker_r"] - LAYOUT["leg_len"] + body = make_arrow_body(point(base_x - h / 3, y), LAYOUT["tri_side"], pal["adapter"], pointing_right=False) + attach = point(body.get_right()[0], y) + leg = make_wire(straight_points(attach, marker_boundary(socket, attach[0])), pal["wire"]) + marker = make_marker("out", True, socket, pal) + return TensorGlyph( + piece_id, body, "open_output", + markers={endpoint: marker}, legs={endpoint: leg}, + port_kinds={endpoint: PortKind.PHYSICAL_OUT}, + ) + + +def build_piece(entry: ProtocolEntry, pal: dict) -> TensorGlyph: + piece_id = f"piece:{entry.id}" + if entry.kind == EntryKind.PREPARE: + glyph = make_initial_triangle(piece_id, entry.endpoints[0], pal) + elif entry.kind == EntryKind.CUSTOM_MAP: + glyph = make_unitary_square(piece_id, entry.endpoints, pal) + elif entry.kind == EntryKind.OPEN_OUTPUT: + glyph = make_final_arrow(piece_id, entry.endpoints[0], pal) + else: + glyph = make_identity_line(piece_id, entry.endpoints, pal) + if DEBUG["show_ids"]: + glyph.label = debug_label(piece_id, glyph.body.get_bottom() + point(0, -0.18), pal) + return glyph + + +# -------------------------------------------------------------------------- +# Animation helpers (never call scene.play) +# -------------------------------------------------------------------------- + + +def rigid_path(mob: VMobject, offsets: list[np.ndarray], durations: list[float], rates: list[Callable]) -> UpdateFromAlphaFunc: + """Move ``mob`` rigidly through waypoints; exact final position independent of frame rate.""" + start = mob.get_center().copy() + waypoints = [start] + [start + o for o in offsets] + total = float(sum(durations)) + edges = np.concatenate([[0.0], np.cumsum(durations) / total]) + + def update(m: VMobject, alpha: float) -> None: + i = min(int(np.searchsorted(edges, alpha, side="right")) - 1, len(durations) - 1) + local = (alpha - edges[i]) / (edges[i + 1] - edges[i]) + s = rates[i](float(np.clip(local, 0.0, 1.0))) + target = waypoints[i] + (waypoints[i + 1] - waypoints[i]) * s + m.shift(target - m.get_center()) + + return UpdateFromAlphaFunc(mob, update, run_time=total, rate_func=linear) + + +def pulse_ring(centre: np.ndarray, pal: dict, run_time: float, scale: float = 1.9) -> tuple[Succession, VMobject]: + ring = Circle(radius=LAYOUT["marker_r"] * scale) + ring.set_stroke(pal["glow"], width=screen_stroke(STYLE["pulse_px"])) + ring.set_fill(opacity=0) + ring.move_to(centre) + ring.set_z_index(Z["glow"]) + anim = Succession(Create(ring, run_time=run_time / 2), Uncreate(ring, run_time=run_time / 2)) + return anim, ring + + +def draw_glyph_animations(glyph: TensorGlyph, run_time: float) -> list: + anims = [Create(glyph.body, run_time=run_time) if glyph.kind == "identity" else DrawBorderThenFill(glyph.body, run_time=run_time)] + anims += [Create(leg, run_time=run_time) for leg in glyph.legs.values()] + anims += [GrowFromCenter(m, run_time=run_time) for m in glyph.markers.values()] + if glyph.label is not None: + anims.append(Create(glyph.label, run_time=run_time)) + return anims + + +# -------------------------------------------------------------------------- +# Choreography: each plan_* returns a Beat +# -------------------------------------------------------------------------- + + +def plan_draw_tape(state: SceneState) -> Beat: + sockets = list(state.sockets.values()) + anims = [ + AnimationGroup(*[Create(p, run_time=TIMING["tape_draw"]) for p in state.tape_parts]), + AnimationGroup(*[GrowFromCenter(s, run_time=TIMING["marker_draw"]) for s in sockets], lag_ratio=0.08), + ] + return Beat("draw_tape", [Succession(*anims)], created_ids=tuple(state.sockets)) + + +def plan_draw_piece_below(state: SceneState, glyph: TensorGlyph, run_time: float) -> Beat: + glyph.group().shift(DOWN * LAYOUT["spawn_drop"]) + + def commit() -> None: + glyph.status = Status.LIVE + state.glyphs[glyph.id] = glyph + + return Beat(f"draw:{glyph.id}", draw_glyph_animations(glyph, run_time), created_ids=(glyph.id,), commit=commit) + + +def plan_snap(state: SceneState, glyph: TensorGlyph, endpoints: list[str], pal: dict) -> Beat: + """Approach a hover point just below the sockets, settle, pulse the receiving sockets.""" + hover = LAYOUT["spawn_drop"] - LAYOUT["hover_gap"] + move = rigid_path( + glyph.group(), + [UP * hover, UP * LAYOUT["spawn_drop"]], + [TIMING["snap_approach"], TIMING["snap_settle"]], + [rate_functions.ease_out_cubic, rate_functions.ease_in_out_sine], + ) + pulses, rings = [], [] + for endpoint in endpoints: + anim, ring = pulse_ring(state.socket_centres[endpoint], pal, TIMING["snap_pulse"]) + pulses.append(anim) + rings.append(ring) + + def commit() -> None: + for endpoint in endpoints: + centre = glyph.markers[endpoint].get_center() + if np.linalg.norm(centre - state.socket_centres[endpoint]) > 1e-6: + raise AssertionError(f"{glyph.id} marker {endpoint} misaligned by {centre - state.socket_centres[endpoint]}") + state.occupancy[endpoint] = glyph.id + + covered = [state.sockets[e] for e in endpoints] + return Beat( + f"snap:{glyph.id}", + [Succession(move, AnimationGroup(*pulses))], + moving_ids=(glyph.id,), + commit=commit, + discard=rings + covered, + ) + + +def plan_insert_piece(state: SceneState, entry: ProtocolEntry, glyph: TensorGlyph, pal: dict) -> Beat: + beat = plan_snap(state, glyph, entry.endpoints, pal) + snap_commit = beat.commit + + def commit() -> None: + snap_commit() + entry.piece_id = glyph.id + + beat.commit = commit + return beat + + +def make_gaussian_wave(pal: dict) -> VGroup: + """Vertical light band. Each column's opacity is a sharp Gaussian of its offset from the peak.""" + sigma = LAYOUT["wave_sigma"] + half = LAYOUT["wave_half_width"] + n = 72 + xs = np.linspace(-half, half, n) + dx = float(xs[1] - xs[0]) + inset = LAYOUT["separator_inset"] + top = LAYOUT["tape_top"] - inset + bottom = LAYOUT["tape_bottom"] + inset + columns = VGroup() + for x in xs: + amp = float(np.exp(-0.5 * (float(x) / sigma) ** 2)) + if amp < 0.02: + continue + slab = Rectangle(width=dx * 1.08, height=top - bottom, stroke_width=0) + slab.set_fill(pal["glow"], opacity=amp) + slab.move_to(point(float(x), 0.5 * (top + bottom))) + columns.add(slab) + columns.set_z_index(Z["wave"]) + return columns + + +def _hold_until_played(animation) -> None: + """Keep this animation's mobjects out of the scene until the animation itself begins. + + A non-introducer played beside the scan is added immediately, fully drawn. + """ + animation.introducer = True + + +def plan_continuous_scan(state: SceneState, pal: dict) -> Beat: + """Sweep a Gaussian light from right to left without stopping. + + When the peak reaches the first empty output, highlight the open markers, + draw the identity below them, then snap it onto those timestamps. + """ + wave = make_gaussian_wave(pal) + x0 = LAYOUT["tape_half_width"] + LAYOUT["wave_half_width"] + x1 = -x0 + wave.move_to(point(x0, wave.get_center()[1])) + state.wave, state.wave_x = wave, x0 + + gap = next(entry for entry in state.protocol if entry.kind == EntryKind.UNSPECIFIED) + trigger = max(gap.endpoints, key=lambda name: state.socket_centres[name][0]) + trigger_x = float(state.socket_centres[trigger][0]) + run_time = (x0 - x1) / TIMING["wave_speed"] + hit_time = (x0 - trigger_x) / TIMING["wave_speed"] + + glyph = make_identity_line(f"piece:{gap.id}:identity", gap.endpoints, pal) + identity_group = glyph.group() + identity_group.shift(DOWN * LAYOUT["spawn_drop"]) + move = rigid_path(wave, [point(x1 - x0, 0)], [run_time], [linear]) + hover = LAYOUT["spawn_drop"] - LAYOUT["hover_gap"] + snap = rigid_path( + identity_group, + [UP * hover, UP * LAYOUT["spawn_drop"]], + [TIMING["snap_approach"], TIMING["snap_settle"]], + [rate_functions.ease_out_cubic, rate_functions.ease_in_out_sine], + ) + _hold_until_played(snap) + pulses, pulse_rings = [], [] + highlights = [] + for endpoint in gap.endpoints: + ring = Circle(radius=LAYOUT["marker_r"] * 2.1) + ring.set_stroke(pal["glow"], width=screen_stroke(STYLE["pulse_px"])) + ring.set_fill(opacity=0) + ring.move_to(state.socket_centres[endpoint]) + ring.set_z_index(Z["glow"]) + highlights.append(ring) + anim, pulse = pulse_ring(state.socket_centres[endpoint], pal, TIMING["snap_pulse"]) + pulses.append(anim) + pulse_rings.append(pulse) + covered = [state.sockets[e] for e in gap.endpoints] + highlight = AnimationGroup(*[Create(ring, run_time=TIMING["highlight"]) for ring in highlights]) + draw = AnimationGroup(*draw_glyph_animations(glyph, TIMING["identity_draw"])) + arrive = AnimationGroup(snap, *[Uncreate(ring, run_time=TIMING["snap_approach"]) for ring in highlights]) + finish = AnimationGroup(*pulses, *[FadeOut(socket, run_time=0.12) for socket in covered]) + for step in (highlight, draw, arrive, finish): + _hold_until_played(step) + identity = Succession(Wait(hit_time), highlight, draw, arrive, finish) + _hold_until_played(identity) + + def commit() -> None: + glyph.status = Status.LIVE + state.glyphs[glyph.id] = glyph + gap.piece_id = glyph.id + gap.kind = EntryKind.DEFAULT_IDENTITY + gap.generated = True + for endpoint in gap.endpoints: + centre = glyph.markers[endpoint].get_center() + if np.linalg.norm(centre - state.socket_centres[endpoint]) > 1e-6: + raise AssertionError(f"{glyph.id} marker {endpoint} misaligned") + state.occupancy[endpoint] = glyph.id + state.transient_ids.discard("wave") + state.wave = None + state.wave_x = x1 + + return Beat( + "scan", + [move, identity], + moving_ids=("wave", glyph.id), + created_ids=(glyph.id,), + consumed_ids=("wave",), + commit=commit, + discard=highlights + pulse_rings + covered + [wave], + ) + + +# -------------------------------------------------------------------------- +# Scene +# -------------------------------------------------------------------------- + + +class InstrumentSequenceScene(Scene): + def setup(self) -> None: + self.theme = active_theme() + self.pal = PALETTES[self.theme] + self.state = SceneState() + self.poster_time: float | None = None + + # -- lifecycle -------------------------------------------------------- + + def play_beat(self, beat: Beat) -> None: + self.play_parallel_beats(beat) + + def play_parallel_beats(self, *beats: Beat) -> None: + anims = [a for beat in beats for a in beat.animations] + if anims: + self.play(*anims) + for beat in beats: + beat.commit() + if beat.discard: + self.remove(*beat.discard) + for mob in beat.discard: + mob.clear_updaters() + if DEBUG["log_beats"]: + print(f"[beat] {beat.name}: moving={list(beat.moving_ids)} consumed={list(beat.consumed_ids)} created={list(beat.created_ids)}") + self.assert_invariants() + + def checkpoint(self, name: str) -> None: + if DEBUG["save_checkpoints"]: + out = Path(config.media_dir) / "checkpoints" + out.mkdir(parents=True, exist_ok=True) + self.renderer.update_frame(self) + self.renderer.get_image().save(out / f"{SCENE_STEM}-{self.theme}-{name}.png") + if DEBUG["log_beats"]: + print(f"[checkpoint] {name}") + if DEBUG["stop_after"] == name: + raise EndSceneEarlyException() + + def scene_family_ids(self) -> set[int]: + return {id(m) for m in self.get_mobject_family_members()} + + def assert_invariants(self) -> None: + live = self.scene_family_ids() + for glyph in self.state.glyphs.values(): + if glyph.status == Status.LIVE and id(glyph.body) not in live: + raise AssertionError(f"live glyph {glyph.id} is not in the scene") + tape_drawn = bool(self.state.tape_parts) and id(self.state.tape_parts[0]) in live + for name, owner in self.state.occupancy.items(): + if owner is None: + if tape_drawn and id(self.state.sockets[name]) not in live: + raise AssertionError(f"empty socket {name} is not visible") + else: + marker = self.state.glyphs[owner].markers[name] + if np.linalg.norm(marker.get_center() - self.state.socket_centres[name]) > 1e-6: + raise AssertionError(f"socket {name} drifted from {owner}") + if id(self.state.sockets[name]) in live: + raise AssertionError(f"covered hollow socket {name} still drawn") + + def assert_counts(self, occupied: int, empty: int, missing_gaps: int) -> None: + occ = sum(v is not None for v in self.state.occupancy.values()) + emp = sum(v is None for v in self.state.occupancy.values()) + gaps = sum(e.kind == EntryKind.UNSPECIFIED for e in self.state.protocol) + if (occ, emp, gaps) != (occupied, empty, missing_gaps): + raise AssertionError(f"expected occupancy {(occupied, empty, missing_gaps)}, got {(occ, emp, gaps)}") + + def mark_poster(self) -> None: + self.poster_time = self.renderer.time + EXPORT["poster_offset"] + + def reset_loop(self) -> None: + mobs = list(self.mobjects) + if mobs: + self.play(*[FadeOut(m, scale=0.96) for m in mobs], run_time=TIMING["loop_fade"]) + for m in mobs: + m.clear_updaters() + self.clear() + self.state = SceneState() + if self.mobjects: + raise AssertionError("reset_loop left mobjects in the scene") + self.wait(TIMING["blank_hold"]) + + # -- story ------------------------------------------------------------ + + def construct(self) -> None: + state, pal = self.state, self.pal + make_tape(state, pal) + state.protocol = make_protocol_positions() + self.wait(TIMING["blank_hold"]) + + self.play_beat(plan_draw_tape(state)) + for entry in state.protocol: + if entry.kind == EntryKind.UNSPECIFIED: + continue + glyph = build_piece(entry, pal) + self.play_beat(plan_draw_piece_below(state, glyph, TIMING["piece_draw"])) + self.play_beat(plan_insert_piece(state, entry, glyph, pal)) + self.assert_counts(occupied=4, empty=2, missing_gaps=1) + self.checkpoint("sequence_before_defaults") + self.wait(TIMING["notice_hold"]) + + self.scan_and_fill_defaults() + self.assert_counts(occupied=6, empty=0, missing_gaps=0) + final = next(e for e in state.protocol if e.kind == EntryKind.OPEN_OUTPUT) + final_glyph = state.glyphs[final.piece_id] + if final.endpoints[0] not in final_glyph.legs: + raise AssertionError("open output lost the leg on its right") + self.checkpoint("sequence_after_defaults") + + self.mark_poster() + self.wait(TIMING["result_hold"]) + self.reset_loop() + + def scan_and_fill_defaults(self) -> None: + self.play_beat(plan_continuous_scan(self.state, self.pal)) + + +# -------------------------------------------------------------------------- +# Render entry point +# -------------------------------------------------------------------------- + + +def vertical_content_window(movie: Path, width: int, height: int) -> tuple[int, int]: + """Return the top row and height of a crop that keeps every opaque pixel. + + Rows that stay empty for the whole clip are dropped. A 22px margin at a + 540px-tall frame (scaled with height) is kept, and the window is expanded + outward to an even height. The horizontal extent is never changed. + """ + raw = subprocess.check_output( + [ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(movie), + "-vf", "format=gbrap,unpremultiply=inplace=1:planes=7,alphaextract,format=gray", + "-f", "rawvideo", "-pix_fmt", "gray", "-", + ] + ) + frame = width * height + n = len(raw) // frame + if n == 0: + return 0, height + alpha = np.frombuffer(raw[: n * frame], dtype=np.uint8).reshape(n, height, width) + rows = np.where((alpha > 8).any(axis=(0, 2)))[0] + if rows.size == 0: + return 0, height + top, bot = int(rows.min()), int(rows.max()) + pad = max(8, round(22 * height / 540)) + y0 = top - pad + y1 = bot + pad + if (y1 - y0 + 1) % 2: + if y0 > 0: + y0 -= 1 + else: + y1 += 1 + y0 = max(0, y0) + y1 = min(height - 1, y1) + if y0 > top or y1 < bot: + raise AssertionError(f"vertical crop would cut content: y={top}-{bot}, window={y0}-{y1}") + return y0, y1 - y0 + 1 + + +def encode_outputs(movie: Path, out_dir: Path, stem: str, theme: str, poster_time: float | None, fps: int, size: tuple[int, int], preset: str) -> None: + out_dir.mkdir(parents=True, exist_ok=True) + ff = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"] + # Cairo renders premultiplied RGBA but Manim writes it as straight alpha; + # without this, fades and antialiased edges darken. + unpremultiply = "format=gbrap,unpremultiply=inplace=1:planes=7" + y0, crop_h = vertical_content_window(movie, size[0], size[1]) + crop = "" if y0 == 0 and crop_h == size[1] else f"crop={size[0]}:{crop_h}:0:{y0}," + if preset == "final": + gif_filter = ( + f"{unpremultiply},{crop}fps={fps},split[a][b];[a]palettegen=reserve_transparent=1:stats_mode=full[p];" + f"[b][p]paletteuse=dither={EXPORT['gif_dither']}:alpha_threshold={EXPORT['gif_alpha_threshold']}" + ) + subprocess.run([*ff, "-i", str(movie), "-vf", gif_filter, "-loop", "0", str(out_dir / f"{stem}.gif")], check=True) + else: + background = EXPORT["mp4_background"][theme] + subprocess.run( + [*ff, "-f", "lavfi", "-i", f"color=c={background}:s={size[0]}x{crop_h}:r={fps}", "-i", str(movie), + "-filter_complex", f"[1]{unpremultiply}{',' + crop.rstrip(',') if crop else ''}[fg];[0][fg]overlay=shortest=1,format=yuv420p", "-c:v", "libx264", + "-crf", "18", "-movflags", "+faststart", str(out_dir / f"{stem}.mp4")], + check=True, + ) + if poster_time is not None: + subprocess.run( + [*ff, "-ss", f"{poster_time:.3f}", "-i", str(movie), "-vf", f"{unpremultiply},{crop}format=rgba", + "-frames:v", "1", str(out_dir / f"{stem}-poster.png")], + check=True, + ) + + +def render_assets(scene_cls: type[Scene] = InstrumentSequenceScene) -> None: + theme = active_theme() + preset = os.environ.get("PT_ANIM_PRESET", "final").strip().lower() + spec = EXPORT[preset] + repo = Path(__file__).resolve().parents[2] + media = repo / "docs" / "animations" / "media" + out_dir = repo / "docs" / "src" / "assets" / "animations" if preset == "final" else media / "drafts" + stem = f"{SCENE_STEM}-{theme}" + aspect = spec["pixel_width"] / spec["pixel_height"] + with tempconfig({ + "pixel_width": spec["pixel_width"], + "pixel_height": spec["pixel_height"], + "frame_rate": spec["fps"], + "frame_height": LAYOUT["frame_height"], + "frame_width": LAYOUT["frame_height"] * aspect, + "background_opacity": 0.0, + "format": "mov", + "media_dir": str(media), + "output_file": stem, + "disable_caching": True, + "verbosity": "WARNING", + "progress_bar": "none", + }): + scene = scene_cls() + scene.render() + movie = Path(scene.renderer.file_writer.movie_file_path) + encode_outputs(movie, out_dir, stem, theme, scene.poster_time, spec["fps"], (spec["pixel_width"], spec["pixel_height"]), preset) + print(f"[export] {stem}: {out_dir}") + + +if __name__ == "__main__": + render_assets() diff --git a/docs/animations/pt_contraction.py b/docs/animations/pt_contraction.py new file mode 100644 index 0000000..2241cc5 --- /dev/null +++ b/docs/animations/pt_contraction.py @@ -0,0 +1,1193 @@ +# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors +# SPDX-License-Identifier: MIT +# +# File: docs/animations/pt_contraction.py +# Contributor: Gauthameshwar S. +# +# Generates the schematic two-panel process-tensor contraction animation: one +# panel ends as an open reduced state, the other as a closed scalar. +# +# Run with: +# PT_ANIM_THEME=light docs/animations/.manim_env/bin/python docs/animations/pt_contraction.py +"""Process-tensor contraction animation for the ProcessTensors.jl homepage and README. + +Schematic only: no package calls, no numerics. Two identical three-core process +tensors sit side by side; time runs right to left. Both receive the same initial +state and identities. The LEFT panel does not contract its last leg: the remaining +square becomes a right-facing triangle and that leg flattens into a wire pointing +left. The RIGHT panel finishes with an effect and settles into a legless scalar. + +Tested with Manim Community v0.21.0 (Cairo renderer), Python 3.12, ffmpeg 8.0.1. + +Render from the repository root:: + + PT_ANIM_THEME=light docs/animations/.manim_env/bin/python docs/animations/pt_contraction.py + PT_ANIM_THEME=dark docs/animations/.manim_env/bin/python docs/animations/pt_contraction.py + +``PT_ANIM_PRESET=draft`` writes an MP4 and a poster PNG into +``docs/animations/media/drafts/``. The final preset writes a transparent GIF +and a transparent poster PNG into ``docs/src/assets/animations/``. +""" + +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Callable + +import numpy as np +from manim import ( + DOWN, + LEFT, + UP, + AnimationGroup, + CapStyleType, + Circle, + Create, + DrawBorderThenFill, + FadeOut, + GrowFromCenter, + MovingCameraScene, + Polygon, + ReplacementTransform, + RoundedRectangle, + Scene, + Succession, + Text, + Uncreate, + UpdateFromAlphaFunc, + VGroup, + VMobject, + config, + linear, + rate_functions, + smooth, + tempconfig, +) +from manim.utils.exceptions import EndSceneEarlyException + +# -------------------------------------------------------------------------- +# Settings +# -------------------------------------------------------------------------- + +SCENE_STEM = "contraction" + +STYLE = { + "outline_px": 5.0, + "wire_px": 4.5, + "marker_px": 4.5, + "pulse_px": 4.0, + "emphasis_px": 7.5, + "reference_pixel_width": 1280, +} + +PALETTES = { + "light": { + "outline": "#2b2b2b", + "wire": "#3a3a3a", + "input": "#138a8a", + "output": "#c27c0e", + "pt": ("#d4ead4", "#217a3e"), + "accumulator": ("#d9c6e8", "#5b2f78"), + "identity": ("#d5e2f0", "#4a6f96"), + "state": ("#c8ecea", "#138a8a"), + "adapter": ("#fdebc8", "#c27c0e"), + "effect": ("#f7d2c4", "#c0533a"), + "scalar": ("#fbe7a1", "#a87a00"), + "glow": "#f2a900", + "debug": "#7a7a7a", + }, + "dark": { + "outline": "#e6e6e6", + "wire": "#cfcfcf", + "input": "#4fd1c5", + "output": "#f2b544", + "pt": ("#1e6b38", "#8fd9a0"), + "accumulator": ("#5e3577", "#e9cdf7"), + "identity": ("#345a80", "#a9c8ea"), + "state": ("#1f7a76", "#8ff0e6"), + "adapter": ("#8a5f12", "#f2b544"), + "effect": ("#a84e36", "#ffb59e"), + "scalar": ("#9a7414", "#ffe08a"), + "glow": "#ffd166", + "debug": "#9e9e9e", + }, +} + +TIMING = { + "blank_hold": 0.15, + "diagram_draw": 0.6, + "piece_draw": 0.45, + "snap_approach": 0.35, + "snap_settle": 0.12, + "snap_pulse": 0.28, + "emphasis": 0.22, + "retract": 0.32, + "merge": 0.68, + "result_settle": 0.75, + "zoom": 0.85, + "beat_gap": 0.12, + "result_hold": 1.8, + "loop_fade": 0.4, +} + +LAYOUT = { + "frame_height": 4.5, + "panel_origins": {"left": (-2.55, 0.0), "right": (2.55, 0.0)}, + "n_steps": 3, + "pitch": 1.55, + "x_right": 1.55, + "row_y": 0.55, + "core_side": 0.42, + "corner": 0.08, + "acc_side": 0.5, + "socket_drop": 0.5, + "marker_r": 0.075, + "spawn_drop": 0.48, + "hover_gap": 0.06, + "tri_side": 0.4, + "leg_len": 0.18, + "open_stub": 0.46, + "result_gap": 1.15, + "result_tri_side": 0.52, + "result_circle_r": 0.26, +} + +DEBUG = { + "show_ids": False, + "show_port_names": False, + "show_bounds": False, + "highlight_moving": False, + "log_beats": True, + "save_checkpoints": False, + "stop_after": None, +} + +EXPORT = { + "final": {"pixel_width": 1280, "pixel_height": 560, "fps": 24}, + "draft": {"pixel_width": 640, "pixel_height": 280, "fps": 12}, + "gif_dither": "sierra2_4a", + "gif_alpha_threshold": 128, + "mp4_background": {"light": "#ffffff", "dark": "#1f2424"}, + "poster_offset": 0.5, +} + +Z = {"wire": 1, "emphasis": 1.5, "body": 2, "marker": 3, "glow": 4, "debug": 5} + + +def active_theme() -> str: + theme = os.environ.get("PT_ANIM_THEME", "light").strip().lower() + if theme not in PALETTES: + raise ValueError(f"PT_ANIM_THEME must be one of {sorted(PALETTES)}, got {theme!r}") + return theme + + +def screen_stroke(px: float) -> float: + """Manim stroke width that renders as ``px`` pixels at the reference export width.""" + return px * config.frame_width / (STYLE["reference_pixel_width"] * 0.01) + + +# -------------------------------------------------------------------------- +# Small local records +# -------------------------------------------------------------------------- + + +class Status(Enum): + HIDDEN = "hidden" + LIVE = "live" + CONSUMED = "consumed" + + +class PortKind(Enum): + PHYSICAL_IN = "physical_in" + PHYSICAL_OUT = "physical_out" + VIRTUAL = "virtual" + OPEN_RESULT = "open_result" + + +class Route(Enum): + STRAIGHT = "straight" + DOWN_SOCKET = "down_socket" + PIECE_LEG = "piece_leg" + TAIL = "tail" + + +@dataclass(frozen=True) +class PortRef: + owner_id: str + port_name: str + semantic_kind: PortKind + + +@dataclass +class TensorGlyph: + id: str + body: VMobject + kind: str + port_specs: dict[str, np.ndarray] = field(default_factory=dict) + markers: dict[str, VMobject] = field(default_factory=dict) + label: VMobject | None = None + status: Status = Status.HIDDEN + + def port(self, name: str) -> np.ndarray: + return self.body.get_center() + self.port_specs[name] + + +@dataclass +class WireRecord: + id: str + a: PortRef + b: PortRef + path: VMobject + route_kind: Route + status: Status = Status.HIDDEN + + +@dataclass +class SceneState: + panel_id: str + origin: np.ndarray + terminal_kind: str + glyphs: dict[str, TensorGlyph] = field(default_factory=dict) + wires: dict[str, WireRecord] = field(default_factory=dict) + sockets: dict[str, VMobject] = field(default_factory=dict) + socket_centres: dict[str, np.ndarray] = field(default_factory=dict) + socket_status: dict[str, str] = field(default_factory=dict) + accumulator_id: str | None = None + piece_parts: dict[str, list[VMobject]] = field(default_factory=dict) + transient_ids: set[str] = field(default_factory=set) + + +@dataclass +class Beat: + name: str + animations: list + moving_ids: tuple[str, ...] = () + consumed_ids: tuple[str, ...] = () + created_ids: tuple[str, ...] = () + commit: Callable[[], None] = lambda: None + prepare: Callable[[Scene], None] = lambda scene: None + finish: Callable[[Scene], None] = lambda scene: None + discard: list[VMobject] = field(default_factory=list) + + +# -------------------------------------------------------------------------- +# Pure geometry factories +# -------------------------------------------------------------------------- + + +def point(x: float, y: float) -> np.ndarray: + return np.array([x, y, 0.0]) + + +def split_cubic(p0, p1, p2, p3) -> np.ndarray: + """One cubic as two cubic halves (8 control points) so every wire interpolates.""" + a, b, c = (p0 + p1) / 2, (p1 + p2) / 2, (p2 + p3) / 2 + d, e = (a + b) / 2, (b + c) / 2 + m = (d + e) / 2 + return np.array([p0, a, d, m, m, e, c, p3]) + + +def route_points(route: Route, a: np.ndarray, b: np.ndarray) -> np.ndarray: + if route == Route.DOWN_SOCKET: + # Quarter-ellipse: horizontal tangent on the core, vertical tangent on the marker. + kappa = 0.551915024494 + dx, dy = b[0] - a[0], b[1] - a[1] + return split_cubic(a, a + point(kappa * dx, 0.0), b - point(0.0, kappa * dy), b) + return split_cubic(a, a + (b - a) / 3, a + 2 * (b - a) / 3, b) + + +def make_wire_path(points: np.ndarray, color: str) -> VMobject: + wire = VMobject() + wire.set_points(points) + wire.set_fill(opacity=0) + wire.set_stroke(color, width=screen_stroke(STYLE["wire_px"])) + wire.set_cap_style(CapStyleType.ROUND) + wire.set_z_index(Z["wire"]) + return wire + + +def make_marker(role: str, filled: bool, position: np.ndarray, pal: dict) -> VMobject: + """Input sockets are teal circles, output sockets amber rounded squares, everywhere.""" + r = LAYOUT["marker_r"] + color = pal["input"] if role == "in" else pal["output"] + if role == "in": + marker = Circle(radius=r) + else: + marker = RoundedRectangle(width=2 * r, height=2 * r, corner_radius=0.35 * r) + marker.set_stroke(color, width=screen_stroke(STYLE["marker_px"])) + marker.set_fill(color, opacity=1.0 if filled else 0.0) + marker.move_to(position) + marker.set_z_index(Z["marker"]) + return marker + + +def make_box(center: np.ndarray, w: float, h: float, colors: tuple[str, str], corner: float) -> VMobject: + box = RoundedRectangle(width=w, height=h, corner_radius=corner) + box.set_fill(colors[0], opacity=1.0) + box.set_stroke(colors[1], width=screen_stroke(STYLE["outline_px"])) + box.move_to(center) + box.set_z_index(Z["body"]) + return box + + +def make_arrow_body(center: np.ndarray, side: float, colors: tuple[str, str], pointing_right: bool) -> VMobject: + """Equilateral arrow. The leg meets the vertical base, opposite the apex.""" + h = side * np.sqrt(3) / 2 + sign = 1.0 if pointing_right else -1.0 + tri = Polygon( + center + point(sign * 2 * h / 3, 0), + center + point(-sign * h / 3, side / 2), + center + point(-sign * h / 3, -side / 2), + ) + tri.round_corners(radius=0.12 * side) + tri.set_fill(colors[0], opacity=1.0) + tri.set_stroke(colors[1], width=screen_stroke(STYLE["outline_px"])) + tri.set_z_index(Z["body"]) + return tri + + +def make_scalar_circle(center: np.ndarray, colors: tuple[str, str]) -> VMobject: + circle = Circle(radius=LAYOUT["result_circle_r"]) + circle.set_fill(colors[0], opacity=1.0) + circle.set_stroke(colors[1], width=screen_stroke(STYLE["outline_px"])) + circle.move_to(center) + circle.set_z_index(Z["body"]) + return circle + + +def debug_label(text: str, position: np.ndarray, pal: dict) -> VMobject: + label = Text(text, font_size=12, color=pal["debug"]) + label.move_to(position) + label.set_z_index(Z["debug"]) + return label + + +# -------------------------------------------------------------------------- +# Panel geometry (local coordinates shifted by the panel origin) +# -------------------------------------------------------------------------- + + +def core_x(state: SceneState, k: int) -> float: + return state.origin[0] + LAYOUT["x_right"] - k * LAYOUT["pitch"] + + +def row_y(state: SceneState) -> float: + return state.origin[1] + LAYOUT["row_y"] + + +def socket_y(state: SceneState) -> float: + return row_y(state) - LAYOUT["socket_drop"] + + +def socket_centre(state: SceneState, name: str) -> np.ndarray: + """Markers sit a quarter-pitch either side of each core, so every gap matches.""" + role, k = name.split("[")[0], int(name[-2]) + sign = 1.0 if role == "in" else -1.0 + return point(core_x(state, k) + sign * LAYOUT["pitch"] / 4, socket_y(state)) + + +def marker_edge(centre: np.ndarray, toward: np.ndarray) -> np.ndarray: + """Point where a straight horizontal leg, coming from ``toward``, meets the marker.""" + sign = 1.0 if toward[0] >= centre[0] else -1.0 + return point(centre[0] + sign * LAYOUT["marker_r"], centre[1]) + + +def core_port_specs(w: float, h: float) -> dict[str, np.ndarray]: + """Physical legs leave the side edges three quarters of the way down; memory bonds leave mid-side.""" + return { + "out": point(-w / 2, -h / 4), + "in": point(w / 2, -h / 4), + "mem_w": point(-w / 2, 0), + "mem_e": point(w / 2, 0), + } + + +def port_position(state: SceneState, ref: PortRef) -> np.ndarray: + if ref.owner_id == "socket": + return state.socket_centres[ref.port_name] + return state.glyphs[ref.owner_id].port(ref.port_name) + + +def leg_end(state: SceneState, ref: PortRef, route: Route) -> np.ndarray: + pos = port_position(state, ref) + if route == Route.DOWN_SOCKET and ref.owner_id == "socket": + return pos + UP * LAYOUT["marker_r"] + return pos + + +def wire_points(state: SceneState, wire: WireRecord) -> np.ndarray: + return route_points(wire.route_kind, leg_end(state, wire.a, wire.route_kind), leg_end(state, wire.b, wire.route_kind)) + + +def register_wire(state: SceneState, wire_id: str, a: PortRef, b: PortRef, route: Route, pal: dict) -> WireRecord: + record = WireRecord(wire_id, a, b, make_wire_path(np.zeros((8, 3)), pal["wire"]), route) + record.path.set_points(wire_points(state, record)) + state.wires[wire_id] = record + return record + + +def make_pt_panel(panel_id: str, origin: tuple[float, float], terminal_kind: str, pal: dict) -> SceneState: + """Three cores, two memory bonds, six downward physical legs, six hollow sockets.""" + state = SceneState(panel_id, point(*origin), terminal_kind) + s = LAYOUT["core_side"] + for k in range(LAYOUT["n_steps"]): + gid = f"pt:k{k}" + body = make_box(point(core_x(state, k), row_y(state)), s, s, pal["pt"], LAYOUT["corner"]) + state.glyphs[gid] = TensorGlyph(gid, body, "pt_core", core_port_specs(s, s)) + if DEBUG["show_ids"]: + state.glyphs[gid].label = debug_label(f"{panel_id}:{gid}", body.get_top() + UP * 0.15, pal) + for role in ("out", "in"): + name = f"{role}[{k}]" + centre = socket_centre(state, name) + state.socket_centres[name] = centre + state.sockets[name] = make_marker(role, False, centre, pal) + state.socket_status[name] = "empty" + kind = PortKind.PHYSICAL_OUT if role == "out" else PortKind.PHYSICAL_IN + register_wire(state, f"leg:{name}", PortRef(gid, role, kind), PortRef("socket", name, kind), Route.DOWN_SOCKET, pal) + for k in range(LAYOUT["n_steps"] - 1): + register_wire( + state, f"mem:{k}", + PortRef(f"pt:k{k}", "mem_w", PortKind.VIRTUAL), PortRef(f"pt:k{k + 1}", "mem_e", PortKind.VIRTUAL), + Route.STRAIGHT, pal, + ) + return state + + +# -------------------------------------------------------------------------- +# Piece factories (built docked; the spawn offset is applied when drawn) +# -------------------------------------------------------------------------- + + +@dataclass +class Piece: + glyph: TensorGlyph + legs: dict[str, VMobject] + endpoints: list[str] + tail: VMobject | None = None + + def group(self) -> VGroup: + parts = [self.glyph.body, *self.legs.values(), *self.glyph.markers.values()] + if self.tail is not None: + parts.append(self.tail) + if self.glyph.label is not None: + parts.append(self.glyph.label) + return VGroup(*parts) + + +def make_panel_initial_state(state: SceneState, pal: dict) -> Piece: + """Preparation: right-pointing arrow to the right of its input, leg running left into the marker.""" + socket = state.socket_centres["in[0]"] + y = float(socket[1]) + h = LAYOUT["tri_side"] * np.sqrt(3) / 2 + base_x = socket[0] + LAYOUT["marker_r"] + LAYOUT["leg_len"] + body = make_arrow_body(point(base_x + h / 3, y), LAYOUT["tri_side"], pal["state"], pointing_right=True) + attach = point(body.get_left()[0], y) + leg = make_wire_path(route_points(Route.STRAIGHT, attach, marker_edge(socket, attach)), pal["wire"]) + glyph = TensorGlyph("state:init", body, "state", markers={"in[0]": make_marker("in", True, socket, pal)}) + return Piece(glyph, {"in[0]": leg}, ["in[0]"]) + + +def make_identity_piece(state: SceneState, gap_k: int, pal: dict) -> Piece: + """Identity: the straight wire between the output square and the input circle.""" + endpoints = [f"out[{gap_k}]", f"in[{gap_k + 1}]"] + sockets = {e: state.socket_centres[e] for e in endpoints} + ordered = sorted(endpoints, key=lambda e: sockets[e][0]) + left, right = sockets[ordered[0]], sockets[ordered[1]] + body = make_wire_path(route_points(Route.STRAIGHT, marker_edge(left, right), marker_edge(right, left)), pal["wire"]) + markers = {e: make_marker(e.split("[")[0], True, sockets[e], pal) for e in endpoints} + glyph = TensorGlyph(f"identity:{gap_k}", body, "identity", markers=markers) + return Piece(glyph, {}, endpoints) + + +def make_effect_piece(state: SceneState, pal: dict) -> Piece: + """Closing effect: left-pointing arrow to the left of the last output, leg running right into the marker.""" + socket = state.socket_centres["out[2]"] + y = float(socket[1]) + h = LAYOUT["tri_side"] * np.sqrt(3) / 2 + base_x = socket[0] - LAYOUT["marker_r"] - LAYOUT["leg_len"] + body = make_arrow_body(point(base_x - h / 3, y), LAYOUT["tri_side"], pal["effect"], pointing_right=False) + attach = point(body.get_right()[0], y) + leg = make_wire_path(route_points(Route.STRAIGHT, attach, marker_edge(socket, attach)), pal["wire"]) + glyph = TensorGlyph("effect:out", body, "effect", markers={"out[2]": make_marker("out", True, socket, pal)}) + return Piece(glyph, {"out[2]": leg}, ["out[2]"]) + + +# -------------------------------------------------------------------------- +# Animation helpers (never call scene.play) +# -------------------------------------------------------------------------- + + +def rigid_path(mob: VMobject, offsets: list[np.ndarray], durations: list[float], rates: list[Callable]) -> UpdateFromAlphaFunc: + """Move ``mob`` rigidly through waypoints; exact final position independent of frame rate.""" + start = mob.get_center().copy() + waypoints = [start] + [start + o for o in offsets] + total = float(sum(durations)) + edges = np.concatenate([[0.0], np.cumsum(durations) / total]) + + def update(m: VMobject, alpha: float) -> None: + i = min(int(np.searchsorted(edges, alpha, side="right")) - 1, len(durations) - 1) + local = (alpha - edges[i]) / (edges[i + 1] - edges[i]) + s = rates[i](float(np.clip(local, 0.0, 1.0))) + m.shift(waypoints[i] + (waypoints[i + 1] - waypoints[i]) * s - m.get_center()) + + return UpdateFromAlphaFunc(mob, update, run_time=total, rate_func=linear) + + +def retract(mob: VMobject, toward: str, run_time: float) -> UpdateFromAlphaFunc: + """Consumed connection writes out towards ``start``, ``end`` or its ``middle``.""" + original = mob.copy() + + def update(m: VMobject, alpha: float) -> None: + a = float(np.clip(alpha, 0.0, 1.0)) + if toward == "start": + lo, hi = 0.0, 1.0 - a + elif toward == "end": + lo, hi = a, 1.0 + else: + lo, hi = a / 2, 1.0 - a / 2 + m.pointwise_become_partial(original, lo, max(hi, lo)) + m.set_stroke(opacity=0.0 if a > 0.92 else 1.0) + + return UpdateFromAlphaFunc(mob, update, run_time=run_time, rate_func=smooth) + + +def shrink_out(mob: VMobject, run_time: float) -> UpdateFromAlphaFunc: + original = mob.copy() + centre = mob.get_center().copy() + + def update(m: VMobject, alpha: float) -> None: + scale = max(1.0 - alpha, 1e-3) + m.become(original.copy().scale(scale, about_point=centre)) + if alpha > 0.92: + m.set_stroke(opacity=0.0).set_fill(opacity=0.0) + + return UpdateFromAlphaFunc(mob, update, run_time=run_time, rate_func=smooth) + + +def interpolate_path(mob: VMobject, start: np.ndarray, end: np.ndarray, run_time: float) -> UpdateFromAlphaFunc: + def update(m: VMobject, alpha: float) -> None: + m.set_points(start + (end - start) * alpha) + + return UpdateFromAlphaFunc(mob, update, run_time=run_time, rate_func=smooth) + + +def emphasis_overlay(path: VMobject, pal: dict) -> VMobject: + overlay = path.copy() + overlay.set_stroke(pal["glow"], width=screen_stroke(STYLE["emphasis_px"]), opacity=1.0) + overlay.set_z_index(Z["emphasis"]) + return overlay + + +def pulse_ring(centre: np.ndarray, pal: dict, run_time: float) -> tuple[Succession, VMobject]: + ring = Circle(radius=LAYOUT["marker_r"] * 1.9) + ring.set_stroke(pal["glow"], width=screen_stroke(STYLE["pulse_px"])) + ring.set_fill(opacity=0) + ring.move_to(centre) + ring.set_z_index(Z["glow"]) + return Succession(Create(ring, run_time=run_time / 2), Uncreate(ring, run_time=run_time / 2)), ring + + +# -------------------------------------------------------------------------- +# Choreography: each plan_* returns a Beat for one panel +# -------------------------------------------------------------------------- + + +def plan_draw_pt(state: SceneState) -> Beat: + t = TIMING["diagram_draw"] + bodies = [DrawBorderThenFill(g.body, run_time=t) for g in state.glyphs.values()] + labels = [Create(g.label, run_time=t) for g in state.glyphs.values() if g.label is not None] + wires = [Create(w.path, run_time=t) for w in state.wires.values()] + sockets = [GrowFromCenter(m, run_time=0.4) for m in state.sockets.values()] + + def commit() -> None: + for g in state.glyphs.values(): + g.status = Status.LIVE + for w in state.wires.values(): + w.status = Status.LIVE + + return Beat( + f"{state.panel_id}:draw_pt", + [Succession(AnimationGroup(*bodies, *labels, *wires), AnimationGroup(*sockets, lag_ratio=0.05))], + created_ids=tuple(state.glyphs) + tuple(state.wires), + commit=commit, + ) + + +def plan_dock_piece(state: SceneState, piece: Piece, pal: dict, name: str) -> Beat: + """Draw below the docking row, approach a hover point, settle exactly, pulse the sockets.""" + group = piece.group() + group.shift(DOWN * LAYOUT["spawn_drop"]) + t = TIMING["piece_draw"] + draw = [Create(piece.glyph.body, run_time=t) if piece.glyph.kind == "identity" else DrawBorderThenFill(piece.glyph.body, run_time=t)] + draw += [Create(leg, run_time=t) for leg in piece.legs.values()] + draw += [GrowFromCenter(m, run_time=t) for m in piece.glyph.markers.values()] + if piece.tail is not None: + draw.append(Create(piece.tail, run_time=t)) + if piece.glyph.label is not None: + draw.append(Create(piece.glyph.label, run_time=t)) + hover = LAYOUT["spawn_drop"] - LAYOUT["hover_gap"] + move = rigid_path( + group, + [UP * hover, UP * LAYOUT["spawn_drop"]], + [TIMING["snap_approach"], TIMING["snap_settle"]], + [rate_functions.ease_out_cubic, rate_functions.ease_in_out_sine], + ) + pulses, rings = [], [] + for endpoint in piece.endpoints: + anim, ring = pulse_ring(state.socket_centres[endpoint], pal, TIMING["snap_pulse"]) + pulses.append(anim) + rings.append(ring) + + def commit() -> None: + for endpoint in piece.endpoints: + offset = piece.glyph.markers[endpoint].get_center() - state.socket_centres[endpoint] + if np.linalg.norm(offset) > 1e-6: + raise AssertionError(f"{state.panel_id}:{piece.glyph.id} misaligned at {endpoint}: {offset}") + state.socket_status[endpoint] = piece.glyph.id + piece.glyph.status = Status.LIVE + state.glyphs[piece.glyph.id] = piece.glyph + state.piece_parts[piece.glyph.id] = [*piece.legs.values(), *([piece.tail] if piece.tail else [])] + state.transient_ids.add(piece.glyph.id) + + return Beat( + f"{state.panel_id}:{name}", + [Succession(AnimationGroup(*draw), move, AnimationGroup(*pulses))], + moving_ids=(piece.glyph.id,), + created_ids=(piece.glyph.id,), + commit=commit, + discard=rings + [state.sockets[e] for e in piece.endpoints], + ) + + +def plan_merge( + state: SceneState, + name: str, + source_ids: list[str], + consumed_wire_ids: list[tuple[str, str]], + pieces: list[Piece], + target: TensorGlyph, + external_port_map: dict[str, tuple[str, PortRef]], + pal: dict, + extra_survivors: list[tuple[VMobject, np.ndarray]] | None = None, + merge_time: float | None = None, +) -> Beat: + """Local contraction: emphasise and write out internal edges, then one composite becomes the target. + + ``consumed_wire_ids`` pairs a registered wire with its retraction direction; + piece legs and docked markers are consumed implicitly. Surviving registered + wires listed in ``external_port_map`` are re-attached to ``target`` ports in + the same beat via explicit start/end path interpolation. + """ + merge_time = merge_time or TIMING["merge"] + consumed_paths = [(state.wires[w].path, toward) for w, toward in consumed_wire_ids] + for piece in pieces: + consumed_paths += [(leg, "start") for leg in piece.legs.values()] + consumed_markers = [m for piece in pieces for m in piece.glyph.markers.values()] + consumed_markers += [state.sockets[e] for piece in pieces for e in piece.endpoints] + for wid, _ in consumed_wire_ids: + b = state.wires[wid].b + if b.owner_id == "socket" and state.socket_status[b.port_name] == "empty": + raise AssertionError(f"{wid} consumed while its socket is still open") + + overlays = [emphasis_overlay(path, pal) for path, _ in consumed_paths] + emphasis = AnimationGroup(*[Create(o, run_time=TIMING["emphasis"]) for o in overlays]) + write_out = AnimationGroup( + *[retract(path, toward, TIMING["retract"]) for path, toward in consumed_paths], + *[retract(o, toward, TIMING["retract"]) for o, (_, toward) in zip(overlays, consumed_paths)], + *[shrink_out(m, TIMING["retract"]) for m in consumed_markers], + ) + + # One coincident target copy per source body: every body visibly slides and + # morphs onto the target (a bare group-to-single transform pads with invisible + # copies). The copies are swapped for the single real target after play. + composite = VGroup(*[state.glyphs[g].body for g in source_ids]) + target_copies = VGroup(*[target.body.copy() for _ in source_ids]) + survivors = [] + for wid, (end, ref) in external_port_map.items(): + wire = state.wires[wid] + start_pts = wire.path.points.copy() + a, b = (ref, wire.b) if end == "a" else (wire.a, ref) + pa = target.port(a.port_name) if a.owner_id == target.id else leg_end(state, a, wire.route_kind) + pb = target.port(b.port_name) if b.owner_id == target.id else leg_end(state, b, wire.route_kind) + survivors.append(interpolate_path(wire.path, start_pts, route_points(wire.route_kind, pa, pb), merge_time)) + for path, end_pts in extra_survivors or []: + survivors.append(interpolate_path(path, path.points.copy(), end_pts, merge_time)) + merge = AnimationGroup(ReplacementTransform(composite, target_copies, run_time=merge_time), *survivors) + + fixed = { + gid: g.body.get_center().copy() + for gid, g in state.glyphs.items() + if g.status == Status.LIVE and gid not in source_ids + } + + def prepare(scene: Scene) -> None: + scene.remove(*composite.submobjects) + scene.add(composite) + + def finish(scene: Scene) -> None: + scene.remove(target_copies, composite) + scene.add(target.body) + + def commit() -> None: + for gid in source_ids: + state.glyphs[gid].status = Status.CONSUMED + state.transient_ids.discard(gid) + state.piece_parts.pop(gid, None) + for wid, _ in consumed_wire_ids: + state.wires[wid].status = Status.CONSUMED + for piece in pieces: + for e in piece.endpoints: + state.socket_status[e] = "contracted" + for wid, (end, ref) in external_port_map.items(): + wire = state.wires[wid] + if end == "a": + wire.a = ref + else: + wire.b = ref + target.status = Status.LIVE + state.glyphs[target.id] = target + state.accumulator_id = target.id + for gid, centre in fixed.items(): + if np.linalg.norm(state.glyphs[gid].body.get_center() - centre) > 1e-6: + raise AssertionError(f"untouched glyph {gid} moved during {name}") + + discard = [p for p, _ in consumed_paths] + overlays + consumed_markers + [composite] + discard += [m for gid in source_ids for m in state.glyphs[gid].markers.values()] + return Beat( + f"{state.panel_id}:{name}", + [Succession(emphasis, write_out, merge)], + moving_ids=tuple(source_ids) + tuple(external_port_map), + consumed_ids=tuple(source_ids) + tuple(w for w, _ in consumed_wire_ids), + created_ids=(target.id,), + commit=commit, + prepare=prepare, + finish=finish, + discard=discard, + ) + + +def make_accumulator(state: SceneState, k: int, pal: dict) -> TensorGlyph: + """Partial result: a square that still carries the memory frontier and newest output.""" + side = LAYOUT["acc_side"] + body = make_box(point(core_x(state, k), row_y(state)), side, side, pal["accumulator"], LAYOUT["corner"]) + return TensorGlyph(f"acc:k{k}", body, "accumulator", core_port_specs(side, side)) + + +def plan_absorb_initial_state(state: SceneState, piece: Piece, pal: dict) -> Beat: + target = make_accumulator(state, 0, pal) + port_map = {"leg:out[0]": ("a", PortRef(target.id, "out", PortKind.PHYSICAL_OUT))} + if LAYOUT["n_steps"] > 1: + port_map["mem:0"] = ("a", PortRef(target.id, "mem_w", PortKind.VIRTUAL)) + return plan_merge( + state, "absorb_initial_state", [piece.glyph.id, "pt:k0"], [("leg:in[0]", "start")], + [piece], target, port_map, pal, + ) + + +def plan_contract_next_core(state: SceneState, identity: Piece, next_k: int, pal: dict) -> Beat: + """Accumulator and the next core merge. The identity is only the wire between them, so it retracts.""" + identity.legs["channel"] = identity.glyph.body + # The wire is retracted, not morphed. Mark it consumed so the stationary-glyph check leaves it alone. + identity.glyph.status = Status.CONSUMED + target = make_accumulator(state, next_k, pal) + port_map = {f"leg:out[{next_k}]": ("a", PortRef(target.id, "out", PortKind.PHYSICAL_OUT))} + if next_k < LAYOUT["n_steps"] - 1: + port_map[f"mem:{next_k}"] = ("a", PortRef(target.id, "mem_w", PortKind.VIRTUAL)) + consumed = [(f"leg:out[{next_k - 1}]", "start"), (f"leg:in[{next_k}]", "start"), (f"mem:{next_k - 1}", "middle")] + beat = plan_merge( + state, f"contract_k{next_k}", [state.accumulator_id, f"pt:k{next_k}"], consumed, + [identity], target, port_map, pal, + ) + base_commit = beat.commit + + def commit() -> None: + base_commit() + identity.glyph.status = Status.CONSUMED + state.transient_ids.discard(identity.glyph.id) + state.piece_parts.pop(identity.glyph.id, None) + + beat.commit = commit + beat.consumed_ids = beat.consumed_ids + (identity.glyph.id,) + return beat + + +def plan_reshape_open_state(state: SceneState, pal: dict) -> Beat: + """The leftover square becomes a right-facing triangle; its open leg flattens and points left. + + Nothing is contracted. The wire already attached to the square is redrawn in place. + """ + acc = state.glyphs[state.accumulator_id] + centre = acc.body.get_center().copy() + side = LAYOUT["result_tri_side"] + body = make_arrow_body(centre, side, pal["state"], pointing_right=True) + base_rel = point(body.get_left()[0] - centre[0], 0.0) + tip_rel = base_rel + LEFT * LAYOUT["open_stub"] + target = TensorGlyph("result:state", body, "state", {"base": base_rel, "tip": tip_rel}) + last = f"out[{LAYOUT['n_steps'] - 1}]" + wire = state.wires[f"leg:{last}"] + flat = route_points(Route.STRAIGHT, centre + base_rel, centre + tip_rel) + copy = body.copy() + morph = ReplacementTransform(acc.body, copy, run_time=TIMING["result_settle"]) + bend = interpolate_path(wire.path, wire.path.points.copy(), flat, TIMING["result_settle"]) + socket = state.sockets[last] + + def finish(scene: Scene) -> None: + scene.remove(copy) + scene.add(body) + + def commit() -> None: + acc.status = Status.CONSUMED + state.transient_ids.discard(acc.id) + target.status = Status.LIVE + state.glyphs[target.id] = target + state.accumulator_id = target.id + wire.a = PortRef(target.id, "base", PortKind.OPEN_RESULT) + wire.b = PortRef(target.id, "tip", PortKind.OPEN_RESULT) + wire.route_kind = Route.STRAIGHT + state.socket_status[last] = "open" + + return Beat( + f"{state.panel_id}:reshape_open_state", + [AnimationGroup(morph, bend, FadeOut(socket, run_time=TIMING["result_settle"]))], + moving_ids=(acc.id, wire.id), + consumed_ids=(acc.id,), + created_ids=(target.id,), + commit=commit, + finish=finish, + discard=[socket], + ) + + +def plan_finish_as_scalar(state: SceneState, effect: Piece, pal: dict) -> Beat: + """The effect consumes the last system leg; the circle has no surviving stubs or markers.""" + body = make_scalar_circle(state.glyphs[state.accumulator_id].body.get_center(), pal["scalar"]) + target = TensorGlyph("result:scalar", body, "scalar") + last = LAYOUT["n_steps"] - 1 + return plan_merge( + state, "finish_as_scalar", [state.accumulator_id, effect.glyph.id], [(f"leg:out[{last}]", "start")], + [effect], target, {}, pal, merge_time=TIMING["result_settle"], + ) + + +def _result_mobs(state: SceneState) -> list[VMobject]: + mobs = [] + for glyph in state.glyphs.values(): + if glyph.status == Status.LIVE and glyph.kind in ("state", "scalar"): + mobs.append(glyph.body) + mobs.extend(glyph.markers.values()) + mobs.extend(wire.path for wire in state.wires.values() if wire.status == Status.LIVE) + return mobs + + +def _bounds(mobs: list[VMobject]) -> tuple[float, float, float, float]: + return ( + min(mob.get_left()[0] for mob in mobs), + max(mob.get_right()[0] for mob in mobs), + min(mob.get_bottom()[1] for mob in mobs), + max(mob.get_top()[1] for mob in mobs), + ) + + +def plan_zoom_results(scene: "PTContractionScene") -> Beat: + """Bring the two results together in the middle of the frame and zoom in by 1.2×.""" + left_mobs = _result_mobs(scene.panels[0]) + right_mobs = _result_mobs(scene.panels[1]) + ll, lr, lb, lt = _bounds(left_mobs) + rl, rr, rb, rt = _bounds(right_mobs) + current_gap = rl - lr + delta = LAYOUT["result_gap"] - current_gap + shift_sum = -(ll + rr) + dx_l = (shift_sum - delta) / 2 + dx_r = (shift_sum + delta) / 2 + dy_l = -(lb + lt) / 2 + dy_r = -(rb + rt) / 2 + move = rate_functions.smooth + anims = [mob.animate(run_time=TIMING["zoom"], rate_func=move).shift(point(dx_l, dy_l)) for mob in left_mobs] + anims += [mob.animate(run_time=TIMING["zoom"], rate_func=move).shift(point(dx_r, dy_r)) for mob in right_mobs] + frame = scene.camera.frame + width = frame.width / 1.2 + anims.append(frame.animate(run_time=TIMING["zoom"], rate_func=move).move_to(point(0.0, 0.0)).set(width=width)) + return Beat(name="zoom_results", animations=anims, discard=[frame]) + + +def plan_centre_result(state: SceneState) -> Beat: + """Slides the result, with any open output attached to it, to the horizontal centre of its panel.""" + (result,) = [g for g in state.glyphs.values() if g.status == Status.LIVE and g.kind in ("state", "scalar")] + shift = point(state.origin[0] - result.body.get_center()[0], 0.0) + attached = [w for w in state.wires.values() if w.status == Status.LIVE and result.id in (w.a.owner_id, w.b.owner_id)] + mobs = [result.body, *result.markers.values(), *(w.path for w in attached)] + return Beat( + f"centre_{result.kind}", + animations=[m.animate.shift(shift) for m in mobs], + moving_ids=(result.id, *(w.id for w in attached)), + ) + + +# -------------------------------------------------------------------------- +# Scene +# -------------------------------------------------------------------------- + + +class PTContractionScene(MovingCameraScene): + def setup(self) -> None: + self.theme = active_theme() + self.pal = PALETTES[self.theme] + self.poster_time: float | None = None + + # -- lifecycle -------------------------------------------------------- + + def play_beat(self, beat: Beat) -> None: + self.play_parallel_beats(beat) + + def play_parallel_beats(self, *beats: Beat) -> None: + for beat in beats: + beat.prepare(self) + anims = [a for beat in beats for a in beat.animations] + if anims: + self.play(*anims) + for beat in beats: + beat.finish(self) + if beat.discard: + self.remove(*beat.discard) + for mob in beat.discard: + mob.clear_updaters() + beat.commit() + if DEBUG["log_beats"]: + print(f"[beat] {beat.name}: moving={list(beat.moving_ids)} consumed={list(beat.consumed_ids)} created={list(beat.created_ids)}") + self.assert_invariants() + + def checkpoint(self, name: str) -> None: + if DEBUG["save_checkpoints"]: + out = Path(config.media_dir) / "checkpoints" + out.mkdir(parents=True, exist_ok=True) + self.renderer.update_frame(self) + self.renderer.get_image().save(out / f"{SCENE_STEM}-{self.theme}-{name}.png") + if DEBUG["log_beats"]: + print(f"[checkpoint] {name}") + if DEBUG["stop_after"] == name: + raise EndSceneEarlyException() + + def assert_invariants(self) -> None: + live = {id(m) for m in self.get_mobject_family_members()} + for state in self.panels: + for glyph in state.glyphs.values(): + if glyph.status == Status.LIVE and id(glyph.body) not in live: + raise AssertionError(f"{state.panel_id}: live glyph {glyph.id} missing from scene") + if glyph.status == Status.CONSUMED and id(glyph.body) in live: + raise AssertionError(f"{state.panel_id}: consumed glyph {glyph.id} still drawn") + for wire in state.wires.values(): + if wire.status == Status.LIVE: + for ref in (wire.a, wire.b): + if ref.owner_id in state.glyphs and state.glyphs[ref.owner_id].status != Status.LIVE: + raise AssertionError(f"{state.panel_id}: wire {wire.id} references {ref.owner_id}") + if id(wire.path) not in live: + raise AssertionError(f"{state.panel_id}: live wire {wire.id} missing from scene") + elif wire.status == Status.CONSUMED and id(wire.path) in live: + raise AssertionError(f"{state.panel_id}: consumed wire {wire.id} still drawn") + expected = set() + for state in self.panels: + for glyph in state.glyphs.values(): + if glyph.status == Status.LIVE: + expected |= {id(glyph.body), *(id(m) for m in glyph.markers.values())} + if glyph.label is not None: + expected |= {id(m) for m in glyph.label.get_family()} + expected |= {id(w.path) for w in state.wires.values() if w.status == Status.LIVE} + expected |= {id(m) for parts in state.piece_parts.values() for m in parts} + expected |= {id(state.sockets[n]) for n, s in state.socket_status.items() if s == "empty"} + frame_ids = {id(self.camera.frame)} + ghosts = [ + m for m in self.get_mobject_family_members() + if m.has_points() and id(m) not in expected and id(m) not in frame_ids + ] + if ghosts: + raise AssertionError(f"{len(ghosts)} unowned mobjects left in the scene: {ghosts[:3]}") + + def assert_identical_panels(self) -> None: + left, right = self.panels + rel = lambda s: [s.glyphs[f"pt:k{k}"].body.get_center() - s.origin for k in range(LAYOUT["n_steps"])] + if not np.allclose(rel(left), rel(right)): + raise AssertionError("contraction panels do not share core geometry") + + def assert_results(self) -> None: + left, right = self.panels + + def live(state, kind): + return [g for g in state.glyphs.values() if g.status == Status.LIVE and g.kind == kind] + + def live_wires(state, prefix=""): + return [w for w in state.wires.values() if w.status == Status.LIVE and w.id.startswith(prefix)] + + if len(live(left, "state")) != 1 or len(live_wires(left)) != 1 or live_wires(left, "mem"): + raise AssertionError("left panel must end as one state with one open output and no memory bonds") + if len(live(right, "scalar")) != 1 or live_wires(right): + raise AssertionError("right panel must end as one scalar with zero open ports") + for state in self.panels: + if [g for g in state.glyphs.values() if g.status == Status.LIVE and g.kind in ("pt_core", "accumulator")]: + raise AssertionError(f"{state.panel_id}: source cores or accumulators survived") + + def mark_poster(self) -> None: + self.poster_time = self.renderer.time + EXPORT["poster_offset"] + + def reset_loop(self) -> None: + mobs = list(self.mobjects) + if mobs: + self.play(*[FadeOut(m, scale=0.96) for m in mobs], run_time=TIMING["loop_fade"]) + for m in mobs: + m.clear_updaters() + self.clear() + if self.mobjects: + raise AssertionError("reset_loop left mobjects in the scene") + self.wait(TIMING["blank_hold"]) + + def pause(self) -> None: + self.wait(TIMING["beat_gap"]) + + # -- story ------------------------------------------------------------ + + def construct(self) -> None: + pal = self.pal + origins = LAYOUT["panel_origins"] + left = make_pt_panel("left", origins["left"], "open_output", pal) + right = make_pt_panel("right", origins["right"], "effect", pal) + self.panels = (left, right) + self.wait(TIMING["blank_hold"]) + + self.play_parallel_beats(plan_draw_pt(left), plan_draw_pt(right)) + self.assert_identical_panels() + self.checkpoint("contraction_two_pts") + self.pause() + + states = {p.panel_id: make_panel_initial_state(p, pal) for p in self.panels} + self.play_parallel_beats(*[plan_dock_piece(p, states[p.panel_id], pal, "prepare_and_dock") for p in self.panels]) + self.play_parallel_beats(*[plan_absorb_initial_state(p, states[p.panel_id], pal) for p in self.panels]) + self.pause() + + for next_k in range(1, LAYOUT["n_steps"]): + ids = {p.panel_id: make_identity_piece(p, next_k - 1, pal) for p in self.panels} + self.play_parallel_beats(*[plan_dock_piece(p, ids[p.panel_id], pal, f"insert_identity_{next_k - 1}") for p in self.panels]) + self.play_parallel_beats(*[plan_contract_next_core(p, ids[p.panel_id], next_k, pal) for p in self.panels]) + self.checkpoint(f"contraction_after_k{next_k}") + self.pause() + + effect = make_effect_piece(right, pal) + self.play_parallel_beats(plan_dock_piece(right, effect, pal, "append_effect")) + self.play_parallel_beats(plan_reshape_open_state(left, pal), plan_finish_as_scalar(right, effect, pal)) + self.play_parallel_beats(*[plan_centre_result(p) for p in self.panels]) + self.play_beat(plan_zoom_results(self)) + self.assert_results() + self.checkpoint("contraction_results") + + self.mark_poster() + self.wait(TIMING["result_hold"]) + self.reset_loop() + + +# -------------------------------------------------------------------------- +# Render entry point +# -------------------------------------------------------------------------- + + +def vertical_content_window(movie: Path, width: int, height: int) -> tuple[int, int]: + """Return the top row and height of a crop that keeps every opaque pixel. + + Rows that stay empty for the whole clip are dropped. A 22px margin at a + 540px-tall frame (scaled with height) is kept, and the window is expanded + outward to an even height. The horizontal extent is never changed. + """ + raw = subprocess.check_output( + [ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(movie), + "-vf", "format=gbrap,unpremultiply=inplace=1:planes=7,alphaextract,format=gray", + "-f", "rawvideo", "-pix_fmt", "gray", "-", + ] + ) + frame = width * height + n = len(raw) // frame + if n == 0: + return 0, height + alpha = np.frombuffer(raw[: n * frame], dtype=np.uint8).reshape(n, height, width) + rows = np.where((alpha > 8).any(axis=(0, 2)))[0] + if rows.size == 0: + return 0, height + top, bot = int(rows.min()), int(rows.max()) + pad = max(8, round(22 * height / 540)) + y0 = top - pad + y1 = bot + pad + if (y1 - y0 + 1) % 2: + if y0 > 0: + y0 -= 1 + else: + y1 += 1 + y0 = max(0, y0) + y1 = min(height - 1, y1) + if y0 > top or y1 < bot: + raise AssertionError(f"vertical crop would cut content: y={top}-{bot}, window={y0}-{y1}") + return y0, y1 - y0 + 1 + + +def encode_outputs(movie: Path, out_dir: Path, stem: str, theme: str, poster_time: float | None, fps: int, size: tuple[int, int], preset: str) -> None: + out_dir.mkdir(parents=True, exist_ok=True) + ff = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"] + # Cairo renders premultiplied RGBA but Manim writes it as straight alpha; + # without this, fades and antialiased edges darken. + unpremultiply = "format=gbrap,unpremultiply=inplace=1:planes=7" + y0, crop_h = vertical_content_window(movie, size[0], size[1]) + crop = "" if y0 == 0 and crop_h == size[1] else f"crop={size[0]}:{crop_h}:0:{y0}," + if preset == "final": + gif_filter = ( + f"{unpremultiply},{crop}fps={fps},split[a][b];[a]palettegen=reserve_transparent=1:stats_mode=full[p];" + f"[b][p]paletteuse=dither={EXPORT['gif_dither']}:alpha_threshold={EXPORT['gif_alpha_threshold']}" + ) + subprocess.run([*ff, "-i", str(movie), "-vf", gif_filter, "-loop", "0", str(out_dir / f"{stem}.gif")], check=True) + else: + background = EXPORT["mp4_background"][theme] + subprocess.run( + [*ff, "-f", "lavfi", "-i", f"color=c={background}:s={size[0]}x{crop_h}:r={fps}", "-i", str(movie), + "-filter_complex", f"[1]{unpremultiply}{',' + crop.rstrip(',') if crop else ''}[fg];[0][fg]overlay=shortest=1,format=yuv420p", "-c:v", "libx264", + "-crf", "18", "-movflags", "+faststart", str(out_dir / f"{stem}.mp4")], + check=True, + ) + if poster_time is not None: + subprocess.run( + [*ff, "-ss", f"{poster_time:.3f}", "-i", str(movie), "-vf", f"{unpremultiply},{crop}format=rgba", + "-frames:v", "1", str(out_dir / f"{stem}-poster.png")], + check=True, + ) + + +def render_assets(scene_cls: type[Scene] = PTContractionScene) -> None: + theme = active_theme() + preset = os.environ.get("PT_ANIM_PRESET", "final").strip().lower() + spec = EXPORT[preset] + repo = Path(__file__).resolve().parents[2] + media = repo / "docs" / "animations" / "media" + out_dir = repo / "docs" / "src" / "assets" / "animations" if preset == "final" else media / "drafts" + stem = f"{SCENE_STEM}-{theme}" + aspect = spec["pixel_width"] / spec["pixel_height"] + with tempconfig({ + "pixel_width": spec["pixel_width"], + "pixel_height": spec["pixel_height"], + "frame_rate": spec["fps"], + "frame_height": LAYOUT["frame_height"], + "frame_width": LAYOUT["frame_height"] * aspect, + "background_opacity": 0.0, + "format": "mov", + "media_dir": str(media), + "output_file": stem, + "disable_caching": True, + "verbosity": "WARNING", + "progress_bar": "none", + }): + scene = scene_cls() + scene.render() + movie = Path(scene.renderer.file_writer.movie_file_path) + encode_outputs(movie, out_dir, stem, theme, scene.poster_time, spec["fps"], (spec["pixel_width"], spec["pixel_height"]), preset) + print(f"[export] {stem}: {out_dir}") + + +if __name__ == "__main__": + render_assets() diff --git a/docs/animations/requirements.txt b/docs/animations/requirements.txt new file mode 100644 index 0000000..abdde41 --- /dev/null +++ b/docs/animations/requirements.txt @@ -0,0 +1,3 @@ +# Python environment for the homepage/README Manim animations. +# Tested with Python 3.12 and ffmpeg 8.0.1 (system binary, used for GIF/WebM/MP4 export). +manim==0.21.0 diff --git a/docs/literate/examples/boundary_driven_spin_chain.jl b/docs/literate/examples/boundary_driven_spin_chain.jl deleted file mode 100644 index 8e22f00..0000000 --- a/docs/literate/examples/boundary_driven_spin_chain.jl +++ /dev/null @@ -1,313 +0,0 @@ -# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors #src -# SPDX-License-Identifier: MIT #src -# #src -# File: docs/literate/examples/boundary_driven_spin_chain.jl #src -# Contributor: Gauthameshwar S. #src -# #src -# Demonstrates spin transport in a boundary-driven XXZ chain using #src -# Liouville-space TDVP. #src - -# # Boundary-driven spin transport -# -# A closed spin chain merely redistributes magnetisation already present in the -# system. To observe sustained transport, we instead attach two reservoirs that favour -# opposite edge polarisations. The left reservoir tries to polarise the first -# spin upward, the right reservoir tries to polarise the final spin downward, -# and the chain must continuously carry spin between them. -# -# This boundary-driven setup is a standard nonequilibrium open-system model. It -# produces three readable signatures: -# -# - a spin current that grows from zero, -# - a magnetisation profile across the chain, -# - an approximately uniform current once the bulk stops accumulating -# magnetisation. -# -# The implementation is correspondingly compact: -# -# 1. define the XXZ Hamiltonian as a physical `OpSum`; -# 2. encode the two reservoirs with four boundary `jump_ops`; -# 3. build one Liouvillian MPO; -# 4. evolve the vectorized density matrix with two-site TDVP. -# -# !!! related "Related material" -# - Tutorial: [Dissipative Dynamics](@ref) (Lindblad jumps, Liouville TDVP) -# - Theory: [Quantum States and Liouville Space](../theory/liouville_space.md) -# -# !!! script "Companion script" -# Advanced figures for ``\Delta=0``, ``0.5``, and ``1.0`` (mean bond current, -# magnetisation profile, and bond-current profile) are generated by -# [`scripts/boundary_driven_xxz_transport.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/main/scripts/boundary_driven_xxz_transport.jl). -# This page keeps a single ``\Delta=0.5`` run executable. - -# ## Model and physical scales -# -# We use the spin-``1/2`` XXZ Hamiltonian -# -# ```math -# H -# = -# J\sum_{j=1}^{N-1} -# \left( -# S_j^x S_{j+1}^x -# + -# S_j^y S_{j+1}^y -# + -# \Delta S_j^z S_{j+1}^z -# \right). -# ``` -# -# The ``XY`` terms move spin between neighbouring sites, while ``\Delta`` sets -# the interaction anisotropy. The density matrix obeys -# -# ```math -# \frac{d\rho}{dt} -# = -# -i[H,\rho] -# + -# \sum_k \mathcal{D}[L_k]\rho , -# ``` -# -# with opposing boundary reservoirs -# -# ```math -# L_{1,+}=\sqrt{\Gamma(1+\mu)}\,S_1^+, -# \qquad -# L_{1,-}=\sqrt{\Gamma(1-\mu)}\,S_1^-, -# ``` -# -# ```math -# L_{N,+}=\sqrt{\Gamma(1-\mu)}\,S_N^+, -# \qquad -# L_{N,-}=\sqrt{\Gamma(1+\mu)}\,S_N^-. -# ``` -# -# Here, the parameter ``\mu`` controls the bias between the left and right reservoirs, -# with its range restricted to ``-1 \leq \mu \leq 1`` . -# An isolated left spin would approach ``\langle S_1^z\rangle=\mu/2``; an -# isolated right spin would approach ``\langle S_N^z\rangle=-\mu/2``. Their -# disagreement drives the chain away from equilibrium. -# -# Because the bulk Hamiltonian conserves total ``S^z``, the local magnetisation -# ``m_j=\langle S_j^z\rangle`` obeys a lattice continuity equation. The bond -# current consistent with ``H`` is -# -# ```math -# \mathcal{J}_j -# = -# J\left( -# S_j^x S_{j+1}^y -# - -# S_j^y S_{j+1}^x -# \right). -# ``` -# -# We start from ``|\mathrm{Dn}\cdots\mathrm{Dn}\rangle`` and report the final -# magnetisation profile together with the mean and standard deviation of the -# bond currents. - -# ### Parameters and operators - -using Printf -using Statistics: mean, std -using ITensors -using ProcessTensors - -const N = 4 -const J = 1.0 -const Δ = 0.5 -const Γ = 1.0 -const μ = 0.4 -const dt = 0.1 -const final_time = 8.0 -const maxdim = 64 -const cutoff = 1e-10 - -physical_sites = siteinds("S=1/2", N) -liouville_sites = liouv_sites(physical_sites) - -hamiltonian = let H = OpSum() - for j in 1:(N - 1) - H += J, "Sx", j, "Sx", j + 1 - H += J, "Sy", j, "Sy", j + 1 - H += J * Δ, "Sz", j, "Sz", j + 1 - end - H -end - -# Package jump tuples use the dissipative rate itself, not its square root, so -# the factors written under the square roots above enter as `Γ * (1 ± μ)`. - -jump_operators = [ - (Γ * (1 + μ), "S+", 1), - (Γ * (1 - μ), "S-", 1), - (Γ * (1 - μ), "S+", N), - (Γ * (1 + μ), "S-", N), -] - -initial_state = MPS(physical_sites, fill("Dn", N)) -initial_density = to_dm(initial_state) -initial_density_liouville = - to_liouville(initial_density; sites=liouville_sites) - -liouvillian = liouvillian_mpo( - hamiltonian, - liouville_sites; - jump_ops=jump_operators, -) - -magnetisation_ops = [ - let observable = OpSum() - observable += 1.0, "Sz", j - to_liouville(MPO(observable, physical_sites); sites=liouville_sites) - end for j in 1:N -] - -current_ops = [ - let observable = OpSum() - observable += J, "Sx", j, "Sy", j + 1 - observable += -J, "Sy", j, "Sx", j + 1 - to_liouville(MPO(observable, physical_sites); sites=liouville_sites) - end for j in 1:(N - 1) -] - -@assert isapprox(real(tr(initial_density)), 1.0; atol=1e-12) - -# ## Liouville-space TDVP -# -# Vectorization turns the master equation into -# -# ```math -# \frac{d}{dt}|\rho(t)\rangle\rangle -# = -# \mathcal L|\rho(t)\rangle\rangle . -# ``` -# -# The Liouvillian is time-independent, so we build it once and advance with -# two-site TDVP. The evolution argument is the real interval `dt` because -# `liouvillian_mpo` already includes the Hamiltonian factor ``-i``. - -nsteps = round(Int, final_time / dt) -times = collect(range(0.0; step=dt, length=nsteps + 1)) -@assert isapprox(nsteps * dt, final_time; atol=100eps(Float64)) - -# ### Time evolution -# -# At each stored time we record the mean bond current. The full magnetisation -# and current profiles are evaluated once at the end. - -trajectory = let - density = copy(initial_density_liouville) - mean_current = Float64[] - trace_errors = Float64[] - bond_dimensions = Int[] - - for step in eachindex(times) - density_trace = tr(to_hilbert(density)) - bond_currents = [ - real(inner(observable, density) / density_trace) - for observable in current_ops - ] - push!(mean_current, mean(bond_currents)) - push!(trace_errors, abs(density_trace - 1)) - push!(bond_dimensions, maxlinkdim(density)) - - step == length(times) && continue - - density = tdvp( - liouvillian, - dt, - density; - time_step=dt, - nsite=2, - maxdim=maxdim, - cutoff=cutoff, - outputlevel=0, - ) - end - - density_trace = tr(to_hilbert(density)) - magnetisation_profile = [ - real(inner(observable, density) / density_trace) - for observable in magnetisation_ops - ] - current_profile = [ - real(inner(observable, density) / density_trace) - for observable in current_ops - ] - - ( - mean_current=mean_current, - magnetisation_profile=magnetisation_profile, - current_profile=current_profile, - trace_errors=trace_errors, - bond_dimensions=bond_dimensions, - ) -end - -# ### Final diagnostics - -mean_bond_current = mean(trajectory.current_profile) -std_bond_current = std(trajectory.current_profile; corrected=false) -left_right_magnetisation = - first(trajectory.magnetisation_profile) - last(trajectory.magnetisation_profile) -max_trace_error = maximum(trajectory.trace_errors) -max_bond_dimension = maximum(trajectory.bond_dimensions) - -@assert all(isfinite, trajectory.mean_current) -@assert all(isfinite, trajectory.magnetisation_profile) -@assert all(isfinite, trajectory.current_profile) -@assert all(value -> -0.5 - 1e-8 ≤ value ≤ 0.5 + 1e-8, trajectory.magnetisation_profile) -@assert max_trace_error < 1e-2 - -println("Boundary-driven XXZ spin chain") -@printf(" N=%d, J=%.2f, Δ=%.2f, Γ=%.2f, μ=%.2f\n", N, J, Δ, Γ, μ) -@printf(" simulated to t = %.2f\n", final_time) -@printf(" final mean bond current = %.6f\n", mean_bond_current) -@printf(" final bond-current std = %.3e\n", std_bond_current) -@printf(" final ⟨S₁ᶻ⟩ − ⟨Sₙᶻ⟩ = %.6f\n", left_right_magnetisation) -@printf(" max trace error = %.3e, max bond dim = %d\n", max_trace_error, max_bond_dimension) - -# ## Transport response and numerical interpretation -# -# Starting from all `Dn`, every site begins at ``\langle S_j^z\rangle=-1/2``. -# The left reservoir injects upward polarisation, so site ``1`` rises toward a -# positive value, while the right reservoir keeps site ``N`` near a negative value. -# This left-to-right bias drives a nonzero spin current through the XXZ chain. -# The mean bond current rises from zero during the transient and saturate to different -# values for each ``Delta`` value in the later time, as it nears the steady state. -# -# At late times the magnetisation profile settles and the bond currents become -# more uniform. Continuity then requires that spin entering one bond leave -# through the next. The resulting state is a -# nonequilibrium steady state maintained by the reservoirs, not an equilibrium -# state of the XXZ Hamiltonian. -# -# ![Mean bond current, magnetisation profiles, and bond currents in the boundary-driven XXZ chain](../assets/examples/boundary_driven_xxz_transport.png) -# -# ### Sources of numerical error -# -# - **TDVP / truncation error:** two-site updates allow the Liouville-MPS bond -# dimension to grow. `cutoff` and `maxdim` control the discarded weight. -# - **Finite timestep:** the real-time TDVP step approximates the short-time -# propagator generated by ``\mathcal L``. -# - **Finite simulation window:** `final_time` may be shorter than the time -# needed for a fully uniform bond-current profile. -# -# Trace drift is a cheap diagnostic for the density-matrix evolution. The mean -# and standard deviation of the final bond currents give a compact check of -# spatial uniformity at the simulated time. -# -# !!! summary "Example takeaways" -# - Opposing boundary reservoirs turn a closed XXZ chain into a driven open -# system with a sustained spin current. -# - Four rate tuples encode the baths; the bulk remains an ordinary -# Hilbert-space `OpSum`. -# - The continuity current ``\mathcal J_j`` follows from the XXZ -# Hamiltonian and becomes approximately bond-independent once the bulk -# magnetisation stops changing. -# - Two-site Liouville TDVP evolves the vectorized density while allowing -# operator-space correlations generated by the drive to grow. -# - The final edge difference ``\langle S_1^z\rangle-\langle S_N^z\rangle`` -# shows the spatial magnetisation bias; the mean and std of the bond -# currents summarize how much of that bias is transmitted. diff --git a/docs/literate/examples/central_spin_ace.jl b/docs/literate/examples/central_spin_ace.jl index 403be5d..6cf24cb 100644 --- a/docs/literate/examples/central_spin_ace.jl +++ b/docs/literate/examples/central_spin_ace.jl @@ -4,298 +4,200 @@ # File: docs/literate/examples/central_spin_ace.jl #src # Contributor: Gauthameshwar S. #src # #src -# Reproduces the fully polarized central-spin benchmark of Cygorek et al. #src -# using the ACE process-tensor construction in ProcessTensors.jl. #src +# Literate example: fully polarised central-spin dynamics with ACE. #src # # Central-spin dynamics using ACE # -# A central spin coupled to many microscopic bath spins is one of the simplest -# models in which an environment is both genuinely many-body and strongly -# non-Markovian. The bath spins do not interact with one another directly. -# Instead, each of them talks to the same central spin, so information deposited -# in the bath can later return to the system. -# -# This example follows the fully polarized central-spin benchmark used by -# Cygorek *et al.* to demonstrate Automated Compression of Environments (ACE). -# We first build the microscopic system and bath mode by mode, then let ACE -# combine those independent mode influences into a single process tensor. +# Can many weakly coupled bath spins produce a simple collective motion? +# We construct their influence with ACE and follow the transverse spin +# $\langle S_x(t)\rangle$. An exact finite-bath expression lets us distinguish +# physical finite-size effects from numerical approximation. # # !!! related "Related material" -# - Tutorial: [Single-Mode Process Tensor](@ref) +# - Tutorial: [Construct a process tensor](@ref) # - Theory: [Process Tensors](../theory/process_tensors.md) # # !!! script "Companion script" -# The larger ``N=5,10,100,1000`` comparison and the bond-dimension scaling -# figure are generated by -# [`scripts/central_spin_ace.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/main/scripts/central_spin_ace.jl). +# [`scripts/central_spin_ace.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/better-docs/scripts/central_spin_ace.jl) +# runs the $N=5,10,100,1000$ sweep, caches the process tensors, and generates +# the trajectory and diagnostic figure. -# ## The central-spin model -# -# We consider one spin-``1/2`` system, ``\mathbf S``, coupled isotropically to -# ``N`` bath spins ``\mathbf s_k``. In the benchmark there is no free system or -# bath Hamiltonian, -# -# ```math -# H_S = 0, -# \qquad -# H_B = 0, -# ``` +# ## Model and physical question # -# and all dynamics comes from the central-spin interaction +# We follow the fully polarised central-spin benchmark of Cygorek *et al.* +# (Nature Physics **18**, 662–668, 2022). There are no free Hamiltonians: +# all motion comes from isotropic exchange with independent bath spins, # # ```math -# H_{\mathrm{int}} -# = -# \sum_{k=1}^{N} -# J_k -# \left( -# S_x s_k^x -# + S_y s_k^y -# + S_z s_k^z -# \right), -# \qquad -# J_k=\frac{J}{N}. +# H=\frac{J}{N}\sum_{k=1}^{N}\mathbf S\cdot\mathbf s_k, +# \qquad H_S=H_B=0. # ``` # -# We use the natural units of the benchmark, ``J=\hbar=1``. Dividing the -# coupling by ``N`` is important: adding more bath spins refines the collective -# environment without simply making the total interaction ``N`` times larger. -# -# The central spin begins along ``+x``, -# -# ```math -# \rho_S(0)=|+\rangle_x\langle+|, -# ``` -# -# while every bath spin is polarized along ``+z``, -# -# ```math -# \rho_B(0) -# = -# \bigotimes_{k=1}^{N} -# |\uparrow\rangle_k\langle\uparrow|. -# ``` +# We use $\hbar=1$ and $J=1$. The central spin starts in $|+x\rangle$ and +# every bath spin in $|\uparrow_z\rangle$. The factor $1/N$ keeps the total +# coupling scale fixed as the bath grows. The polarised bath acts approximately +# as a field along $z$, around which the central spin precesses. Exchange also +# allows the central spin to flip while exciting the bath. # -# This is already enough to produce non-trivial dynamics. The ``+x`` state is -# a superposition in the ``z`` basis, and the exchange part of the isotropic -# coupling allows the central spin and bath to exchange angular momentum. -# Consequently ``\langle S_x(t)\rangle`` oscillates even though both -# ``H_S`` and ``H_B`` vanish. -# -# As ``N`` grows, each individual coupling ``J/N`` becomes weaker and the -# finite-size bath approaches the collective large-``N`` response. For this -# normalization the analytical limiting curve is -# -# ```math -# \frac{\langle S_x(t)\rangle}{\hbar} -# \longrightarrow -# \frac{1}{2}\cos\!\left(\frac{tJ}{2\hbar}\right). -# ``` -# -# The companion benchmark will show this convergence directly. - -# ## Build one modest bath -# -# This example uses a small bath and a short process tensor so that -# both ACE construction and ``evolve`` stay cheap. The companion script uses -# the paper timestep ``dt=0.01`` out to ``t=20``. +# !!! note "A small executable example and a larger figure" +# The cells below use $N=5$, $dt=0.2$, and 20 snapshots, ending at $t=3.8$. +# The companion figure uses $N=5,10,100,1000$, $dt=0.01$, $t=20$, +# cutoff $10^{-10}$, and `maxdim=1024`. These are separate calculations. using Logging -using Printf +using LinearAlgebra using ITensors using ITensors.Ops: Trotter using ProcessTensors -function one_site_density_matrix(ρ) - tensor = foldl(*, ρ) - site = only( - filter( - index -> plev(index) == 0 && hastags(index, "Site"), - inds(tensor), - ), - ) - return ComplexF64.(Array(tensor, prime(site), site)) -end - -const N_bath = 5 -const J = 1.0 -const dt = 0.2 -const nsteps = 20 -const final_time = (nsteps - 1) * dt -const ace_cutoff = 1e-8 -const ace_maxdim = 32 +N_bath = 5 +J = 1.0 +dt = 0.2 +nsteps = 20 +ace_cutoff = 1e-8 +ace_maxdim = 32 +# ## Assemble and compress the bath +# +# Each bath spin becomes a `SpinMode`. In its coupling `OpSum`, site 1 is the +# bath spin and site 2 is the central spin. Empty `OpSum`s specify the vanishing +# free Hamiltonians; the nonzero coupling is supplied separately. We silence +# the constructors’ warnings about these intentionally empty Hamiltonians using the `NullLogger`. -# Empty free Hamiltonians are intentional in this benchmark. We want the central spin particle to be free and through the bath spins, experience an effective magnetic field trying to polarise it. system_sites = siteinds("S=1/2", 1) -system = with_logger(NullLogger()) do - spin_system(system_sites, OpSum()) -end; - -# The initial central spin points along +x. -initial_density = to_dm(MPS(system_sites, ["+"])); - -# Each bath spin is represented as one independent ACE mode. The mode has no -# free Hamiltonian, starts in |↑><↑|, and carries its own system-mode coupling. +system = with_logger(() -> spin_system(system_sites, OpSum()), NullLogger()) +initial_density = to_dm(MPS(system_sites, ["+"])) bath_sites = siteinds("S=1/2", N_bath) bath_liouville_sites = liouv_sites(bath_sites) -mode_coupling = J / N_bath -modes = with_logger(NullLogger()) do - [ - let - initial_mode_density = to_liouville( - to_dm(MPS([bath_sites[k]], ["Up"])); - sites=[bath_liouville_sites[k]], - ) - - coupling = OpSum() - coupling += mode_coupling, "Sx", 1, "Sx", 2 - coupling += mode_coupling, "Sy", 1, "Sy", 2 - coupling += mode_coupling, "Sz", 1, "Sz", 2 - - spin_mode( - [bath_liouville_sites[k]], - OpSum(), - initial_mode_density; - coupling=coupling, - ) - end for k in 1:N_bath - ] -end; - -# There are deliberately no bath-bath terms here. The modes are independent -# before they are combined by ACE; their only communication is mediated by the -# central spin. -bath = spin_bath(modes); - -println("Central-spin ACE setup") -@printf(" bath spins: %d\n", N_bath) -@printf(" coupling per bath spin J/N: %.6f\n", mode_coupling) -@printf(" timestep dt: %.3f\n", dt) -@printf(" final time: %.1f\n", final_time) -@printf(" snapshots: %d\n", nsteps) -@printf(" ACE cutoff: %.1e\n", ace_cutoff) -@printf(" ACE maxdim: %d\n", ace_maxdim) - -# ## Compress the environment into a process tensor -# -# If we propagated the complete system-bath density matrix directly, the bath -# Liouville space would grow as ``4^N``. ACE avoids carrying that full object. -# Instead, it constructs the influence of one bath mode at a time, joins those -# influences along the temporal direction, and truncates the resulting temporal -# bonds after each combination. -# -# The object returned below is therefore not a bath trajectory. It is the -# multi-time map seen by the central spin after the microscopic bath spins -# have been eliminated. -# -# We use symmetric second-order splittings for the system-mode propagation and -# for combining mode process tensors. - -build_time = @elapsed begin - process_tensor = build_process_tensor( - system; - method=ACE(cutoff=ace_cutoff, maxdim=ace_maxdim), - environment=bath, - dt=dt, - nsteps=nsteps, - sys_alg=Trotter{2}(), - combine_alg=Trotter{2}(), - ) +coupling = OpSum() +coupling += J / N_bath, "Sx", 1, "Sx", 2 +coupling += J / N_bath, "Sy", 1, "Sy", 2 +coupling += J / N_bath, "Sz", 1, "Sz", 2 + +modes = SpinMode[] +for k in 1:N_bath + ρk = to_dm(MPS([bath_sites[k]], ["Up"])) + ρk_l = to_liouville(ρk; sites=[bath_liouville_sites[k]]) + mode = with_logger(() -> spin_mode([bath_liouville_sites[k]], OpSum(), ρk_l; + coupling=copy(coupling)), NullLogger()) + push!(modes, mode) end +bath = spin_bath(modes) -println(process_tensor) - -# The temporal bond dimension tells us how much information ACE had to retain -# between neighboring times. It is therefore also a useful numerical diagnostic -# of the compressed environmental memory. +# ACE combines the mode influences and compresses their temporal bonds. +# This avoids explicitly storing a joint bath density operator with $4^N$ +# components. The retained bond dimensions depend on the process and truncation. -max_pt_bond = maxlinkdim(process_tensor) - -@printf(" maximum PT bond dimension: %d\n", max_pt_bond) -@printf(" PT build time: %.3f s\n", build_time) +process_tensor = build_process_tensor( + system; environment=bath, + method=ACE(cutoff=ace_cutoff, maxdim=ace_maxdim, compression=:zipup_cpp), + dt=dt, nsteps=nsteps, + sys_alg=Trotter{2}(), combine_alg=Trotter{2}(), +) -@assert max_pt_bond < ace_maxdim +# !!! note "Which splitting matters here?" +# The free-system Hamiltonian vanishes, so its half-steps are identities. +# Different mode interactions still share the central spin and generally +# do not commute. Symmetric mode combination therefore retains a timestep +# error, in addition to ACE truncation. A bond below `maxdim` only tells us +# that the cap was not reached; it does not establish convergence. -# ## Evolve the central spin through the process tensor +# ## Read the central-spin trajectory # -# The expensive environmental calculation is now finished. Applying the -# process tensor to ``rho_S(0)`` produces the reduced central-spin trajectory -# without explicitly restoring the bath degrees of freedom. +# `evolve` contracts the completed process with the initial preparation. +# We convert its one-spin output to a $2\times2$ matrix for compact diagnostics. +# The expectation is trace-normalised, while raw trace drift is reported +# separately so that normalisation cannot conceal it. -evolution_time = @elapsed begin - trajectory = evolve(process_tensor, initial_density) -end - -Sx_matrix = ComplexF64.( - Array( - op("Sx", system_sites[1]), - prime(system_sites[1]), - system_sites[1], - ), -) +trajectory = evolve(process_tensor, initial_density) -spin_x = Float64[] -trace_errors = Float64[] - -for ρ in trajectory.states_hilbert - ρ_matrix = one_site_density_matrix(ρ) - trace_value = tr(ρ_matrix) - - push!( - spin_x, - real(tr(Sx_matrix * ρ_matrix) / trace_value), - ) - push!(trace_errors, abs(trace_value - 1)) +function one_spin_matrix(ρ) + tensor = foldl(*, ρ) + site = only(filter(i -> plev(i) == 0 && hastags(i, "Site"), inds(tensor))) + return ComplexF64.(Array(tensor, prime(site), site)) end -max_trace_error = maximum(trace_errors) - +states = one_spin_matrix.(trajectory.states_hilbert) +traces = tr.(states) +@assert all(z -> isfinite(z) && abs(z) > 1e-12, traces) +Sx = ComplexF64[0 1; 1 0] / 2 +spin_x = [real(tr(Sx * ρ) / z) for (ρ, z) in zip(states, traces)] +trace_error = maximum(abs.(traces .- 1)) +hermiticity_error = maximum(norm(ρ - ρ') / norm(ρ) for ρ in states) + +println((final_time=last(trajectory.times), initial_sx=first(spin_x), + final_sx=last(spin_x), max_trace_error=trace_error, + max_hermiticity_error=hermiticity_error, + max_pt_bond=maxlinkdim(process_tensor))) @assert all(isfinite, spin_x) -@assert all(value -> -0.5 - 1e-3 ≤ value ≤ 0.5 + 1e-3, spin_x) -@assert max_trace_error < 1e-3 - -println("Central-spin trajectory diagnostics") -@printf(" (0): %.6f\n", first(spin_x)) -@printf(" (t_final): %.6f\n", last(spin_x)) -@printf(" maximum trace error: %.3e\n", max_trace_error) -@printf(" trajectory evolution time: %.3f s\n", evolution_time) +@assert maximum(abs, spin_x) <= 0.5 + 1e-3 +@assert trace_error < 1e-3 -# ## From one process tensor to the benchmark +# ## Interpret the oscillations +# +# Uniform coupling preserves the bath's collective-spin sector $j=N/2$. +# Starting with a fully polarised bath restricts the evolution to the all-up +# state and a two-state block containing one spin flip. Their energy differences +# give the exact finite-$N$ transverse expectation # -# Repeating exactly the same construction for larger ``N`` gives the upper -# panel below. The finite baths ``N=5`` and ``N=10`` visibly oscillate at a -# shifted frequency, while ``N=100`` and ``N=1000`` lie almost on top of the -# analytical ``N\rightarrow\infty`` result, shown as sparse ED crosses. The -# convergence is especially satisfying because nothing about that limiting -# curve was inserted into ACE: it emerges from combining the microscopic -# spin modes. +# ```math +# \langle S_x(t)\rangle_N +# =\frac{1+N\cos[J(N+1)t/(2N)]}{2(N+1)} +# \;\longrightarrow\;\frac12\cos(Jt/2). +# ``` # -# The lower panel reports the corresponding density-matrix diagnostics on a -# logarithmic scale: the trace error, the Hermiticity defect, and the -# deviation of ``\langle S_x\rangle`` from the ``N\rightarrow\infty`` ED -# curve. Colors match the ``N`` values in the upper panel. +# This expression is specific to uniform coupling and the stated initial state; +# it is a reference for ACE, not an ingredient of its construction. + +exact_sx = (1 .+ N_bath .* cos.(J * (N_bath + 1) .* trajectory.times ./ + (2N_bath))) ./ (2(N_bath + 1)) +large_bath_sx = 0.5 .* cos.(J .* trajectory.times ./ 2) +println((max_error_vs_exact_finite_N=maximum(abs.(spin_x .- exact_sx)), + max_deviation_from_large_N=maximum(abs.(spin_x .- large_bath_sx)))) + +# ![Central-spin ACE trajectories and diagnostics](../assets/examples/central_spin_ace.png) # -# ![Fully polarized central-spin ACE dynamics with sparse ED markers and density-matrix errors](../assets/examples/central_spin_ace.png) +# In the companion figure, the $N=5$ and $N=10$ curves reach their +# first minimum before the large-bath reference and do not reach $-1/2$. +# The formula explains both features. The oscillation frequency and the value +# of that minimum are # -# !!! note "Numerical accuracy" -# The benchmark uses ``dt=0.01`` and an ACE truncation threshold of -# ``10^{-10}``. The remaining approximations are the second-order Trotter -# splitting and the finite process-tensor bond truncation. A convergence -# check should reduce ``dt``, tighten the ACE cutoff, and increase -# ``maxdim`` until the observable of interest is unchanged. +# ```math +# \omega_N=\frac{J(N+1)}{2N}, +# \qquad +# \min_t\langle S_x(t)\rangle_N=\frac{1-N}{2(N+1)}. +# ``` # -# !!! summary "Example takeaways" -# - The central-spin benchmark contains no free system or bath dynamics; -# all reduced dynamics comes from the isotropic ``S\cdot s_k`` coupling. -# - A fully ``+z``-polarized spin bath can be assembled transparently from -# independent [`SpinMode`](@ref) objects with ``J_k=J/N``. -# - ACE eliminates those microscopic bath spins and stores their complete -# multi-time influence as a temporally compressed process tensor. -# - Increasing ``N`` drives the finite bath toward the analytical -# ``N\rightarrow\infty`` central-spin oscillation. Trace and Hermiticity -# errors stay small, while the deviation from the ED curve falls with -# ``N``. -# - Once built, the same process tensor can be reused with different system -# preparations and later with multi-time instrument sequences. +# For $N=5$ this minimum is $-1/3$; for $N=10$ it is $-9/22$. The $N=100$ +# and $N=1000$ curves approach the limiting cosine. +# Thus these offsets and phase shifts are expected finite-bath physics. +# +# The black crosses show the large-bath limit. In the lower panel, the solid +# curves are the absolute error against the exact finite-$N$ expression. Trace +# drift and the relative Hermiticity defect show different properties of the +# reconstructed density operator. The error of the observable is compared against +# the analytical expectation provided above. +# +# !!! note "Read numerical diagnostics separately from finite-size effects" +# At fixed $N$, reducing `dt` and tightening compression should improve +# agreement with the finite-$N$ reference. It should not eliminate physical +# differences from the limiting cosine. Trace and Hermiticity checks are +# useful but do not certify positivity or accuracy of the whole process. +# +# !!! tip " Try changing" +# These edits test whether the offset from the large-bath cosine is +# finite-bath physics, and whether the remaining disagreement with the +# finite-$N$ formula comes from the timestep or from ACE truncation. A new +# central-spin preparation can reuse the stored process tensor. A new bath +# size, coupling, or `dt` is built into the cores and needs a new one. +# - Increase $N$ while keeping each coupling equal to $J/N$. Compare the +# first minimum of $\langle S_x(t)\rangle$ with the time +# $t=\pi/\omega_N$ and the depth given above. +# - Halve `dt` and set `nsteps = 2(nsteps - 1) + 1`, so the final time stays +# the same. If the second-order splitting dominates, the error against +# the finite-$N$ formula should fall by about a factor of four. A plateau +# points to the ACE cutoff and `maxdim` next. +# - Change one bath coupling, or prepare the central spin in a different +# direction. The finite-$N$ formula above applies only to uniform coupling +# and $|+x\rangle$. The changed preparation can reuse this process tensor. diff --git a/docs/literate/examples/dissipative_spin.jl b/docs/literate/examples/dissipative_spin.jl index 8c219b6..50e49de 100644 --- a/docs/literate/examples/dissipative_spin.jl +++ b/docs/literate/examples/dissipative_spin.jl @@ -9,36 +9,28 @@ # # Dissipative spin-chain dynamics # -# A closed transverse-field Ising model describes coherent spin rotation and -# correlated many-body motion. Real spin platforms also exchange energy with -# their surroundings, so coherent dynamics is progressively damped and the -# system relaxes toward a state selected by both the Hamiltonian and the -# environment. -# -# Adding local amplitude damping gives a standard open-system benchmark. -# The dissipative transverse-field Ising chain is a paradigmatic model of -# correlated open-system dynamics, widely used to study relaxation, -# decoherence, and nonequilibrium steady states. Here it also -# provides a compact example of the `ProcessTensors.jl` workflow: -# -# 1. define a physical Hamiltonian as an `OpSum`; -# 2. specify local Lindblad channels as `jump_ops`; -# 3. vectorize the initial density matrix; -# 4. evolve it directly with Liouville-space `tebd`. +# A closed transverse-field Ising model describes coherent spin rotation and correlated +# many-body motion. Real spin platforms also exchange energy with their surroundings, +# so coherent dynamics is progressively damped and the system relaxes toward a +# state selected by both the Hamiltonian and the environment. +# How does local amplitude damping change the Up population and the transverse +# magnetization of a transverse-field Ising chain? The observables are the mean +# Up density ``\bar n_\uparrow(t)`` and the mean Pauli magnetization +# ``\overline X(t)``. # # !!! related "Related material" # - Tutorial: [Dissipative Dynamics](@ref) (`liouvillian_mpo`, Liouville TEBD) # - Theory: [Quantum States and Liouville Space](../theory/liouville_space.md) # # !!! script "Companion script" -# Advanced figures comparing first-, second-, and fourth-order TEBD against -# dense ``e^{t\mathcal L}`` evolution are generated by +# The figures below, with first-, second-, and fourth-order TEBD against +# dense ``e^{t\mathcal L}`` evolution, are generated by # [`scripts/tebd_tfim_dissipative.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/main/scripts/tebd_tfim_dissipative.jl). -# This page keeps one representative second-order run visible and executable. +# This page runs one second-order trajectory on a shorter window. -# ## Model and physical scales +# ## Model # -# We use an open transverse-field Ising chain, +# Time is in units of ``1/J``, with ``\hbar=1``. The spin chain is # # ```math # H @@ -47,14 +39,10 @@ # -h\sum_{j=1}^{N} X_j , # ``` # -# together with a local loss channel at each site, -# -# ```math -# L_j=S_j^-, -# \qquad j=1,\ldots,N, -# ``` -# -# so the density matrix evolves as +# with a local loss channel on every site. On an `S=1/2` site, the Hamiltonian +# operators ``X`` and ``Z`` are Pauli operators. The jump operator ``S^-`` is +# the spin lowering operator, with ``S^-|\mathrm{Up}\rangle=|\mathrm{Dn}\rangle``. +# The master equation is # # ```math # \frac{d\rho}{dt} @@ -69,14 +57,10 @@ # -\frac{1}{2}\{L^\dagger L,\rho\}. # ``` # -# The Ising term correlates neighboring spins, the transverse field rotates -# them away from the ``Z`` axis, and ``S_j^-`` transfers local `Up` population -# to `Dn`. The rates ``J``, ``h``, and ``\gamma`` therefore set competing -# coherent, interaction, and relaxation timescales. -# -# We start from the fully polarized state -# ``|\mathrm{Up}\cdots\mathrm{Up}\rangle`` and monitor two complementary -# observables: +# The initial state is ``|\mathrm{Up}\cdots\mathrm{Up}\rangle``. Its local Up +# population is the projector ``(I+Z)/2``. That product state is an eigenstate +# of every local ``Z``, and it is an eigenstate of the interacting ``H`` only +# in special limits. The two plotted densities are # # ```math # \bar n_\uparrow(t) @@ -89,13 +73,12 @@ # \frac{1}{N}\sum_{j=1}^{N}\langle X_j\rangle_t . # ``` # -# The excited-spin density ``\bar n_\uparrow`` tracks population relaxation -# under amplitude damping. The transverse magnetization ``\overline X`` -# exposes the coherent response generated by the transverse field. +# ``\sum_j X_j`` commutes with ``\overline X``, so the transverse field by +# itself does not rotate ``\overline X``. A nonzero value in this model comes +# from the Ising coupling, the field, and the loss acting together. # ### Parameters and operators -using Printf using ITensors using ITensors.Ops: Trotter using ProcessTensors @@ -173,11 +156,10 @@ excited_density_liouville = to_liouville( # \mathcal L|\rho(t)\rangle\rangle . # ``` # -# `tebd` constructs local Liouville-space gates internally from the physical -# Hamiltonian and the jump tuples. A second-order Suzuki--Trotter -# decomposition approximates ``e^{\mathcal L\Delta t}``; because the generator -# already contains the Hamiltonian factor ``-i``, the evolution time passed to -# Liouville TEBD is the real interval `dt`. +# `tebd` builds the local Liouville gates from `hamiltonian` and +# `jump_operators`. The evolution interval passed to it is the real step `dt`, +# because ``\mathcal L`` already contains the factor ``-i``. This page uses one +# second-order splitting, `Trotter{2}()`. nsteps = round(Int, final_time / dt) @assert isapprox(nsteps * dt, final_time; atol=100eps(Float64)) @@ -223,77 +205,59 @@ trajectory = let ) end -max_trace_error = maximum(trajectory.trace_errors) -max_bond_dimension = maximum(trajectory.bond_dimensions) +println(( + N=N, + final_time=final_time, + final_mean_X=trajectory.mean_x[end], + final_n_up=trajectory.excited_density[end], + max_trace_error=maximum(trajectory.trace_errors), + max_bond_dimension=maximum(trajectory.bond_dimensions), +)) @assert all(isfinite, trajectory.mean_x) @assert all(isfinite, trajectory.excited_density) @assert all(value -> -1 - 1e-8 ≤ value ≤ 1 + 1e-8, trajectory.mean_x) @assert all(value -> -1e-8 ≤ value ≤ 1 + 1e-8, trajectory.excited_density) -@assert max_trace_error < 1e-2 +@assert maximum(trajectory.trace_errors) < 1e-2 -println("Dissipative transverse-field Ising chain") -@printf(" N=%d, J=%.2f, h=%.2f, γ=%.2f\n", N, J, transverse_field, decay_rate) -@printf( - " final ⟨X̄⟩ = %.6f, final n̄_↑ = %.6f\n", - trajectory.mean_x[end], - trajectory.excited_density[end], -) -@printf(" max trace error = %.3e, max bond dim = %d\n", max_trace_error, max_bond_dimension) - -# ## Relaxation and numerical interpretation +# ## What the populations show # -# The initial all-`Up` state has unit excited-spin density. With time, local ``S^-`` -# jump operators remove that population, thus ``\bar n_\uparrow(t)`` falls toward a -# stationary value. The transverse field creates a non-zero coherent response, -# that leads to transient oscillations in the transverse magnetization. These oscillations are damped as the -# environment erases coherent spin motion by decaying the population, while the Ising coupling makes the -# response collective rather than a set of independent one-spin decays. -# -# As a consequence, the late-time values are not ground-state observables of ``H``. They describe -# a nonequilibrium stationary response determined by coherent rotation, -# spin--spin interactions, and local loss. The dense curves in the figures -# below validate that interpretation and show how the TEBD approximation -# improves as the Trotter order increases at fixed ``\Delta t``. +# The cells above use ``N=6``, end at ``t=4``, and apply only `Trotter{2}()`, +# with `maxdim=96` and `cutoff=1e-10`. The figures are the companion run: +# ``N=4``, the same ``J``, ``h``, ``\gamma``, and ``\Delta t=0.1``, through +# ``t=9``, with orders 1, 2, and 4 against dense ``e^{t\mathcal L}``, +# `maxdim=128`, and `cutoff=1e-12`. # # ![Excited-spin density for dissipative TFIM TEBD and dense evolution](../assets/examples/tebd_tfim_dissipative_dynamics_nup.png) # -# ![Transverse magnetization for dissipative TFIM TEBD and dense evolution](../assets/examples/tebd_tfim_dissipative_dynamics_mx.png) +# The Up density starts at 1, falls below the dashed late-time level, and +# recovers toward it. At this ``\Delta t``, first-order TEBD stays visibly +# above the dense curve. Orders 2 and 4 track that curve through ``t=9``. +# Agreement on this ``N=4`` window is the comparison the figure supports. # -# ### Sources of numerical error -# -# This calculation has three principal numerical approximations: +# ![Transverse magnetization for dissipative TFIM TEBD and dense evolution](../assets/examples/tebd_tfim_dissipative_dynamics_mx.png) # -# - **Trotter error:** local coherent and dissipative Liouvillian terms do not -# generally commute. At fixed ``\Delta t``, raising the Suzuki--Trotter order -# from ``1`` to ``2`` to ``4`` systematically reduces that splitting error. -# - **Tensor-network truncation:** two-site gates can increase the Liouville-MPS -# bond dimension. `cutoff` and `maxdim` control the discarded information. -# - **Finite simulation window:** `final_time` may be too short to establish -# that the stationary regime has been reached. +# ``\overline X`` starts at zero, becomes nonzero, and oscillates while the +# loss damps it toward the dashed level. The same first-order curve falls +# away from the dense result, while orders 2 and 4 stay with it. The dashed +# line is the steady response of this master equation. # -# Trace drift is a useful inexpensive diagnostic, but it does not by itself -# prove positivity or convergence. The full script compares Trotter orders -# ``1``, ``2``, and ``4`` at ``\Delta t = 0.1`` with dense evolution on a -# smaller chain. +# A trace that stays near 1 does not establish accuracy. The first-order +# population is the visible counterexample: its trace can remain acceptable +# while ``\bar n_\uparrow`` and ``\overline X`` are still offset. Bond +# truncation and the finite window are separate limits. This ``t=9`` curve +# has settled near the dashed line; a shorter window would not show that. # - -# !!! summary "Example takeaways" -# - Local amplitude damping is specified by adding one local -# `(γ, "S-", j)` jump tuple for every spin; the physical Hamiltonian remains -# an ordinary Hilbert-space `OpSum`. -# - `tebd` converts this Hamiltonian-and-jump description into local -# Liouville-space propagators and evolves the vectorised density matrix -# directly. -# - The excitation density tracks population removed by the environment, -# while ``\overline X(t)`` tracks coherent spin dynamics damped by the same -# loss channels. -# - The stationary values are nonequilibrium observables determined jointly -# by the Hamiltonian and dissipators; they need not coincide with -# ground-state expectation values of `H`. -# - At a fixed timestep, increasing the Trotter order systematically reduces -# the operator-splitting error in this example, as confirmed by comparison -# with dense Liouvillian evolution. -# - Trotter order and timestep are complementary accuracy controls: -# higher-order formulas perform more gate applications per step, whereas a -# smaller `dt` increases the number of steps. +# !!! tip " Try changing" +# These edits test which parts of ``\bar n_\uparrow`` and ``\overline X`` +# come from the field, the Ising coupling, and the timestep. Rebuild the +# Hamiltonian or the jump list and rerun `tebd` from the initial all-`Up` +# state. +# - Set `transverse_field = 0`. The Up density should follow +# ``\bar n_\uparrow(t)=e^{-\gamma t}``. The Ising coupling leaves that +# law unchanged. +# - Set `J = 0` and keep the field and the loss. ``\overline X`` stays +# zero for a product of independent spins that start in `Up`. +# - Use the companion length ``N=4``, halve `dt`, and keep the same final +# time. Compare the late population and ``\overline X`` with that +# script's dense reference at the same ``J``, ``h``, and ``\gamma``. diff --git a/docs/literate/examples/driven_dissipative_bose_hubbard.jl b/docs/literate/examples/driven_dissipative_bose_hubbard.jl index 4d18131..3af8e26 100644 --- a/docs/literate/examples/driven_dissipative_bose_hubbard.jl +++ b/docs/literate/examples/driven_dissipative_bose_hubbard.jl @@ -9,18 +9,10 @@ # # Driven-dissipative Bose–Hubbard dynamics # -# This example simulates an open bosonic chain with a smoothly switched-on -# coherent pump and local particle loss. Its main purpose is to show that the -# same function-based approach used for a time-dependent Hamiltonian extends -# directly to dissipative dynamics: -# -# 1. evaluate the physical Hamiltonian at the midpoint of the current interval, -# 2. combine it with the fixed jump operators to construct a Liouvillian MPO, -# 3. evolve the vectorized density matrix with ordinary two-site TDVP. -# -# We compare several onsite interaction strengths while keeping the pump, -# hopping, and loss fixed. This simple example shows the competing behavior -# of pumping and particle loss in a bosonic lattice. +# How does the onsite interaction change the mean occupation of a coherently +# pumped Bose–Hubbard chain with local particle loss? The observable is +# ``\bar n(t)``. ``F(t)`` is the coherent pump amplitude, held on the same +# schedule while ``U`` changes. # # !!! related "Related material" # - Tutorial: [Dissipative Dynamics](@ref) for Lindblad / Liouville TDVP @@ -28,12 +20,13 @@ # - Performance tips for long TDVP runs: [Advanced Usage](@ref) # # !!! script "Companion script" -# Advanced figures for the driven-dissipative Bose–Hubbard scan are generated by +# The two-panel figure below is generated by # [`scripts/driven_dissipative_bose_hubbard.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/main/scripts/driven_dissipative_bose_hubbard.jl). +# This page evolves one interaction, ``U=0.75``. # ## Model # -# In a frame rotating with the coherent pump, +# In a frame rotating with the pump, and with ``\hbar=1``, # # ```math # H(t) @@ -46,41 +39,49 @@ # \right) # -\Delta\sum_{j=1}^{N}n_j # +\frac{U}{2}\sum_{j=1}^{N}n_j(n_j-1) -# +F(t)\sum_{j=1}^{N}\left(a_j+a_j^\dagger\right), +# +F(t)\sum_{j=1}^{N}\left(a_j+a_j^\dagger\right). # ``` # -# where ``J`` is the hopping strength, ``U`` is the onsite interaction, -# ``\Delta`` is the pump detuning, and ``F(t)`` is the time-dependent coherent -# pump amplitude. This model is widely used as a minimal description of driven -# nonlinear cavity arrays and other interacting bosonic open systems. -# -# The pump term does not add particles incoherently. Instead, it drives a -# phase-coherent displacement of every bosonic mode. Starting from vacuum, it -# raises the occupation of the lattice while hopping redistributes particles -# and the onsite interaction makes multiple occupation increasingly costly. -# -# Local one-particle loss is described by +# ``F(t)`` displaces every mode coherently. Hopping moves particles between +# sites. It does not create them. Local loss is # # ```math # \frac{d\rho}{dt} # = # -i[H(t),\rho] # + -# \kappa\sum_{j=1}^{N}\mathcal{D}[a_j]\rho . +# \kappa\sum_{j=1}^{N}\mathcal{D}[a_j]\rho, # ``` # -# with +# with # # ```math -# \mathcal{D}[a_j]\rho -# = -# a_j\rho a_j^\dagger -# - \frac{1}{2}\left(a_j^\dagger a_j \rho + \rho a_j^\dagger a_j\right). +# \mathcal{D}[a_j]\rho +# = +# a_j\rho a_j^\dagger +# -\frac12\{a_j^\dagger a_j,\rho\}. # ``` # +# The chain starts in the vacuum. The monitored density is +# +# ```math +# \bar n(t) +# = +# \frac{1}{N}\sum_{j=1}^{N}\operatorname{Tr}[n_j\rho(t)]. +# ``` +# +# The pump envelope is +# +# ```math +# F(t) +# = +# F_0\left[ +# 1-\exp\left(-\frac{t^2}{2\tau^2}\right) +# \right]. +# ``` + # ### Parameters and setup -using Printf using ITensors using ProcessTensors @@ -105,25 +106,13 @@ initial_density = to_dm(initial_state) initial_density_liouville = to_liouville(initial_density; sites=liouville_sites) -# The tuple convention `(rate, operator, site)` adds -# `rate * D[operator_site]` to the Liouvillian. These jump operators remain -# unchanged while the Hamiltonian is rebuilt in time. +# The tuple `(rate, operator, site)` adds `rate * D[operator]` on that site. +# The jumps stay fixed while the Hamiltonian is rebuilt at each midpoint. loss_jump_operators = [ (loss_rate, "A", j) for j in 1:N ] -# We monitor only the mean occupation, -# -# ```math -# \bar n(t) -# = -# \frac{1}{N}\sum_{j=1}^{N}\operatorname{Tr}[n_j\rho(t)], -# ``` -# to observe the dynamics of the system under driven pumping. -# Vectorizing the observable lets us evaluate it directly through the -# Hilbert--Schmidt inner product with the Liouville-space density MPS. (link back to the theory section where this was stated) - mean_occupation_operator = let observable = OpSum() for j in 1:N observable += 1 / N, "N", j @@ -131,29 +120,15 @@ mean_occupation_operator = let observable = OpSum() observable end -mean_occupation_mpo = MPO(mean_occupation_operator, physical_sites) -mean_occupation_liouville = - to_liouville(mean_occupation_mpo; sites=liouville_sites) +mean_occupation_liouville = to_liouville( + MPO(mean_occupation_operator, physical_sites); + sites=liouville_sites, +) @assert isapprox(real(tr(initial_density)), 1.0; atol=1e-12) -# ### Time-dependent Hamiltonian -# -# We use -# -# ```math -# F(t) -# = -# F_0\left[ -# 1-\exp\left(-\frac{t^2}{2\tau^2}\right) -# \right]. -# ``` -# -# The pump begins at zero and smoothly approaches ``F_0``. Unlike a pulse that -# later switches off, this protocol lets the occupation rise and settle under -# the continued competition between driving and loss. -# Calling `driven_bose_hubbard_hamiltonian(t)` returns the `OpSum` at time `t`. -# The interaction uses ``n(n-1)=n^2-n``. +# `driven_bose_hubbard_hamiltonian(t)` is the `OpSum` at time `t`. The +# interaction uses ``n(n-1)=n^2-n``. pump_amplitude(t::Real) = pump_strength * ( @@ -177,15 +152,6 @@ function driven_bose_hubbard_hamiltonian(t::Real) return H end -H_final_linear = - driven_bose_hubbard_hamiltonian(final_time) -L_final_linear = liouvillian_mpo( - H_final_linear, - liouville_sites; - jump_ops=loss_jump_operators, -) -@assert length(L_final_linear) == N - # ## Midpoint Liouville TDVP # # The vectorized density matrix obeys @@ -196,7 +162,7 @@ L_final_linear = liouvillian_mpo( # \mathcal{L}(t)|\rho(t)\rangle\rangle . # ``` # -# On each interval ``[t_n,t_n+\Delta t]``, we construct +# On each interval ``[t_n,t_n+\Delta t]`` the step holds # # ```math # \mathcal{L}_{n+\frac12} @@ -204,11 +170,9 @@ L_final_linear = liouvillian_mpo( # \mathcal{L}\left(t_n+\frac{\Delta t}{2}\right) # ``` # -# and hold that Liouvillian fixed for one two-site TDVP step. The timestep is -# the real duration `dt` because the Hamiltonian factor ``-i`` is already -# included in the Liouvillian. -# -# The complete workflow of this is `OpSum` → `liouvillian_mpo` → `tdvp` for each timestep. +# fixed. `liouvillian_mpo` builds that generator from the midpoint `OpSum` and +# the loss jumps, and `tdvp` advances the Liouville MPS by the real interval +# `dt`. nsteps = round(Int, final_time / dt) times = collect(range(0.0; step=dt, length=nsteps + 1)) @@ -255,47 +219,67 @@ trajectory = let ) end +println(( + U=interaction, + final_time=final_time, + final_n=trajectory.occupations[end], + max_trace_error=maximum(trajectory.trace_errors), + max_bond_dimension=maximum(trajectory.bond_dimensions), +)) + @assert all(isfinite, trajectory.occupations) @assert all( n -> -1e-8 ≤ n ≤ local_dim - 1 + 1e-8, trajectory.occupations, ) +@assert maximum(trajectory.trace_errors) < 1e-2 -max_trace_error = maximum(trajectory.trace_errors) -@assert max_trace_error < 1e-2 -if max_trace_error > 5e-4 - @warn "Trace drift exceeds the soft example tolerance." max_trace_error=max_trace_error -end - -println("Driven-dissipative Bose–Hubbard (single U)") -@printf(" U = %.3f, final n̄ = %.6f\n", interaction, trajectory.occupations[end]) -@printf(" max trace error = %.3e\n", max_trace_error) -@printf(" max bond dim = %d\n", maximum(trajectory.bond_dimensions)) - -# ## Physical response -# -# The script-generated figure places the common pump turn-on above the mean -# occupation curves for all three interaction strengths. Becayse every run uses -# the same drive, hopping, and loss, differences between them isolate the effect of on-site interaction. +# ## What the occupation shows +# +# The cells above use one value, ``U=0.75``, on ``N=4`` sites with +# `local_dim=3`, hopping ``0.5``, ``\Delta=0``, ``F_0=0.30``, ``\tau=0.6``, +# ``\kappa=0.8``, ``\Delta t=0.1``, and a window ending at ``t=5``, with +# `maxdim=60`. The figure is the companion scan ``U=0``, ``0.75``, and +# ``2.5`` at ``N=5``, `local_dim=5`, hopping ``0.2``, through ``t=7``, with +# `maxdim=50`. The pump, detuning, loss, and ``\Delta t`` match. # # ![Pump amplitude and mean occupation for several interaction strengths](../assets/examples/driven_dissipative_bose_hubbard.png) # -# Initially, there are no particles in the lattice. -# As the pump turns on, it raises the particle number, and reaches a constant pump ratefor a brief time-window. -# However, as the time progresses, local loss prevents -# unbounded growth, thus plateauing to a constant particle occupation. -# The on-site interaction strength plays an important role in deciding the final particle occupation. -# If the interaction strength is too strong, the cost of adding more bosons to a site increases, thus leading to a lower final particle occupation. -# If there is no interaction, the particle excitations caused by hopping are not in good resonance with the pump. -# The lossy bosonic system, therefore, has the largest particle occupation at an intermediate interaction strength. -# -# !!! note "Numerical accuracy" -# The main approximations are the midpoint timestep, the local bosonic -# cutoff `local_dim - 1`, TDVP projection, and two-site truncation controlled -# by `cutoff` and `maxdim`. The printed trace error is a compact diagnostic -# for the Liouville-space evolution. +# The pump amplitude has levelled near ``F_0`` by ``t\approx 2``. The +# occupation rises later: it lags the pump. At the right edge of the plot the +# curves are still increasing, so this window does not show a steady state. +# There, ``U=0.75`` lies slightly above ``U=0``, and ``U=2.5`` lies lower. +# For these parameters and this window the dependence on ``U`` is +# nonmonotonic. +# +# Interaction shifts the energy of transitions that change the occupation. +# Whether that shift moves a transition through resonance with the pump is a +# hypothesis. These three curves do not establish it; a detuning scan would +# be a separate calculation. +# +# For hopping ``J=0``, interaction ``U=0``, and an untruncated driven lossy +# oscillator, the steady occupation is +# +# ```math +# n_{\mathrm{ss}} +# = +# \frac{F_0^2}{\Delta^2+(\kappa/2)^2}. +# ``` +# +# The plotted chains have hopping, a finite `local_dim`, and occupations that +# are still rising, so that formula is a reference for a different run. +# A trace near 1 does not establish that ``\bar n(t)`` has converged in +# timestep, bond dimension, or local dimension. # -# !!! summary "Example takeaways" -# - A Julia function returning an `OpSum` is enough to define ``H(t)``. -# - Rebuild the midpoint Liouvillian from that `OpSum` and fixed jump operators. -# - Mean occupation and trace drift expose the driven-loss response. +# !!! tip " Try changing" +# These edits test the steady-occupation reference, the role of detuning, +# and whether the small intermediate-``U`` gap survives the local cutoff. +# Rebuild the midpoint Liouvillian and rerun `tdvp` from the vacuum. +# - Set `hopping = 0` and `interaction = 0`. Compare ``\bar n(t)`` with +# ``n_{\mathrm{ss}}`` only on a longer window, and repeat the run at a +# larger `local_dim`. +# - Repeat ``U=0``, ``0.75``, and ``2.5`` at several values of `detuning`. +# Record which curve ends highest; the present figure does not fix that +# order. +# - Increase `local_dim` at the figure's parameters and see whether the +# small gap between ``U=0.75`` and ``U=0`` remains. diff --git a/docs/literate/examples/laser_driven_tdvp.jl b/docs/literate/examples/laser_driven_tdvp.jl index 4f23081..81aa7d7 100644 --- a/docs/literate/examples/laser_driven_tdvp.jl +++ b/docs/literate/examples/laser_driven_tdvp.jl @@ -9,21 +9,21 @@ # # Laser-driven TDVP dynamics # -# This example shows how an ordinary Julia function can represent a -# time-dependent Hamiltonian. At each timestep, we evaluate that function at -# the interval midpoint, construct an MPO, and pass it to the usual TDVP -# routine. +# Does a Gaussian pulse leave residual Up population in a closed Ising chain, +# and what does it do to the energy density? Excitation here means Up +# population in the ``Z`` basis. The energy density is the expectation of the +# instantaneous Hamiltonian, divided by ``N``. # # !!! related "Related material" # - Tutorial: [Unitary Dynamics](@ref) (Hilbert-space TDVP basics) # # !!! script "Companion script" -# Advanced figures for the laser-driven chain are generated by +# The figure below is generated by # [`scripts/laser_driven_tdvp.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/main/scripts/laser_driven_tdvp.jl). # ## Laser-driven spin chain # -# We consider a closed interacting spin chain driven by a Gaussian pulse, +# With ``\hbar=1`` and time in units of ``1/J``, # # ```math # H(t) @@ -33,7 +33,7 @@ # +\frac{\Omega(t)}{2}\sum_{j=1}^{N} X_j , # ``` # -# where +# where the envelope is # # ```math # \Omega(t) @@ -42,16 +42,22 @@ # \exp\left[-\frac{(t-t_c)^2}{2\sigma^2}\right]. # ``` # -# The longitudinal field sets the detuning, the Gaussian transverse field -# drives coherent spin flips, and the Ising interaction makes the response -# genuinely many-body. Starting from -# ``|\psi(0)\rangle=|\downarrow\cdots\downarrow\rangle``, we monitor the -# instantaneous energy density and the mean excitation density +# ``X`` and ``Z`` are Pauli operators on `S=1/2` sites. The chain starts in +# ``|\downarrow\cdots\downarrow\rangle``. The two recorded densities are # # ```math -# \bar n(t)=\frac{1}{N}\sum_j -# \left\langle\frac{I+Z_j}{2}\right\rangle . +# \bar n(t) +# = +# \frac{1}{N}\sum_{j=1}^{N} +# \left\langle\frac{I+Z_j}{2}\right\rangle_t , +# \qquad +# e(t) +# = +# \frac{1}{N}\langle\psi(t)|H(t)|\psi(t)\rangle . # ``` +# +# ``\bar n`` counts Up population. It is not the energy above the instantaneous +# ground state. ``e(t)`` is that separate comparison. # ### Model and parameters @@ -73,8 +79,8 @@ const cutoff = 1e-10 sites = siteinds("S=1/2", N) initial_state = MPS(sites, fill("Dn", N)) -# The pulse envelope and Hamiltonian are ordinary Julia functions. Calling -# `laser_driven_hamiltonian(t)` produces the `OpSum` at time `t`. +# `laser_driven_hamiltonian(t)` returns the `OpSum` at time `t`. Each TDVP +# step freezes the MPO of that operator at the midpoint of the step. gaussian_drive(t::Real) = Ω0 * exp(-((t - pulse_center)^2) / (2pulse_width^2)) @@ -91,35 +97,20 @@ function laser_driven_hamiltonian(t::Real) return H end -H_at_pulse_center = MPO(laser_driven_hamiltonian(pulse_center), sites) -@assert length(H_at_pulse_center) == N - # ## Midpoint TDVP evolution # -# On an interval ``[t_n,t_n+\Delta t]``, the midpoint approximation uses -# -# ```math -# H_{n+\frac12}=H\left(t_n+\frac{\Delta t}{2}\right) -# ``` -# -# for one ordinary TDVP step, +# On ``[t_n, t_n+\Delta t]`` the step uses # # ```math +# H_{n+\frac12}=H\left(t_n+\frac{\Delta t}{2}\right), +# \qquad # |\psi(t_{n+1})\rangle # \approx # \exp[-i\Delta t\,H_{n+\frac12}]|\psi(t_n)\rangle . # ``` # -# We use two-site TDVP (`nsite=2`) so that the MPS bond dimensions can grow as -# the drive and interactions generate entanglement. The core workflow is the -# midpoint `OpSum` → `MPO` → `tdvp` sequence visible inside this loop. -# -# !!! note "Time-dependent Hamiltonians in ITensorMPS TDVP" -# In earlier versions of ITensorMPS, time-dependent Hamiltonians were defined -# using the `TimeDependentHamiltonian` type. This method is now deprecated. -# The current recommended approach is to define an ordinary Julia function that -# returns an `OpSum` (or `MPO`) for a given time. This function is then called -# at each time step to construct the appropriate Hamiltonian for the TDVP evolution. +# Two-site TDVP (`nsite=2`) lets the bond dimension grow. The loop builds the +# midpoint MPO and passes it to `tdvp`. nsteps = round(Int, final_time / dt) times = collect(range(0.0; step=dt, length=nsteps + 1)) @@ -155,36 +146,68 @@ trajectory = let end ( - final_state=ψ, energy_density=energies, excitation_density=excitations, norm_error=norm_errors, ) end +println(( + N=N, + final_time=final_time, + final_energy_density=trajectory.energy_density[end], + final_excitation=trajectory.excitation_density[end], + max_norm_error=maximum(trajectory.norm_error), +)) + @assert maximum(trajectory.norm_error) < 1e-8 @assert all(x -> -1e-10 ≤ x ≤ 1 + 1e-10, trajectory.excitation_density) @assert all(isfinite, trajectory.energy_density) -# ## Physical response +# ## What the pulse does # -# The energy is not conserved because the external pulse performs work on the -# chain. The excitation density measures the coherent population transferred -# away from the initial unexcited product state. The shaded interval in the -# script-generated figure marks one pulse width on either side of the pulse -# center. The plotting script uses a longer ``N=12`` run than the compact -# executable example above, but follows exactly the same midpoint workflow. +# The cells above use ``N=6``, pulse center ``t_c=1.2``, width ``\sigma=0.35``, +# and end at ``t=2.4``, with `maxdim=60`. The figure is the companion chain: +# ``N=12``, ``t_c=3.0``, ``\sigma=0.7``, through ``t=6``, with `maxdim=100`. +# Both use ``J=0.4``, detuning ``1``, ``\Omega_0=2.5``, and ``\Delta t=0.1``. +# The shaded band is one pulse width on either side of the center. # # ![Instantaneous energy and excitation density for the laser-driven chain](../assets/examples/laser_driven_tdvp.png) # -# !!! note "Numerical accuracy" -# The midpoint approximation must resolve the pulse, so `dt` should be much -# smaller than `pulse_width`. Two-site TDVP also introduces MPS truncation -# error controlled by `cutoff` and `maxdim`; the norm check above provides a -# compact diagnostic for this unitary evolution. -# -# !!! summary "Example takeaways" -# - A Julia function returning an `OpSum` is enough to define ``H(t)``. -# - Construct the midpoint MPO before each ordinary two-site TDVP step. -# - For a driven system, instantaneous energy and excitation density expose -# complementary effects of the applied pulse. +# The excitation stays near zero until the pulse, rises through the shaded +# interval, and then falls to a nonzero residual. That fall is coherent +# evolution under the closing pulse. This Hamiltonian has no dissipator. +# +# The energy density decreases through the pulse and becomes negative, so the +# drive extracts energy relative to the initial state. It then recovers part +# of that drop. After the pulse, ``\Omega(t)`` is negligible and ``H`` is +# diagonal in the ``Z`` product basis, so the local ``Z`` populations and the +# energy of that static Hamiltonian stop changing. The late plateau is that +# frozen coherent evolution. +# +# When ``J=0`` and ``\Delta=0``, a product of spins that start in +# ``|\downarrow\rangle`` has the pulse-area population +# +# ```math +# \bar n(t)=\sin^2\!\left[\Theta(t)/2\right], +# \qquad +# \Theta(t)=\int_0^t \Omega(s)\,ds . +# ``` +# +# The plotted chain has both ``J`` and ``\Delta`` nonzero, so that formula is +# the reference for the first edit below. A norm that stays near 1 does not +# establish that the peak or the residual has converged in ``\Delta t`` or +# bond dimension. +# +# !!! tip " Try changing" +# These edits test the pulse-area law, the separate roles of width and +# detuning, and whether the peak and residual move when the step is +# refined. Rebuild the midpoint MPO and rerun `tdvp` from the initial +# product state. +# - Set `J = 0` and `detuning = 0`. Compare ``\bar n(t)`` with +# ``\sin^2[\Theta(t)/2]``. +# - Keep the pulse area fixed and compare a narrow pulse with a broad one, +# on resonance and at nonzero detuning. Place the pulse tails inside the +# time window, and choose `dt` so that the narrow pulse is resolved. +# - Halve `dt` at fixed final time. Compare the peak excitation and the +# residual excitation. A smaller norm error is a separate check. diff --git a/docs/literate/examples/multitime_correlations.jl b/docs/literate/examples/multitime_correlations.jl index a960a71..23d1db0 100644 --- a/docs/literate/examples/multitime_correlations.jl +++ b/docs/literate/examples/multitime_correlations.jl @@ -9,39 +9,35 @@ # # Multi-time correlations # -# A process tensor stores how an environment carries information between -# different intervention times. Once it is built, changing the instruments on -# its time legs gives different multi-time observables without rebuilding the -# system–environment dynamics. -# -# Here we evaluate +# A process tensor stores how an environment carries information between different intervention times. Once built, we can use custom instruments to probe many of its properties. One such object of interest that often appears in open quantum systems is the two-time correlation function. The observable on +# this page is # # ```math # C_{zz}(t_2,t_1) # = -# \langle \sigma_z(t_2)\sigma_z(t_1)\rangle +# \langle \sigma_z(t_2)\sigma_z(t_1)\rangle . # ``` # -# for a system spin coupled to one bath spin. -# # !!! related "Related material" -# - Tutorial: [Single-Mode Process Tensor](@ref) (instruments / `evaluate_process`) +# - Tutorial: [Process tensor instruments](@ref) # - Theory: [Process Tensors](../theory/process_tensors.md) # # !!! script "Companion script" -# Advanced three-correlator figures are generated by +# The three-correlator figure below is generated by # [`scripts/pt_multitime_correlations.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/main/scripts/pt_multitime_correlations.jl). -# ## Spin-bath process tensor +# ## Model # -# We use +# With ``\hbar=1``, # # ```math -# H_S=S_x,\qquad H_E=S_x,\qquad H_{SE}=S_z\otimes S_z, +# H_S=S^x,\qquad H_E=S^x,\qquad H_{SE}=S^z\otimes S^z . # ``` # -# with the system initially in ``|\mathrm{Dn}\rangle`` and the bath in -# ``|\mathrm{Up}\rangle``. +# The system starts in ``|\mathrm{Dn}\rangle`` and the bath spin in +# ``|\mathrm{Up}\rangle``. The inserted operators are Pauli operators. In the +# code they are `2 * Sz`, `2 * Sx`, and `2 * Sy`, because ``S^\alpha=\sigma_\alpha/2`` +# on an `S=1/2` site. using ITensors using LinearAlgebra: diag @@ -77,6 +73,11 @@ bath_mode = spin_mode( ) bath = spin_bath([bath_mode]) +# ## Build the process tensor +# +# `build_process_tensor` stores the influence of this bath on the system time +# legs. Later grids change only the instruments. + process_tensor = build_process_tensor( system, system.sites[1]; @@ -85,47 +86,43 @@ process_tensor = build_process_tensor( nsteps=pt_nsteps, ) -@assert process_tensor.nsteps == pt_nsteps -@assert process_tensor.dt == dt - # ## Two-time instrument schedules # -# A two-time correlator is not computed by first producing a reduced trajectory -# and then multiplying numbers. The operator insertions are part of the process. +# For ``t_2>t_1``, write ``U(t)`` for the joint system–bath evolution and +# ``\rho_{SE}`` for the joint state. The operators ``A`` and ``B`` act on the +# system and as the identity on the bath: # -# For `t₂ > t₁`, the two-time correlation of `A` and `B` is given as # ```math # \begin{aligned} -# \langle A(t_2) B(t_1) \rangle -# &= \operatorname{Tr}[U^{\dagger}(t_2) A\, U(t_2-t_1)\, B\, U(t_1)\, \rho(0)] \\ -# &= \operatorname{Tr}[U^{\dagger}(t_2-t_1) A\, U(t_2-t_1)\, B\, \rho(t_1)] \\ -# &= \operatorname{Tr}\left[A\, U(t_2-t_1)\, B\, \rho(t_1)\, U^{\dagger}(t_2-t_1)\right] \\ -# &= \operatorname{Tr}\left[A\, U(t_2-t_1)\, \rho_{B_L}(t_1)\, U^{\dagger}(t_2-t_1)\right] \\ -# &= \operatorname{Tr}\left[A\, \rho_{B_L}(t_2)\right]. +# \langle A(t_2)B(t_1)\rangle +# &= +# \operatorname{Tr}\!\left[ +# U(t_2)^\dagger (A\otimes I)\, +# U(t_2-t_1)\,(B\otimes I)\, +# U(t_1)\,\rho_{SE}(0) +# \right] \\ +# &= +# \operatorname{Tr}\!\left[ +# (A\otimes I)\, +# U(t_2-t_1)\,(B\otimes I)\,\rho_{SE}(t_1)\, +# U(t_2-t_1)^\dagger +# \right]. # \end{aligned} # ``` # -# In simple words, we evolve $\rho(0)$ forward to $t_1$, aply B from the _left_, -# evolve the resultant object to $t_2$, and measure the expectation of $A$. -# This can be implemented as the following instrument sequence: -# ```text -# t = 0 : state_preparation(ρ₀) -# t = t₁ : insert B from the left -# t = t₂ : insert A -# after t₂ : propagate with identity operations and TraceOut -# ``` -# -# For `t₁ > t₂`, the earlier operator and the later operator exchange roles. -# The implementation must also keep track of whether the early insertion acts -# from the left or from the right, so that the product ordering in -# `⟨A(t₂)B(t₁)⟩` is represented correctly. +# The object ``(B\otimes I)\rho_{SE}(t_1)`` is an auxiliary operator. The same +# joint evolution carries it from ``t_1`` to ``t_2``, and the trace against +# ``A\otimes I`` is the correlator. Propagating that operator is a bookkeeping +# device for the ordered product. It is separate from preparing a +# post-measurement state, and this contraction does not replace the joint +# state by a reduced state at the intermediate time. # -# On the diagonal `t₂ = t₁`, both operators are inserted at the same time. The -# same-time instrument represents the product at that single time leg. -# -# The package helper `two_time_correlation_seq` builds this `InstrumentSeq` for -# us, considering all the above cases. Then `evaluate_process` contracts the -# process tensor with that sequence. +# `two_time_correlation_seq` turns that ordering into an `InstrumentSeq`: +# prepare ``\rho_0`` at the first leg, insert the earlier operator, insert the +# later operator, and use identity operations elsewhere. If ``t_1>t_2``, the +# earlier and later insertions exchange, including whether the early insertion +# acts from the left or the right. On ``t_2=t_1`` both operators sit on the +# same leg. `evaluate_process` contracts the sequence with the process tensor. system_state = to_dm(MPS(system_sites, ["Dn"])) @@ -146,8 +143,7 @@ correlation = evaluate_process(process_tensor, sequence) # ## Correlation grid # -# Each grid entry differs only by the two insertion times. The same process -# tensor is reused throughout: +# Each entry uses the same process tensor and a new pair of insertion times. time_indices = 0:(n_times - 1) times = dt .* collect(time_indices) @@ -167,57 +163,69 @@ Czz = [ diagonal_error = maximum(abs.(diag(Czz) .- 1)) conjugate_error = maximum(abs.(Czz - Czz')) +println(( + n_times=n_times, + final_time=times[end], + sample_correlation=correlation, + diagonal_error=diagonal_error, + conjugate_error=conjugate_error, +)) @assert all(isfinite, Czz) @assert diagonal_error < 1e-10 @assert conjugate_error < 1e-10 - -# You can run the dedicated script to evaluate the three correlator grids to produce the -# following heatmaps: -# -# ![Two-time correlation heatmaps](../assets/examples/pt_multitime_correlations.png) +# ## What the heatmaps show # -# The first panel is an autocorrelation: +# The executable grid uses six times from ``0`` to ``2.5``, with +# ``\Delta t=0.5``. The figure is the companion script on the same Hamiltonian +# and the same initial state, extended to ``t_f=5.5``, for three operator pairs. # -# ```math -# C_{zz}(t_2,t_1) = -# \langle \sigma_z(t_2)\sigma_z(t_1)\rangle . -# ``` +# ![Two-time correlation heatmaps](../assets/examples/pt_multitime_correlations.png) # -# Since `σz` is Hermitian and `σz² = I`, the diagonal is a sanity check: +# The left column is the autocorrelation. Because ``\sigma_z`` is Hermitian and +# ``\sigma_z^2=I``, # # ```math -# C_{zz}(t,t) = 1 . +# C_{zz}(t,t)=1 . # ``` # -# Therefore the real diagonal should be close to one, and the imaginary -# diagonal should be close to zero. Off the diagonal, the autocorrelation -# satisfies the conjugate structure +# The real diagonal is one and the imaginary diagonal is zero, up to the +# contraction tolerance. Off the diagonal, # # ```math -# C_{zz}(t_2,t_1)^* = C_{zz}(t_1,t_2), +# C_{zz}(t_2,t_1)^*=C_{zz}(t_1,t_2), # ``` # -# so the real part is symmetric while the imaginary part is antisymmetric. +# so the real part is symmetric and the imaginary part is antisymmetric. # -# For cross-correlations, the structure is different. For example, +# The middle and right columns are cross-correlations. For the Pauli products # # ```math -# \sigma_z\sigma_x = i\sigma_y,\qquad -# \sigma_x\sigma_y = i\sigma_z . +# \sigma_z\sigma_x=i\sigma_y, +# \qquad +# \sigma_x\sigma_y=i\sigma_z, # ``` # -# Thus the equal-time diagonal of `⟨σz(t₂)σx(t₁)⟩` is expected to be mainly -# imaginary and related to `⟨σy(t)⟩`, while the equal-time diagonal of -# `⟨σx(t₂)σy(t₁)⟩` is related to `⟨σz(t)⟩`. Away from the diagonal, there is no -# reason for a single cross-correlation grid to be symmetric; the conjugate -# partner involves the reversed operator ordering. -# -# !!! summary "Example takeaways" -# - Build the process tensor once and reuse it for every pair of insertion -# times. -# - `two_time_correlation_seq` encodes the operator ordering as an -# `InstrumentSeq`. -# - `evaluate_process` contracts that sequence with the stored -# non-Markovian process. +# the equal-time value is purely imaginary in exact arithmetic: it equals +# ``i\langle\sigma_y(t)\rangle`` or ``i\langle\sigma_z(t)\rangle``. Away from +# that diagonal, the conjugate partner is the correlator with the operator +# order reversed. +# +# A diagonal that matches these identities checks the contraction at equal +# times. It does not by itself certify the off-diagonal entries, which depend +# on the joint evolution stored in the process tensor. +# +# !!! tip " Try changing" +# These comparisons test whether the grid is fixed by the system operators +# and the initial state, or whether the bath spin changes it. +# - Try reducing the coupling strength such that the process tensor is +# approximately Markovian. How do the correlation functions change? +# - Exchange the operator order and compare the new grid with the complex +# conjugate of the original partner. +# - Set the coupling to zero and build a new process tensor. Compare the +# grid with the isolated-spin correlator. +# +# Correlations at more than two times would place further system operators +# on those legs. One can use the same idea of applying left\right actions of +# operators and extend the analysis to obtain higher-order correlation functions. diff --git a/docs/literate/examples/noisy_quantum_circuit_tester.jl b/docs/literate/examples/noisy_quantum_circuit_tester.jl index ec2fb3a..180c8af 100644 --- a/docs/literate/examples/noisy_quantum_circuit_tester.jl +++ b/docs/literate/examples/noisy_quantum_circuit_tester.jl @@ -4,512 +4,229 @@ # File: docs/literate/examples/noisy_quantum_circuit_tester.jl #src # Contributor: Gauthameshwar S. #src # #src -# Demonstrates a noisy processor qubit coupled to a thermal bosonic process #src -# tensor and probed by a memory-bearing ancillary tester. #src +# Literate example: store, phase-tag, and retrieve with a persistent tester. #src # # Noisy quantum circuits with a memory-bearing tester # -# Noise in a quantum circuit does not have to forget what happened one gate -# ago. A coherent defect can keep ringing, a neighbouring degree of freedom can -# remain correlated with the computational qubit, leakage can survive between -# control pulses, and a structured environment can carry information from one -# circuit time to the next. In all of these cases the error at the next gate is -# allowed to depend on the **history of earlier interventions**. +# Can an ancillary qubit hold and manipulate a state while its processor remains +# coupled to a noisy environment? We prepare processor $Q$ in $|+\rangle$, +# SWAP its state into an isolated ancilla $A$, apply $Z_A$, then SWAP it back. +# A `Tester` carries the same ancilla through all three operations; `TesterSeq` +# specifies the circuit. The bath process tensor is built only once. # -# That is exactly the regime where process tensors become useful. Rather than -# assigning an independent noise channel to every circuit layer, a process -# tensor stores the multi-time response of the noisy hardware. This connection -# is already important in quantum-computing theory: Figueroa-Romero *et al.* -# formulate randomized benchmarking in the presence of temporally correlated -# noise using the process-tensor framework -# ([PRX Quantum 2, 040351 (2021)](https://doi.org/10.1103/PRXQuantum.2.040351)). -# Gandhari and Gullans make the connection even more concrete by studying -# qubit gates interspersed with interactions with a **multimode bosonic bath** -# ([Phys. Rev. Research 8, 023075 (2026)](https://doi.org/10.1103/z8zh-n1hl)). +# ![Processor, thermal bath, and the SWAP–Z–SWAP tester protocol](../assets/examples/noisy_quantum_circuit_tester_protocol.png) # -# We use that idea as a motivation to demonstrate a toy model for a noisy circuit wire. -# Characterising the non-Markovianity of noise in a quantum hardware is already an open problem. -# So, in this example, we take a simple case where we assume the noise comes from a thermal bosonic environment. -# We compress that environment into a reusable process tensor with ACE and use it to study the performance of our noisy circuit. -# -# Now suppose we probe the noisy processor qubit ``Q`` repeatedly using the **same ancillary qubit** ``A``. -# The ancilla survives between interventions, so information acquired at one time can influence what happens later. -# The probing sequence is therefore no longer a product of independent time-local instruments: it is a memory-bearing multi-time instrument, or **tester**. -# Memory-assisted quantum testers have an operational role in the theory of channels with memory; see Chiribella, D'Ariano and Perinotti ([Phys. Rev. Lett. 101, 180501 (2008)](https://doi.org/10.1103/PhysRevLett.101.180501)). -# -# Our circuit is deliberately tiny: -# ```text -# noisy process on Q -# ┌────┐ ┌────┐ ┌────┐ -# Q : |+> ─────┤ PT ├── ... ───┤SWAP├── PT ── PT ── ... ┤SWAP├── PT ── ... -# └────┘ └─┬──┘ └─┬──┘ -# │ │ -# A : |0> ───────────────────────┴────── Z ────────────────┴──────────── -# store phase-tag retrieve -# ``` -# -# Only ``Q`` sees the bosonic environment. The first SWAP parks the logical -# qubit state in ``A``. A ``Z`` gate phase-tags the stored state while it is -# outside the noisy wire. The second SWAP returns it to ``Q``. -# The main question we want to answer is: -# -# > Can a coherent ancilla store, manipulate, and return quantum information -# > while the physical processor qubit remains embedded in a noisy process with -# > memory? +# Time runs left to right. Only $Q$ couples directly to the bath; $A$ persists +# between controls. This is the ideal protocol schematic; the finite-interval +# convention used for the joint gates is stated beside the schedule below. # # !!! related "Related material" -# - [Thermal spin-boson dynamics using ACE](thermal_spinboson_ace.md) -# - Tutorial: [Single-Mode Process Tensor](@ref) +# - Tutorial: [Construct a process tensor](@ref) # - Theory: [Process Tensors](../theory/process_tensors.md) # -# !!! script "Companion calculation" -# The executable documentation uses a deliberately small bath. A larger -# ACE calculation, an idle-time sweep, and the staged figure are generated -# by -# [`scripts/noisy_quantum_circuit_tester.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/main/scripts/noisy_quantum_circuit_tester.jl). -# -# ## The three observables to measure during the experiment -# -# Before building anything, it helps to know what we actually want to measure. -# -# **1. Where is the phase information?** We prepare ``Q`` in ``|+\rangle``, so -# initially ``\langle X_Q\rangle=1``. A SWAP should move this signal from ``Q`` -# to ``A`` and later bring it back. We therefore follow both -# ``\langle X_Q\rangle`` and ``\langle X_A\rangle``. -# -# **2. How much coherence survives?** ``\langle X\rangle`` alone is not a fair -# measure after the ``Z`` gate: a perfectly coherent qubit can rotate from -# ``|+\rangle`` to ``|-\rangle`` and merely flip the sign of ``\langle X\rangle``. -# We therefore use -# -# ```math -# C_{xy}=\sqrt{\langle X\rangle^2+\langle Y\rangle^2}. -# ``` -# -# A coherent phase operation can rotate the Bloch vector without reducing -# ``C_{xy}``; genuine dephasing shrinks it. +# !!! script "Companion script" +# [`scripts/noisy_quantum_circuit_tester.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/better-docs/scripts/noisy_quantum_circuit_tester.jl) +# runs the larger bath calculation and generates the two-panel result. +# It reuses a cached process tensor when the bath settings match. + +# ## Build the noisy processor process # -# **3. Are ``Q`` and ``A`` actually correlated?** Local observables cannot -# answer this. For the joint trajectory we use the quantum mutual information +# The processor has no free Hamiltonian. Its pure-dephasing environment is # # ```math -# I(Q{:}A)=S(\rho_Q)+S(\rho_A)-S(\rho_{QA}), +# H_B=\sum_k\omega_k b_k^\dagger b_k,\qquad +# H_{QB}=Z_Q\sum_k g_k(b_k+b_k^\dagger),\qquad +# J(\omega)=2\alpha\omega e^{-\omega/\omega_c}. # ``` # -# with ``S(\rho)=-\mathrm{Tr}[\rho\log_2\rho]``. Mutual information counts all -# correlations, classical and quantum. +# Each mode starts in a truncated Gibbs state at $\theta=k_BT/\hbar$. Midpoint +# quadrature sets $g_k^2=J(\omega_k)\Delta\omega$. Here `Z` is the Pauli +# operator with eigenvalues $\pm1$, not `Sz`. Since $Z^2=I$, the usual static +# displacement counterterm would be proportional to the identity and is omitted. using LinearAlgebra using Logging -using Printf using ITensors using ITensors.Ops: Trotter using ProcessTensors -function density_matrix(state) - pairs = siteinds(state) - physical_sites = Index[ - only(filter(index -> plev(index) == 0, pair)) - for pair in pairs - ] - tensor = foldl(*, state) - dimension = prod(dim.(physical_sites)) - return reshape( - ComplexF64.(Array( - tensor, - prime.(physical_sites)..., - physical_sites..., - )), - dimension, - dimension, - ) -end - -function operator_matrix(name::AbstractString, site::Index) - return ComplexF64.(Array(op(name, site), prime(site), site)) -end - -expectation(rho, operator) = real(tr(operator * rho) / tr(rho)) - -function entropy_bits(rho) - normalized = (rho + rho') / (2real(tr(rho))) - probabilities = clamp.(real.(eigvals(Hermitian(normalized))), 0, Inf) - probabilities ./= sum(probabilities) - return -sum(p * log2(p) for p in probabilities if p > eps(Float64)) -end - -mutual_information(rho_Q, rho_A, rho_QA) = - entropy_bits(rho_Q) + entropy_bits(rho_A) - entropy_bits(rho_QA) - -# ## Turn a microscopic bath into a noisy circuit process -# -# We choose the pure-dephasing spin-boson model -# -# ```math -# H_B=\sum_k\omega_k b_k^\dagger b_k, -# \qquad -# H_{QB}=Z_Q\sum_k g_k(b_k+b_k^\dagger). -# ``` -# -# There is no free Hamiltonian on ``Q``. Without the bath, ``|+\rangle_Q`` -# would therefore sit still forever. Any decay or revival of its transverse -# coherence is generated by the environment. -# -# The oscillator couplings are summarized by the Ohmic spectral density -# -# ```math -# J(\omega)=2\alpha\,\omega\,e^{-\omega/\omega_c}. -# ``` -# -# ``\alpha`` sets the overall coupling, while -# ``\omega_c`` suppresses modes far above the characteristic bath frequency -# scale. At low frequency the Ohmic spectrum grows linearly with ``\omega``. -# -# A finite simulation replaces the continuum by frequency bins. For midpoint -# frequencies ``\omega_k`` and bin width ``\Delta\omega``, we choose -# -# ```math -# g_k^2\simeq J(\omega_k)\Delta\omega. -# ``` -# -# The coupling constants below are therefore discretized samples of one smooth -# environment rather than four unrelated fitting parameters. - -const N_bath = 4 -const local_dim = 3 -const alpha = 0.008 -const omega_cutoff = 4.0 -const omega_max = 20.0 - -# Temperature is quoted in the natural frequency unit k_B T / ħ. The companion -# script uses 2.5, which gives the lowest-frequency mode on this coarse grid a -# visible thermal occupation while the higher modes become progressively colder. -const thermal_frequency = 2.5 - -const dt = 0.15 -const nsteps = 14 -const ace_cutoff = 1e-5 -const ace_maxdim = 64 - -frequency_spacing = omega_max / N_bath -frequencies = [(k - 0.5) * frequency_spacing for k in 1:N_bath] -spectral_weights = 2alpha .* frequencies .* exp.(-frequencies ./ omega_cutoff) -couplings = sqrt.(spectral_weights .* frequency_spacing) - -# Four modes and three local Fock states are intentionally under-resolved. They -# are enough to produce a structured finite-memory process while keeping the -# documentation build quick. With this grid, the lowest mode has -# ω_1 = k_B T / ħ, so its thermal occupation is appreciable; the higher modes -# are much closer to their ground states. The companion script is the place for -# larger mode counts, boson cutoffs, and convergence checks. +N_bath, local_dim = 4, 3 +alpha, omega_cutoff, omega_max = 0.008, 4.0, 20.0 +thermal_frequency = 2.5 +dt, nsteps = 0.15, 14 +ace_cutoff, ace_maxdim = 1e-5, 64 processor_sites = siteinds("Qubit", 1) processor = qubit_system(processor_sites) rho_Q0 = to_dm(MPS(processor_sites, ["+"])) - +Δω = omega_max / N_bath +frequencies = [(k - 0.5) * Δω for k in 1:N_bath] +couplings = sqrt.(2alpha .* frequencies .* exp.(-frequencies ./ omega_cutoff) .* Δω) bath_sites = siteinds("Boson", N_bath; dim=local_dim) bath_liouville_sites = liouv_sites(bath_sites) -# Each `BosonicMode` supplies one microscopic piece of the environment: -# `H_mode` gives ω_k b†b, `rho_mode` gives the truncated Gibbs state, and -# `H_coupling` gives g_k(b+b†)Z_Q. In the local `OpSum`, site 1 is the -# oscillator and site 2 is the processor qubit. - -modes = [ - let - omega = frequencies[k] - coupling = couplings[k] - - occupations = 0:(local_dim - 1) - thermal_weights = exp.(-omega .* occupations ./ thermal_frequency) - thermal_weights ./= sum(thermal_weights) - - number_states = [ - MPS([bath_sites[k]], [string(n)]) - for n in occupations - ] - - rho_mode = to_liouville( - to_dm(number_states; coeffs=thermal_weights); - sites=[bath_liouville_sites[k]], - ) - - H_mode = OpSum() + (omega, "N", 1) - H_coupling = OpSum() - H_coupling += coupling, "A", 1, "Z", 2 - H_coupling += coupling, "Adag", 1, "Z", 2 - - bosonic_mode( - [bath_liouville_sites[k]], - H_mode, - rho_mode; - coupling=H_coupling, - ) - end for k in 1:N_bath -] - -bath = with_logger(NullLogger()) do - bosonic_bath(modes) +modes = BosonicMode[] +for k in 1:N_bath + H_mode = OpSum() + (frequencies[k], "N", 1) + coupling = OpSum() + coupling += couplings[k], "A", 1, "Z", 2 + coupling += couplings[k], "Adag", 1, "Z", 2 + push!(modes, thermal_mode([bath_liouville_sites[k]], H_mode, + thermal_frequency; coupling=coupling)) end - -# ## Compress the bath memory with ACE -# -# This is the one heavy-looking constructor block, so it is worth saying what it -# buys us. Direct propagation of ``Q`` together with all oscillators grows -# exponentially with the number of bath modes. ACE instead adds their -# influences one at a time and compresses the **temporal memory bonds** between -# process-tensor cores. -# -# Everything above is microscopic input. The call below turns it into the object -# we actually want: -# -# ```math -# \boxed{\text{thermal spin-boson noise}\longrightarrow -# \text{reusable noisy circuit process}}. -# ``` -# -# The implementation follows the ACE construction of Cygorek and Gauger -# ([J. Chem. Phys. 161, 074111 (2024)](https://doi.org/10.1063/5.0221182)). -# Once `process_tensor` exists, changing the tester circuit does not rebuild the -# bosonic bath. - -build_seconds = @elapsed begin - process_tensor = build_process_tensor( - processor; - method=ACE(cutoff=ace_cutoff, maxdim=ace_maxdim), - environment=bath, - dt, - nsteps, - sys_alg=Trotter{2}(), - combine_alg=Trotter{2}(), - progress=false, - ) -end - -println(process_tensor) -@printf( - "ACE build: %.3f s, maximum PT bond dimension: %d\n", - build_seconds, - maxlinkdim(process_tensor), +# The suppressed size warning concerns joint Dense construction, not this ACE run. +bath = with_logger(() -> bosonic_bath(modes), NullLogger()) +process_tensor = build_process_tensor( + processor; environment=bath, dt=dt, nsteps=nsteps, + method=ACE(cutoff=ace_cutoff, maxdim=ace_maxdim, compression=:zipup_cpp), + sys_alg=Trotter{2}(), combine_alg=Trotter{2}(), progress=false, ) -# First run the processor with no ancilla. This is the noisy reference circuit. - -baseline = evolve(process_tensor, rho_Q0; progress=false) -for (name, value) in pairs(baseline) - @printf("%s: %d snapshots of %s\n", name, length(value), eltype(value)) -end +# !!! note "Scope of the executable example" +# Four modes and three oscillator levels keep this calculation small; its +# final snapshot is at $t=1.95$. The companion figure uses 24 modes, +# three levels, $dt=0.2$, and $t=5$. Both require convergence checks before +# quantitative use, particularly the local oscillator cutoff at finite +# temperature. Neither is a calibrated model of particular hardware. -# ## When does an ancilla become a tester? -# -# Physically, ``A`` is just another qubit. What makes it a *tester memory* is -# that the same quantum degree of freedom persists across several circuit -# times. `Tester` stores that ancillary state, while `TesterSeq` stores the -# operations performed on it and jointly on ``Q⊗A``. +# ## Program the persistent ancilla # -# First attach ``A`` and do nothing. An identity tester must be a spectator: -# merely adding the memory wire cannot alter the noisy process. +# The ancilla starts in $|0\rangle$. It has no free evolution or direct bath +# coupling here. A tester retains its state across interventions, whereas a +# sequence of unrelated system-only operations would not carry that memory. ancilla_sites = siteinds("Qubit", 1) rho_A0 = to_dm(MPS(ancilla_sites, ["0"])) -tester_memory = tester(ancilla_sites, rho_A0) +memory = tester(ancilla_sites, rho_A0) +store_step, phase_step, retrieve_step = 4, 7, 10 -spectator = evolve( - process_tensor, - rho_Q0; - tester=tester_memory, - progress=false, -) -for (name, value) in pairs(spectator) - @printf("%s: %d snapshots of %s\n", name, length(value), eltype(value)) -end +swap_gate = op("SWAP", only(processor_sites), only(ancilla_sites)) +phase_gate = op("Z", only(ancilla_sites)) +controls = TesterSeq(nsteps=nsteps) +add!(controls, joint_unitary(swap_gate, processor_sites, ancilla_sites), store_step) +add!(controls, tester_unitary(phase_gate, ancilla_sites), phase_step) +add!(controls, joint_unitary(swap_gate, processor_sites, ancilla_sites), retrieve_step) -X_Q = operator_matrix("X", only(processor_sites)) -Y_Q = operator_matrix("Y", only(processor_sites)) -X_A = operator_matrix("X", only(ancilla_sites)) -Y_A = operator_matrix("Y", only(ancilla_sites)) +# !!! note "Joint gates occupy a process interval" +# A joint gate $G$ is compiled by applying $G^{1/2}$ before and after the +# noisy process interval. +# It is therefore not an instantaneous SWAP outside the bath dynamics. +# Tester-only gates act on the ancilla wire. Schedule step `k` corresponds +# to snapshot `k`, labelled `(k-1)*dt` by `evolve`. -x_baseline = [expectation(density_matrix(rho), X_Q) for rho in baseline.states_hilbert] -x_spectator = [expectation(density_matrix(rho), X_Q) for rho in spectator.states_hilbert] +trajectory = evolve(process_tensor, rho_Q0; tester=memory, tester_seq=controls, + return_joint=true, progress=false); +println((element_type=eltype(trajectory.states_liouville),)) +println(sprint(show, foldl(*, last(trajectory.states_liouville)))) -spectator_error = maximum(abs.(x_spectator .- x_baseline)) -@printf("identity tester max |Δ|: %.3e\n", spectator_error) -@assert spectator_error < 1e-8 +# Two short reference contractions reuse the same process: no ancilla, and an +# idle ancilla. The second must leave the processor's reduced trajectory unchanged. + +baseline = evolve(process_tensor, rho_Q0; progress=false); +spectator = evolve(process_tensor, rho_Q0; tester=memory, progress=false); -# ## Program the memory-assisted circuit +# ## Read local signals and joint correlations # -# Now the tester gets a job: +# `return_joint=true` supplies $\rho_{QA}$ as well as both reduced states. +# We track $\langle X_Q\rangle$, $\langle X_A\rangle$, and # -# 1. SWAP ``Q`` and ``A`` to store the logical state; -# 2. apply ``Z_A`` while the state is parked; -# 3. SWAP again to retrieve it. +# ```math +# I(Q{:}A)=S(\rho_Q)+S(\rho_A)-S(\rho_{QA}), +# ``` # -# The ``Z`` gate gives us a useful diagnostic. ``\langle X_A\rangle`` should -# change sign while ``C_{xy,A}`` remains approximately unchanged. That is the -# difference between **coherent phase manipulation** and decoherence. +# with entropy in bits. The lower panel of the companion figure is this mutual +# information. +# The two small helpers convert these one- and two-qubit outputs and check +# their suitability for entropy evaluation. -store_step = 4 -phase_step = 7 -retrieve_step = 10 - -swap_gate = op("SWAP", only(processor_sites), only(ancilla_sites)) -phase_gate = op("Z", only(ancilla_sites)) - -tester_seq = TesterSeq(nsteps=process_tensor.nsteps) - -add!( - tester_seq, - joint_unitary(swap_gate, processor_sites, ancilla_sites), - store_step, -) -add!( - tester_seq, - tester_unitary(phase_gate, ancilla_sites), - phase_step, -) -add!( - tester_seq, - joint_unitary(swap_gate, processor_sites, ancilla_sites), - retrieve_step, -) - -# Joint controls are compiled symmetrically around their process-tensor slab, -# ``G^{1/2}P_kG^{1/2}``, while tester-only gates act directly on the ancillary -# memory wire. The process tensor itself remains unchanged. - -trajectory = evolve( - process_tensor, - rho_Q0; - tester=tester_memory, - tester_seq, - return_joint=true, - progress=false, -) -for (name, value) in pairs(trajectory) - @printf("%s: %d snapshots of %s\n", name, length(value), eltype(value)) +function density_matrix(state) + sites = [only(filter(i -> plev(i) == 0, pair)) for pair in siteinds(state)] + tensor = foldl(*, state) + d = prod(dim.(sites)) + return reshape(ComplexF64.(Array(tensor, prime.(sites)..., sites...)), d, d) end -# ## Follow the information, not just the qubits -# -# The tester-aware trajectory gives ``ρ_Q(t)``, ``ρ_A(t)``, and, because we -# requested `return_joint=true`, ``ρ_QA(t)``. We can now evaluate the three -# diagnostics introduced at the start. +function entropy_diagnostics(ρ; tolerance=1e-7) + z = tr(ρ) + isfinite(z) && real(z) > 1e-12 || error("Invalid density-matrix trace") + hermiticity_error = norm(ρ - ρ') / norm(ρ) + λ = eigvals(Hermitian((ρ + ρ') / (2real(z)))) + min_eigenvalue = minimum(λ) + valid = min_eigenvalue >= -tolerance && hermiticity_error <= tolerance + entropy = NaN + if valid + p = max.(λ, 0) # Only negativity within the declared tolerance is clipped. + p ./= sum(p) + entropy = -sum(x * log2(x) for x in p if x > 0) + end + return (; entropy, min_eigenvalue, hermiticity_error, trace_error=abs(z - 1), valid) +end rho_Q = density_matrix.(trajectory.states_hilbert) rho_A = density_matrix.(trajectory.tester_states_hilbert) rho_QA = density_matrix.(trajectory.joint_states_hilbert) +rho_baseline = density_matrix.(baseline.states_hilbert) +rho_spectator = density_matrix.(spectator.states_hilbert) +spectator_error = maximum(norm.(rho_spectator .- rho_baseline)) +@assert spectator_error < 1e-8 -x_Q = [expectation(rho, X_Q) for rho in rho_Q] -y_Q = [expectation(rho, Y_Q) for rho in rho_Q] -x_A = [expectation(rho, X_A) for rho in rho_A] -y_A = [expectation(rho, Y_A) for rho in rho_A] - -coherence_Q = sqrt.(x_Q .^ 2 .+ y_Q .^ 2) -coherence_A = sqrt.(x_A .^ 2 .+ y_A .^ 2) - -information = [ - mutual_information(rho_Q[k], rho_A[k], rho_QA[k]) - for k in eachindex(trajectory.times) -] - -@printf( - "after store: =%+.4f, =%+.4f, C_A=%.4f\n", - x_Q[store_step], x_A[store_step], coherence_A[store_step], -) -@printf( - "after phase: =%+.4f, =%+.4f, C_A=%.4f\n", - x_Q[phase_step], x_A[phase_step], coherence_A[phase_step], -) -@printf( - "after retrieve: =%+.4f, =%+.4f, C_Q=%.4f\n", - x_Q[retrieve_step], x_A[retrieve_step], coherence_Q[retrieve_step], -) -@printf("maximum I(Q:A): %.4e bits\n", maximum(information)) - -@assert all(isfinite, x_Q) -@assert all(isfinite, x_A) -@assert all(isfinite, coherence_Q) -@assert all(isfinite, coherence_A) -@assert all(isfinite, information) -@assert minimum(information) > -1e-8 - -# ## Reading the result -# -# The companion calculation produces the figure below. -# -# ![Store, phase-tag, and retrieve a noisy qubit state with a memory-bearing tester](../assets/examples/noisy_quantum_circuit_tester.png) -# -# The three panels answer the three questions we posed before writing any code. -# -# **Top — where is the phase information?** The dashed curve is ``Q`` left -# continuously in the noisy process, so ``\langle X_Q\rangle`` decays. In the -# controlled circuit, the first SWAP moves the transverse signal from ``Q`` to -# ``A``. The ``Z_A`` gate then flips the sign of ``\langle X_A\rangle`` without -# destroying the state. The second SWAP returns that phase-tagged information -# to ``Q``, after which the processor continues to decohere under the same -# bosonic process. -# -# **Middle — did the tester remain an independent spectator?** No. The mutual -# information ``I(Q{:}A)`` becomes nonzero during the joint protocol. This does -# not by itself certify entanglement, but it shows that the tester-aware -# experiment cannot be reconstructed from ``ρ_Q`` and ``ρ_A`` as independent -# states. The same ancilla has participated in more than one circuit time and -# carries correlations generated by that history. -# -# **Bottom — how much coherence do we recover?** The companion script repeats -# the experiment for several idle durations. After retrieval it waits the same -# fixed readout delay and evaluates -# -# ```math -# C_{xy,Q}=\sqrt{\langle X_Q\rangle^2+\langle Y_Q\rangle^2}. -# ``` -# -# The bare reference spends the whole idle interval on the noisy processor -# qubit, so its coherence falls strongly with idle time. In the tester-assisted -# circuit the logical state spends most of that interval in the isolated -# ancilla, so considerably more coherence survives. The remaining idle-time -# dependence is what keeps this from being a noiseless SWAP cartoon: the -# bosonic process continues evolving while the logical state is parked -# elsewhere. -# -# We do **not** interpret this curve as a universal non-Markovianity measure. It -# is a control observable generated by one memory-assisted probing protocol. -# -# ## Why the process-tensor split matters -# -# There are two kinds of memory in this example: -# -# ```text -# bath memory E : compressed once into the process tensor -# control memory A : carried explicitly by the tester -# ``` -# -# They should not be rebuilt together whenever the circuit changes. The -# expensive ACE process tensor is reused unchanged while the SWAP times, -# ancillary gates, and idle duration are varied only through `TesterSeq`. -# -# That separation is the point of the example: `ProcessTensors.jl` constructs a -# microscopic non-Markovian noise model once and then lets genuinely -# memory-bearing quantum circuits interrogate it. -# -# !!! note "Numerical scope" -# The four-mode, three-level bath is intentionally a documentation-scale -# toy model. Quantitative conclusions require convergence in timestep, -# oscillator discretization, local boson cutoff, ACE threshold, and ACE -# bond dimension. The companion script performs a larger calculation, but -# it should still be treated as a demonstration rather than a hardware -# calibration. -# -# !!! summary "Example takeaways" -# - Temporally correlated circuit noise is naturally a multi-time problem, -# so a process tensor is a better object than unrelated noise channels. -# - A thermal spin-boson bath gives a simple microscopic noisy-qubit model; -# ACE compresses it into one reusable temporal process. -# - The same persistent ancilla participates in several interventions, so -# the SWAP–Z–SWAP sequence is a memory-bearing tester. -# - ``\langle X\rangle`` follows where phase information lives, -# ``C_{xy}`` separates phase rotation from decoherence, and ``I(Q{:}A)`` -# exposes correlations invisible in reduced trajectories. -# - Bath memory belongs to the process tensor; controllable circuit memory -# belongs to the tester. Many control experiments can therefore reuse the -# same expensive noisy process. +X = ComplexF64[0 1; 1 0] +x_Q = [real(tr(X * ρ) / tr(ρ)) for ρ in rho_Q] +x_A = [real(tr(X * ρ) / tr(ρ)) for ρ in rho_A] +x_baseline = [real(tr(X * ρ) / tr(ρ)) for ρ in rho_baseline] +Q_data, A_data, QA_data = entropy_diagnostics.(rho_Q), entropy_diagnostics.(rho_A), entropy_diagnostics.(rho_QA) +information = [q.entropy + a.entropy - qa.entropy for (q, a, qa) in zip(Q_data, A_data, QA_data)] +checks = vcat(Q_data, A_data, QA_data) +println((identity_tester_error=spectator_error, max_trace_error=maximum(c.trace_error for c in checks), + minimum_eigenvalue=minimum(c.min_eigenvalue for c in checks), + max_hermiticity_error=maximum(c.hermiticity_error for c in checks))) + +# The three selected snapshots report the same quantities as the figure: +# $\langle X_Q\rangle$, $\langle X_A\rangle$, and $I(Q{:}A)$. + +for (event, k) in zip((:store, :phase, :retrieve), (store_step, phase_step, retrieve_step)) + println((event=event, time=trajectory.times[k], x_Q=x_Q[k], x_A=x_A[k], + mutual_information=information[k])) +end +@assert all(isfinite, x_Q) && all(isfinite, x_A) + +# !!! note "Entropy needs a physical density matrix" +# The helper reports raw trace drift, the relative Hermiticity defect, and +# the smallest eigenvalue of the normalised Hermitian part before clipping. +# Only negative eigenvalues within `1e-7` are treated as numerical roundoff; +# larger violations produce `NaN`, not a plausible-looking entropy. This +# tolerance should be checked alongside timestep and ACE convergence. + +# ## Interpret the storage experiment +# +# ![Local X signals and processor–ancilla mutual information](../assets/examples/noisy_quantum_circuit_tester.png) +# +# In the upper panel, the uncontrolled processor gradually loses its transverse +# signal. The first SWAP transfers most of that signal to the ancilla, where it +# is nearly constant during storage. The $Z_A$ gate reverses its sign; the second +# SWAP returns the negative signal to $Q$. Its magnitude then decreases again +# under the same bath process. +# +# The lower panel shows the small processor–ancilla correlations generated by +# this finite-interval protocol. Mutual information measures total correlations; +# it does not certify entanglement or non-Markovianity. In particular, the ideal +# instantaneous first SWAP would leave $Q$ in $|0\rangle$, initially factorised +# from the ancilla and bath. Noise acting between the two half-SWAPs changes that +# idealisation and can generate the nonzero correlations seen here. +# +# The bath can remain correlated with the stored state even though it does not +# directly couple to $A$. The controlled ancilla and the uncontrolled bath thus +# play different roles: the bath is represented by the fixed process tensor, +# while the ancilla is carried explicitly through the chosen tester circuit. +# +# !!! tip "Try changing" +# These edits test the sign of $\langle X\rangle$ and the mutual information +# under the finite-interval gate convention, while reusing the same bath +# process whenever its parameters stay fixed. +# +# - Replace `Z` by `Id`: the stored X signal should no longer change sign. +# - Move the retrieval step later, keeping all three operations ordered. +# Follow where the local X signal resides before and after retrieval. +# - Reduce `dt`, rebuilding the PT and adjusting all step indices to keep +# the physical gate times fixed. Does the small mutual information change +# as the noise interval between the half-gates becomes shorter? diff --git a/docs/literate/examples/ramsey_povm.jl b/docs/literate/examples/ramsey_povm.jl new file mode 100644 index 0000000..c2248a6 --- /dev/null +++ b/docs/literate/examples/ramsey_povm.jl @@ -0,0 +1,263 @@ +# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors #src +# SPDX-License-Identifier: MIT #src +# #src +# File: docs/literate/examples/ramsey_povm.jl #src +# Contributor: Gauthameshwar S. #src +# #src +# Repeated Ramsey readouts with active reset on a non-Markovian process tensor. #src + +# # Ramsey readouts as a probe of bath memory +# +# Can three readouts remain correlated when we reset the qubit after each one? +# We prepare a qubit, let a bosonic bath dephase it, then measure and prepare it +# again. Comparing the eight outcome records with independent-shot predictions +# tests what survives the reset in the environment. +# +# !!! related "Related material" +# - Tutorial: [Construct a process tensor](@ref) +# - Theory: [Process Tensors](../theory/process_tensors.md) +# +# !!! script "Companion script" +# [`scripts/ramsey_povm.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/better-docs/scripts/ramsey_povm.jl) +# generates the protocol and probability figures with the longer schedule described below. +# +# ## Build the dephasing environment +# +# In units with ``\hbar=k_B=1``, the qubit has no free Hamiltonian: +# +# ```math +# H_S=0,\qquad H_E=\sum_k\omega_k b_k^\dagger b_k,\qquad +# H_{SE}=Z\sum_k g_k(b_k+b_k^\dagger). +# ``` +# +# Here ``Z`` is the Pauli operator, with eigenvalues ±1. This coupling changes +# coherence without transferring population between its eigenstates. A usual +# oscillator counterterm is proportional to ``Z^2=I`` and contributes only a +# constant. We sample the Ohmic spectral density on a midpoint grid: +# +# ```math +# J(\omega)=2\alpha\omega e^{-\omega/\omega_c},\qquad +# \Delta\omega=\omega_{\max}/N,\qquad +# \omega_k=(k-\tfrac12)\Delta\omega,\qquad +# g_k=\sqrt{J(\omega_k)\Delta\omega}. +# ``` + +using Logging +using ITensors +using ITensors.Ops: Trotter +using ProcessTensors + +const N_BATH, LOCAL_DIM = 4, 3 +const ALPHA, OMEGA_C, OMEGA_MAX = 0.20, 4.0, 20.0 +const TEMPERATURE = 2.5 +const DT, NSTEPS = 0.10, 7 +const ACE_CUTOFF, ACE_MAXDIM = 1e-5, 32 + +system_sites = siteinds("Qubit", 1) +system = with_logger(() -> qubit_system(system_sites), NullLogger()) + +# Each mode starts in a normalized, truncated Gibbs state, independently of +# the other modes and the prepared qubit: +# +# ```math +# \rho_E=\bigotimes_k\rho_k^{\rm th},\qquad +# \rho_k^{\rm th}=\frac{\sum_{n=0}^{M-1}e^{-n\omega_k/T}|n\rangle\langle n|} +# {\sum_{n=0}^{M-1}e^{-n\omega_k/T}}. +# ``` +# +# In each coupling `OpSum`, site 1 is the oscillator and site 2 is the qubit. + +Δω = OMEGA_MAX / N_BATH +frequencies = [(k - 0.5) * Δω for k in 1:N_BATH] +couplings = sqrt.(2ALPHA .* frequencies .* exp.(-frequencies ./ OMEGA_C) .* Δω) +bath_sites = siteinds("Boson", N_BATH; dim=LOCAL_DIM) +bath_liouville_sites = liouv_sites(bath_sites) +modes = BosonicMode[] +for k in eachindex(frequencies) + H_mode = OpSum() + (frequencies[k], "N", 1) + coupling = OpSum() + coupling += couplings[k], "A", 1, "Z", 2 + coupling += couplings[k], "Adag", 1, "Z", 2 + push!(modes, thermal_mode([bath_liouville_sites[k]], H_mode, TEMPERATURE; + coupling=coupling)) +end +bath = with_logger(() -> bosonic_bath(modes), NullLogger()) + +# This small bath makes the instrument calculation inexpensive. It is a finite +# model, not a converged approximation to the Ohmic continuum. + +process_tensor = build_process_tensor( + system; method=ACE(cutoff=ACE_CUTOFF, maxdim=ACE_MAXDIM, compression=:zipup_cpp), + environment=bath, dt=DT, nsteps=NSTEPS, + sys_alg=Trotter{2}(), combine_alg=Trotter{2}(), progress=false) +println((maximum_bond_dimension=maxlinkdim(process_tensor),)) + +# ## Measure and reset: a causal break +# +# Preparation and analysis pulses are absorbed into the reset state and an +# unsharp ``X`` readout: +# +# ```math +# \rho_{\rm r}=|+\rangle\langle+|,\qquad +# E_x^{(\eta)}=\tfrac12(I+x\eta X),\qquad x=\pm1,\quad 0\le\eta\le1. +# ``` +# +# At unit visibility the detector is projective. At zero visibility it reports +# an independent fair coin, whatever the qubit state; it does not imply that +# the qubit itself is maximally mixed. The complete outcome operation is +# +# ```math +# \mathcal A_x(\rho)=\operatorname{Tr}(E_x^{(\eta)}\rho)\rho_{\rm r},\qquad +# \mathbf A_x=|\rho_{\rm r}\rangle\!\rangle\langle\!\langle E_x^{(\eta)}|. +# ``` +# +# The second expression is its Liouville matrix, using the Hilbert–Schmidt +# inner product. The reset is the **output ket** and the effect is the **input +# bra**. Summing the outcome maps gives a trace-preserving replacement channel: +# +# ```math +# \sum_x\mathcal A_x(\rho)=\operatorname{Tr}(\rho)\rho_{\rm r}. +# ``` +# +# `observable_measurement` supplies the effect on the preceding PT output; +# `state_preparation` supplies the state on the next PT input. Their product +# constructs this [`ProductInstrument`](@ref), not a Lüders update without reset. + +const ETA, OUTCOMES = 0.90, (-1, 1) +rho_reset = to_dm(MPS(system_sites, ["+"])) +effects = Dict(x => OpSum() + (0.5, "Id", 1) + (x * ETA / 2, "X", 1) + for x in OUTCOMES) +ramsey_instruments = Dict( + x => observable_measurement(effects[x]) * state_preparation(rho_reset) + for x in OUTCOMES) + +# On a correlated qubit–bath state, the same local operation gives +# +# ```math +# (\mathcal A_x\otimes\mathcal I_E)(\rho_{SE}) +# =\rho_{\rm r}\otimes\widetilde\rho_E^{(x)},\qquad +# \widetilde\rho_E^{(x)}=\operatorname{Tr}_S[(E_x^{(\eta)}\otimes I_E)\rho_{SE}]. +# ``` +# +# The bath operator is unnormalized: its trace is the outcome probability. +# The qubit is reset and disentangled from the bath, but the conditional bath +# state can still depend on the record. **Resetting the qubit does not reset the bath.** +# +# ![Repeated measurement and reset, with a persistent environment](../assets/examples/ramsey_povm_protocol.png) +# +# ## Contract the eight records +# +# Each bath core propagates one time step. A measure-and-reset at slot ``s`` +# closes the preceding output after ``s`` propagations, then prepares the next input: +# +# ```math +# t_j=s_j\Delta t,\qquad \tau_j=t_j-t_{j-1},\qquad t_0=0. +# ``` +# +# The documentation uses slots (2, 4, 6), giving equal waits of 0.2. The companion +# uses (4, 8, 12), step size 0.15, 14 cores, and bond cap 512: its waits are 0.6. +# The terminal trace closes the remaining evolution without conditioning on it. + +const READOUT_STEPS = (2, 4, 6) +readout_times = collect(READOUT_STEPS) .* DT + +# Start with one explicit record, ``(+,-,+)``. Unused slots keep the identity. + +sequence = default_schedule(process_tensor) +add!(sequence, state_preparation(rho_reset), 0) +add!(sequence, ramsey_instruments[1], READOUT_STEPS[1]) +add!(sequence, ramsey_instruments[-1], READOUT_STEPS[2]) +add!(sequence, ramsey_instruments[1], READOUT_STEPS[3]) +add!(sequence, trace_out(), process_tensor.nsteps) +example_probability = evaluate_process(process_tensor, sequence; progress=false) + +# With joint system–bath evolution ``\mathcal U_j`` over each waiting interval, +# the contraction evaluates +# +# ```math +# p(\boldsymbol{x})=\operatorname{Tr}_{SE}\!\left[ +# (\mathcal A_{x_3}\otimes\mathcal I_E)\mathcal U_3 +# (\mathcal A_{x_2}\otimes\mathcal I_E)\mathcal U_2 +# (\mathcal A_{x_1}\otimes\mathcal I_E)\mathcal U_1 +# (\rho_{\rm r}\otimes\rho_E)\right]. +# ``` +# +# Repeat for all eight records, reusing the same PT. These are joint branch +# probabilities; do not normalize a branch after each readout. + +function branch_probability(record) + sequence = default_schedule(process_tensor) + add!(sequence, state_preparation(rho_reset), 0) + for (step, outcome) in zip(READOUT_STEPS, record) + add!(sequence, ramsey_instruments[outcome], step) + end + add!(sequence, trace_out(), process_tensor.nsteps) + value = evaluate_process(process_tensor, sequence; progress=false) + @assert isfinite(value) && abs(imag(value)) < 1e-7 + return real(value) +end + +records = vec(collect(Iterators.product(OUTCOMES, OUTCOMES, OUTCOMES))) +probabilities = branch_probability.(records) +normalization_error = abs(sum(probabilities) - 1) +@assert minimum(probabilities) >= -1e-7 +@assert normalization_error < 5e-3 + +# ## What survives the reset? +# +# The bars compare the joint distribution with the product of its own marginals: +# +# ```math +# p_j(x)=\sum_{\boldsymbol{x}:x_j=x}p(\boldsymbol{x}),\qquad +# q(\boldsymbol{x})=\prod_{j=1}^3 p_j(x_j),\qquad +# D=\tfrac12\sum_{\boldsymbol{x}}|p(\boldsymbol{x})-q(\boldsymbol{x})|. +# ``` +# +# For normalized, nonnegative probabilities, ``D`` is the total variation +# distance from independent records. We retain raw numerical weights and print +# their normalization error alongside the residual, rather than clipping or +# renormalizing away contraction errors. + +# Columns contain P(-) and P(+) for each round; retain the raw PT weights. +marginals = zeros(3, 2) +for (record, probability) in zip(records, probabilities) + for round in 1:3 + marginals[round, record[round] == -1 ? 1 : 2] += probability + end +end +independent_probabilities = [ + prod(marginals[j, record[j] == -1 ? 1 : 2] for j in 1:3) + for record in records] +factorization_residual = sum(abs.(probabilities .- independent_probabilities)) / 2 +println((normalization_error=normalization_error, minimum_probability=minimum(probabilities), + factorization_residual=factorization_residual)) + +# ![Joint record probabilities compared with independent records](../assets/examples/ramsey_povm_records.png) +# +# In the companion result, the ``(+,-,-)`` and ``(-,-,+)`` records are enhanced +# relative to the product prediction, while ``(-,-,-)`` is suppressed. Thus a +# bias toward either detector outcome alone cannot explain the bars: the orange +# distribution already includes each round's individual bias. Their mismatch +# reveals information in the combinations of outcomes. +# +# With the same reset for every outcome, fixed controls, and no detector memory, +# a memoryless process factorizes even if the intervals differ. A discrepancy +# that survives numerical convergence therefore witnesses bath-mediated temporal +# memory in this protocol. It does not establish entanglement or uniquely quantum +# memory; classical environmental memory can also correlate records. Conversely, +# agreement would not prove the whole process Markovian: this readout may miss memory. +# +# !!! tip "Try changing" +# These edits test whether the record correlations survive changes in the detector, +# timing, and numerical resolution. +# +# - Set `ETA = 0`: all eight records should approach probability `1/8`, even +# though the bath can retain memory. Set `ALPHA = 0`: records should factorize, +# with a plus-outcome probability of `(1 + ETA)/2` in every round. +# - Change the readout spacing. For equal waiting times of `m * DT` from the +# initial preparation, use slots `(m, 2m, 3m)` and `NSTEPS > 3m`. +# - Reduce `DT` while preserving the physical readout times, lower `ACE_CUTOFF`, +# and increase `ACE_MAXDIM`. The normalization error should be much smaller +# than the factorization residual before interpreting it physically. +# - Increase `LOCAL_DIM` and refine `N_BATH` at fixed frequency range. Do the +# enhanced records persist beyond this small, truncated bath? diff --git a/docs/literate/examples/spin_bath_process_tensor.jl b/docs/literate/examples/spin_bath_process_tensor.jl index 414e23b..c6c353b 100644 --- a/docs/literate/examples/spin_bath_process_tensor.jl +++ b/docs/literate/examples/spin_bath_process_tensor.jl @@ -8,203 +8,56 @@ # # Spin-bath process tensor # -# This example builds a process tensor for a spin coupled to a spin bath. We first -# use a single bath mode, then repeat the same idea with several bath modes. -# -# The point of the example is not only to reproduce reduced dynamics. The point is -# to see the **process tensor itself** as the reusable object. Once the bath has -# been absorbed into this object, we can probe the system with different -# preparations, uninterrupted evolution, observable readouts, and later more -# general instruments. +# How does a spin's motion change when it interacts with one bath spin or several? +# We construct both processes, follow the system polarisation, and reuse the +# single-mode process for a final observable readout. A small joint-evolution +# check separates numerical agreement from the physical interpretation. # # !!! related "Related material" -# - Tutorial: [Single-Mode Process Tensor](@ref) +# - Tutorial: [Construct a process tensor](@ref) # - Theory: [Process Tensors](../theory/process_tensors.md) # # !!! script "Companion scripts" -# Advanced figures below are generated by +# The trajectory and error figures are generated separately by # [`scripts/pt_tfim_singlemode.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/main/scripts/pt_tfim_singlemode.jl) # and # [`scripts/pt_tfim_multimode.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/main/scripts/pt_tfim_multimode.jl). +# They contain the extended trajectory benchmarks; this page keeps a small +# runnable calculation and a final-state check. -# ## The object we build +# ## Model and numerical setup # -# A usual reduced-dynamics calculation evolves the joint system-environment state, +# In units with $\hbar=1$ and system precession frequency one, the Hamiltonian is # # ```math -# \rho_{SE}(t) -# = -# U(t)\,\rho_S(0)\otimes\rho_E(0)\,U^\dagger(t), +# H = S_x^{(S)} + \sum_{m=1}^{M}\omega_m S_x^{(m)} +# + \sum_{m=1}^{M}g_m S_z^{(m)}S_z^{(S)}. # ``` # -# and then traces out the environment, -# -# ```math -# \rho_S(t) = \operatorname{Tr}_E[\rho_{SE}(t)]. -# ``` -# -# A process tensor reorganizes this computation. Instead of repeating the full -# system-environment evolution for every system-level question, we integrate out -# the environment once and store its influence as a tensor network in time. -# -# Conceptually, a process tensor acts as a multi-time map, -# -# ```math -# \rho_S(t_n) -# = -# \mathcal{T}_{n:0} -# \left[ -# \mathcal{A}_{n-1},\ldots,\mathcal{A}_1,\rho_S(0) -# \right], -# ``` -# -# where the maps $\mathcal{A}_k$ are the interventions or instruments inserted -# on the system at intermediate times. -# -# In this package, the process tensor is represented as a PT-MPO. Its memory bonds -# carry the bath influence between different time steps. Once this object is -# built, the bath no longer appears explicitly in the user-facing reduced -# calculation. -# -# !!! note "The perspective to keep" -# `build_process_tensor(...)` is the expensive object-building step. -# Calls such as `evolve(pt, ρ0)` and `evaluate_process(pt, seq)` contract -# this object with a particular instrument schedule. - -# ## Physical model -# -# We use one system spin with Hamiltonian -# -# ```math -# H_S = S_x. -# ``` -# -# The bath is made of one or more spin modes. A single mode has Hamiltonian -# -# ```math -# H_B = \omega S_x, -# ``` -# -# and couples to the system through -# -# ```math -# H_{SB} = g\,S_z^{(B)} S_z^{(S)}. -# ``` +# Every spin starts in $|\uparrow_z\rangle$. The bath spins have no direct +# interactions with one another, but they all couple to the system. +# We first use $M=1$, $\omega_1=g_1=1$, then change the bath to four modes. +# The main observable is the system polarisation $\langle S_z\rangle$. # -# Both the system and bath modes start in the up state. +# !!! note "Spin convention" +# ITensor's `S=1/2` operators are $S_\alpha=\sigma_\alpha/2$. +# Thus the initial $\langle S_z\rangle$ is $1/2$; a figure showing Pauli +# expectations $\langle\sigma_\alpha\rangle$ uses twice this normalisation. -# ## Setup - -using Printf using ProcessTensors using ITensors using LinearAlgebra using ITensors.Ops: Exact, Trotter -const dt = 0.1 -const nsteps = 24 -const final_time = dt * nsteps -const joint_ed_frob_tol_single = 0.08 -const joint_ed_frob_tol_multimode = 0.05 -const dsys = 2 -const denv_single = 2 -const nmodes = 4 -const denv_multimode = 2^nmodes -const mode_w = [0.5 + 0.1 * m for m in 1:nmodes] -const mode_g = [0.2 + 0.3 * m for m in 1:nmodes] - -function print_pt_summary(label::AbstractString, result, frob_tol::Float64) - @printf("%s\n", label) - @printf(" max ‖ρ_PT − ρ_joint ED‖_F = %.3e\n", result.max_frob) - @printf(" ⟨σ_x⟩ at t=0 (PT / ED) = %.6f / %.6f\n", result.sx_pt[1], result.sx_ed[1]) - @printf(" ⟨σ_x⟩ at t=T (PT / ED) = %.6f / %.6f\n", result.sx_pt[end], result.sx_ed[end]) - println() - @assert all(isfinite, result.sx_pt) && all(isfinite, result.sx_ed) - @assert all(isfinite, result.frob_err) - @assert result.max_frob < frob_tol -end - -# ## Exact diagonalization reference -# -# To audit the process-tensor construction on this small system, we compare the -# reduced states from `evolve` against exact joint Liouville evolution of the -# combined system and bath, -# -# ```math -# |\rho_{SE}(t)\rangle\rangle = e^{t\mathcal{L}_{SE}}|\rho_{SE}(0)\rangle\rangle, -# ``` -# -# followed by a partial trace over the bath Hilbert space, -# -# ```math -# \rho_S(t) = \operatorname{Tr}_E[\rho_{SE}(t)]. -# ``` -# -# The helpers below convert Liouville MPS/MPO objects to dense matrices, extract -# the reduced system state, and report Frobenius errors along the trajectory. -# They are used only for this validation block. - -σx = ComplexF64[0 1; 1 0] -σy = ComplexF64[0 -im; im 0] -σz = ComplexF64[1 0; 0 -1] - -function pauli_expectations(ρ::AbstractMatrix{<:Number}) - return real(tr(ρ * σx)), real(tr(ρ * σy)), real(tr(ρ * σz)) -end - -function reduced_system_ρ(state_l, dsys::Int) - rho_h = to_hilbert(state_l) - sites = [ - only(filter(i -> plev(i) == 0 && hastags(i, "Site"), inds(rho_h.core[j]))) - for j in eachindex(rho_h.core) - ] - T = foldl(*, rho_h) - A = Array(T, prime.(sites)..., sites...) - return reshape(ComplexF64.(A), dsys, dsys) -end - -function partial_trace_system(rho_h, dsys::Int, denv::Int) - sites = [ - only(filter(i -> plev(i) == 0 && hastags(i, "Site"), inds(rho_h.core[j]))) - for j in eachindex(rho_h.core) - ] - T = foldl(*, rho_h) - A = Array(T, prime.(sites)..., sites...) - ρ4 = reshape(ComplexF64.(A), dsys, denv, dsys, denv) - ρ_red = zeros(ComplexF64, dsys, dsys) - for e in 1:denv - ρ_red .+= @view ρ4[:, e, :, e] - end - return ρ_red -end - -function compare_trajectory_to_joint_ed(trajectory, rho_sys0_h, system, H_full, joint_liouv, rho_joint0_l, denv::Int) - sx_pt, sx_ed = Float64[], Float64[] - frob_err = Float64[] - - ρ_pt = reduced_system_ρ(to_liouville(rho_sys0_h; sites=system.sites), dsys) - ρ_ed = partial_trace_system(to_hilbert(rho_joint0_l), dsys, denv) - push!(sx_pt, pauli_expectations(ρ_pt)[1]) - push!(sx_ed, pauli_expectations(ρ_ed)[1]) - push!(frob_err, norm(ρ_pt - ρ_ed)) - - for k in 1:nsteps - t = k * dt - ρ_pt = reduced_system_ρ(trajectory.states_liouville[k], dsys) - U_L = liouvillian_propagator(H_full, joint_liouv, t; alg=Exact()) - rho_joint_l = apply(U_L, copy(rho_joint0_l); cutoff=0.0, maxdim=typemax(Int)) - ρ_ed = partial_trace_system(to_hilbert(rho_joint_l), dsys, denv) - push!(sx_pt, pauli_expectations(ρ_pt)[1]) - push!(sx_ed, pauli_expectations(ρ_ed)[1]) - push!(frob_err, norm(ρ_pt - ρ_ed)) - end - - return (; sx_pt, sx_ed, frob_err, max_frob=maximum(frob_err)) -end +dt = 0.1 +nsteps = 24 +final_time = dt * nsteps -# ## Single-mode spin bath +# ## Construct a single-mode process # -# We first couple one system spin to one bath spin. +# `spin_system` supplies the system Hamiltonian. Each `spin_mode` supplies a bath +# Hamiltonian, its initial density operator, and its coupling. In the coupling +# `OpSum`, site 1 is the bath spin and site 2 is the system spin. sys_phys = siteinds("S=1/2", 1) env_phys = siteinds("S=1/2", 1) @@ -226,128 +79,72 @@ coupling += 1.0, "Sz", 1, "Sz", 2 mode = spin_mode(env_liouv, H_env, ρ_env0_l; coupling=coupling) bath = spin_bath([mode]) -# !!! info "Live progress on long builds" -# Process-tensor construction can take noticeable time. For a dynamic spinner -# animation during CPU-heavy steps, start Julia with at least two threads, -# for example `julia --project=. -t 2` when launching Julia. -# See [Advanced Usage](@ref) for `progress`, `verbose`, and threading -# options. -# -# ### Build the process tensor -# -# The bath is integrated out here. The returned `ProcessTensor` stores the -# environment influence on the system across all time steps. -# -# `sys_alg=Trotter{2}()` uses the second-order timestep sandwich -# ``M(Δt/2)·Q·M(Δt/2)`` of free-system maps around each bath core -# (smaller time-discretization error than the default asymmetric -# `Trotter{1}()` layout). +# The construction fixes the bath, couplings, time grid, and system propagation. +# Different system preparations and readouts can reuse the resulting object. pt_single = build_process_tensor( - system, - system.sites[1]; - environment=bath, - dt=dt, - nsteps=nsteps, - alg=Exact(), - sys_alg=Trotter{2}(), + system, system.sites[1]; environment=bath, + dt=dt, nsteps=nsteps, alg=Exact(), sys_alg=Trotter{2}(), ) -println("Single-mode process tensor:") -println(pt_single) +# !!! note "Exact mode propagation is not exact full evolution" +# `Exact()` evaluates the mode propagator, while `Trotter{2}()` places +# half-steps of free-system evolution on either side of the bath core. +# This splitting still has a finite-timestep error. Check smaller `dt` at +# fixed final time, and converge any bond truncation separately. -@assert pt_single isa ProcessTensor -@assert pt_single.nsteps == nsteps -@assert pt_single.dt == dt - -#- -# ### Probe with instruments -# -# Once `pt_single` exists, reduced questions are asked by contracting it with an -# instrument schedule. -# -# `evolve(pt, ρ0)` is the convenience interface for uninterrupted evolution: -# prepare the initial state, let the system pass through each time step, and -# read out the reduced states at the requested times. +# ## Evolve and reuse the process # -# `evaluate_process(pt, seq)` exposes the same contraction with an explicit -# [`InstrumentSeq`](@ref). Bind `ObservableMeasurement` to the PT output leg at -# the final time label (`output_sites(pt, pt.nsteps - 1)`) so the instrument -# ITensor contracts with the reduced state without index warnings. +# `evolve` returns the reduced trajectory for an initial system state. The +# explicit schedule below asks the same process for a final $\langle S_z\rangle$. +# The final output belongs to core `nsteps - 1`; its readout is scheduled at +# boundary `nsteps`. ρ_sys0_h = to_dm(MPS(sys_phys, ["Up"])) - trajectory_single = evolve(pt_single, ρ_sys0_h) -println("evolve returned $(length(trajectory_single.times)) snapshots") Sz = OpSum() Sz += 1.0, "Sz", 1 -k_final = pt_single.nsteps - 1 -final_sites = output_sites(pt_single, k_final) - -seq_final_sz = default_schedule(pt_single) -add!(seq_final_sz, state_preparation(ρ_sys0_h), 0) -add!(seq_final_sz, observable_measurement(Sz, final_sites), pt_single.nsteps) -final_sz_schedule = evaluate_process(pt_single, seq_final_sz) - -Sz_obs = ProcessTensors.Instruments.instrument_itensor( - observable_measurement(Sz, final_sites), - final_sites, - k_final, -) -ρ_final_T = foldl(*, trajectory_single.states_liouville[end]) -final_sz_evolve = real(inner(ρ_final_T, Sz_obs)) +final_sites = output_sites(pt_single, nsteps - 1) +seq = default_schedule(pt_single) +seq += state_preparation(ρ_sys0_h), 0 +seq += observable_measurement(Sz, final_sites), nsteps +final_sz_schedule = evaluate_process(pt_single, seq) + +# ### Read the trajectory +# +# For this one-spin output, a small conversion helper exposes the $2\times2$ +# density matrix. We reuse it to calculate polarisation, raw trace drift, and +# the final-state reference error. No trace rescaling is applied. + +function spin_density(state_l) + ρ_h = to_hilbert(state_l) + tensor = foldl(*, ρ_h) + site = only(filter(i -> plev(i) == 0 && hastags(i, "Site"), inds(tensor))) + return ComplexF64.(Array(tensor, prime(site), site)) +end -println("Final ⟨σ_z⟩ from evaluate_process: ", real(final_sz_schedule)) -println("Final ⟨σ_z⟩ from evolve: ", final_sz_evolve) -@assert abs(real(final_sz_schedule) - final_sz_evolve) < 1e-8 +states_single = spin_density.(trajectory_single.states_liouville) +sz_matrix = ComplexF64[1 0; 0 -1] / 2 +sz_single = [real(tr(sz_matrix * ρ)) for ρ in states_single] +trace_error_single = maximum(abs(tr(ρ) - 1) for ρ in states_single) -#- -# ### Validate against joint ED - -joint_phys = Index[sys_phys[1], env_phys[1]] -joint_liouv_single = liouv_sites(joint_phys) - -H_full_single = OpSum() -H_full_single += 1.0, "Sx", 1 -H_full_single += 1.0, "Sx", 2 -H_full_single += 1.0, "Sz", 1, "Sz", 2 - -psi_joint = MPS(joint_phys, ["Up", "Up"]) -ρ_joint0_l_single = to_liouville(to_dm(psi_joint); sites=joint_liouv_single) - -result_single = compare_trajectory_to_joint_ed( - trajectory_single, - ρ_sys0_h, - system, - H_full_single, - joint_liouv_single, - ρ_joint0_l_single, - denv_single, -) -print_pt_summary("Single-mode spin bath", result_single, joint_ed_frob_tol_single) -println("Final ⟨σ_x⟩ (PT): ", result_single.sx_pt[end]) +println((final_sz=last(sz_single), max_trace_error=trace_error_single, + max_pt_bond=maxlinkdim(pt_single))) +@assert abs(final_sz_schedule - tr(sz_matrix * last(states_single))) < 1e-8 +@assert trace_error_single < 1e-8 -# ![Single-mode spin-bath process tensor](../assets/examples/pt_tfim_singlemode.png) +# ## Add several bath spins # -# The plotting script compares all Pauli expectations and shows the Frobenius -# error on a log scale in the lower panel. - -# ## Multimode spin bath -# -# The multimode case changes the bath, not the process-tensor idea. We couple the -# same system spin to several independent bath spins, -# -# ```math -# H_B^{(m)} = \omega_m S_x^{(m)}, -# \qquad -# H_{SB}^{(m)} = g_m S_z^{(m)} S_z^{(S)}. -# ``` -# -# !!! note "Single mode vs multimode" -# Both cases use the same build-then-probe workflow. The difference is the -# bath memory stored in the PT-MPO, not the user-facing API. +# We now choose four frequencies $\omega_m=0.5+0.1m$ and couplings +# $g_m=0.2+0.3m$. Building the list of modes is the only new setup step; +# construction and reduced evolution use the same interfaces. +# This changes both the number of spins and the coupling distribution, so the +# comparison does not isolate bath size alone. +nmodes = 4 +mode_w = [0.5 + 0.1 * m for m in 1:nmodes] +mode_g = [0.2 + 0.3 * m for m in 1:nmodes] env_phys_multi = siteinds("S=1/2", nmodes) env_liouv_multi = liouv_sites(env_phys_multi) @@ -366,154 +163,105 @@ for m in 1:nmodes end bath_multi = spin_bath(modes) -# ### Build the process tensor +#- pt_multi = build_process_tensor( - system, - system.sites[1]; - environment=bath_multi, - dt=dt, - nsteps=nsteps, - alg=Exact(), - sys_alg=Trotter{2}(), + system, system.sites[1]; environment=bath_multi, + dt=dt, nsteps=nsteps, alg=Exact(), sys_alg=Trotter{2}(), ) - -println("Multimode process tensor ($nmodes bath spins):") -println(pt_multi) - -@assert pt_multi isa ProcessTensor -@assert maxlinkdim(pt_multi) >= maxlinkdim(pt_single) - -#- -# ### Probe with instruments - trajectory_multi = evolve(pt_multi, ρ_sys0_h) -println("evolve returned $(length(trajectory_multi.times)) snapshots") - -final_sites_multi = output_sites(pt_multi, pt_multi.nsteps - 1) -seq_multi_sz = default_schedule(pt_multi) -add!(seq_multi_sz, state_preparation(ρ_sys0_h), 0) -add!(seq_multi_sz, observable_measurement(Sz, final_sites_multi), pt_multi.nsteps) -final_sz_multi = evaluate_process(pt_multi, seq_multi_sz) - -Sz_obs_multi = ProcessTensors.Instruments.instrument_itensor( - observable_measurement(Sz, final_sites_multi), - final_sites_multi, - pt_multi.nsteps - 1, -) -ρ_final_multi_T = foldl(*, trajectory_multi.states_liouville[end]) -final_sz_multi_evolve = real(inner(ρ_final_multi_T, Sz_obs_multi)) - -println("Final ⟨σ_z⟩ from evaluate_process: ", real(final_sz_multi)) -println("Final ⟨σ_z⟩ from evolve: ", final_sz_multi_evolve) -@assert abs(real(final_sz_multi) - final_sz_multi_evolve) < 1e-8 - -#- -# ### Validate against joint ED - -joint_phys_multi = Index[sys_phys[1], env_phys_multi...] -joint_liouv_multi = liouv_sites(joint_phys_multi) - -H_full_multi = let - H = OpSum() - H += 1.0, "Sx", 1 - for m in 1:nmodes - H += mode_w[m], "Sx", m + 1 - H += mode_g[m], "Sz", m + 1, "Sz", 1 - end - H -end - -joint_init = vcat(["Up"], fill("Up", nmodes)) -psi_joint_multi = MPS(joint_phys_multi, joint_init) -ρ_joint0_l_multi = to_liouville(to_dm(psi_joint_multi); sites=joint_liouv_multi) - -result_multi = compare_trajectory_to_joint_ed( - trajectory_multi, - ρ_sys0_h, - system, - H_full_multi, - joint_liouv_multi, - ρ_joint0_l_multi, - denv_multimode, -) -print_pt_summary("Multimode spin bath ($nmodes modes)", result_multi, joint_ed_frob_tol_multimode) -println("Final ⟨σ_x⟩ (PT): ", result_multi.sx_pt[end]) - -# ![Multimode spin-bath process tensor](../assets/examples/pt_tfim_multimode.png) +states_multi = spin_density.(trajectory_multi.states_liouville) +sz_multi = [real(tr(sz_matrix * ρ)) for ρ in states_multi] +trace_error_multi = maximum(abs(tr(ρ) - 1) for ρ in states_multi) + +println((bath_spins=nmodes, final_sz=last(sz_multi), + max_trace_error=trace_error_multi, max_pt_bond=maxlinkdim(pt_multi))) +@assert all(isfinite, sz_multi) +@assert trace_error_multi < 1e-8 + +# The transverse fields rotate the spins while the longitudinal coupling creates +# system–bath correlations. Several bath frequencies can produce more intricate +# oscillations and dephasing-like behaviour. A finite spin bath can also support +# recurrences; a damped-looking segment alone does not establish irreversible +# relaxation or thermalisation. +# +# !!! note "Bond dimension is a numerical diagnostic" +# The reported PT bond dimension measures storage in this representation. +# It need not increase monotonically with bath size and does not by itself +# certify memory or convergence. + +# ## Check and interpret the result +# +# For the single-mode case, the joint Hilbert space has only four dimensions. +# We can exponentiate its full Hamiltonian directly and trace out the bath. +# With basis ordering `bath ⊗ system`, reshaping the pure state places the system +# index in the rows, so $\rho_S=\Psi\Psi^\dagger$ performs the partial trace. + +sx_matrix = ComplexF64[0 1; 1 0] / 2 +identity_spin = Matrix{ComplexF64}(I, 2, 2) +H_joint = kron(identity_spin, sx_matrix) + kron(sx_matrix, identity_spin) + + kron(sz_matrix, sz_matrix) +ψ0 = ComplexF64[1, 0, 0, 0] +ψ_exact = exp(-im * final_time * H_joint) * ψ0 +Ψ = reshape(ψ_exact, 2, 2) +ρ_exact = Ψ * Ψ' +final_error = norm(last(states_single) - ρ_exact) + +println((final_time=final_time, pt_sz=last(sz_single), + exact_sz=real(tr(sz_matrix * ρ_exact)), final_frobenius_error=final_error)) +@assert isfinite(final_error) +@assert final_error < 0.08 + +# !!! note "What this check establishes" +# The final-state error compares the discretised process with unsplit joint +# evolution. The bound above is a coarse regression check, not an accuracy +# target. The schedule agreement checks two ways of contracting the same +# process. Trace preservation alone does not establish positivity. +# +# ### Companion figures +# +# These images come from the companion scripts, not from the cells above. +# The single-mode figure uses the same `dt = 0.1` and `sys_alg = Trotter{2}()`. # -# ### What controls the error? +# ![Single-mode spin-bath process tensor](../assets/examples/pt_tfim_singlemode.png) # -# The main algorithmic error comes from the short-time system–bath split inside -# `build_process_tensor`. Prefer `sys_alg=Trotter{2}()` (second-order sandwich) -# over the default `Trotter{1}()` asymmetric layout when time-discretization -# error dominates; smaller `dt` further reduces that residual. In larger -# calculations, also monitor PT-MPO bond truncation (`cutoff`, `maxdim`) and -# accumulated roundoff in long contractions. +# The Pauli expectations stay close to the joint evolution. The lower panel is +# the more informative diagnostic. At $t=0$ the reduced states are the same +# product state, so the Frobenius error sits at roundoff, about $10^{-15}$. +# After the first step it jumps to about $10^{-4}$. # -# Here is a direct comparison at fixed ``Δt`` up to ``t = 1.5``. We build two -# single-mode process tensors that differ only in `sys_alg`, ask -# `evaluate_process` for the final ``⟨S^x⟩`` (ITensor `S=1/2` spin operator), -# and compare both answers to joint continuous-time ED. - -T_cmp = 1.5 -nsteps_cmp = round(Int, T_cmp / dt) -@assert isapprox(nsteps_cmp * dt, T_cmp; atol=1e-12) - -pt_order1 = build_process_tensor( - system, - system.sites[1]; - environment=bath, - dt=dt, - nsteps=nsteps_cmp, - alg=Exact(), - sys_alg=Trotter{1}(), -) -pt_order2 = build_process_tensor( - system, - system.sites[1]; - environment=bath, - dt=dt, - nsteps=nsteps_cmp, - alg=Exact(), - sys_alg=Trotter{2}(), -) - -Sx = OpSum() -Sx += 1.0, "Sx", 1 - -function final_sx_evaluate(pt) - out = output_sites(pt, pt.nsteps - 1) - seq = default_schedule(pt) - add!(seq, state_preparation(ρ_sys0_h), 0) - add!(seq, observable_measurement(Sx, out), pt.nsteps) - return real(evaluate_process(pt, seq)) -end - -sx_t1 = final_sx_evaluate(pt_order1) -sx_t2 = final_sx_evaluate(pt_order2) - -U_ed = liouvillian_propagator(H_full_single, joint_liouv_single, T_cmp; alg=Exact()) -ρ_joint_ed = apply(U_ed, copy(ρ_joint0_l_single); cutoff=0.0, maxdim=typemax(Int)) -ρ_ed = partial_trace_system(to_hilbert(ρ_joint_ed), dsys, denv_single) - -sx_ed = real(tr(ρ_ed * (σx / 2))) - -err_t1 = abs(sx_t1 - sx_ed) -err_t2 = abs(sx_t2 - sx_ed) - -@printf("⟨Sˣ⟩(t=%.1f) Trotter{1} / Trotter{2} / ED = %.6f / %.6f / %.6f\n", T_cmp, sx_t1, sx_t2, sx_ed) -@printf("|⟨Sˣ⟩_PT − ⟨Sˣ⟩_ED| Trotter{1} = %.3e, Trotter{2} = %.3e\n", err_t1, err_t2) -@assert err_t2 < err_t1 -@assert err_t2 < 0.1 * err_t1 - +# That jump is the Strang splitting, not a failure of the bath contraction. +# `Trotter{2}()` writes each interval as a free-system half step, the exact +# bath propagator, and the matching half step, +# +# ```math +# e^{-iH_S\Delta t/2}\, +# e^{-i(H_B+H_{SB})\Delta t}\, +# e^{-iH_S\Delta t/2}. +# ``` +# +# The system and bath generators do not commute, so each step has a local +# error of order $\Delta t^3$. With $\Delta t=0.1$ that error is already visible +# on a logarithmic axis, and later steps accumulate it. The mode propagator +# itself is exact. +# +# ![Multimode spin-bath process tensor](../assets/examples/pt_tfim_multimode.png) # -# !!! summary "Example takeaways" -# - `build_process_tensor` integrates out the bath once; the returned -# `ProcessTensor` is the reusable open-system object. -# - `evolve(pt, ρ0)` and `evaluate_process(pt, seq)` probe that same object -# with different instrument schedules—no second bath evolution is needed. -# - Multimode baths change the PT-MPO memory structure, not the user-facing -# workflow for preparing states, evolving, or measuring observables. -# - At fixed ``Δt``, `sys_alg=Trotter{2}()` typically reduces the -# system–bath split error relative to `Trotter{1}()`. +# The four-spin bath uses the same splitting. Its flatter oscillations are an +# outcome of the change of model. +# +# !!! tip " Try changing" +# These edits test the Strang jump in the single-mode error panel. A new +# system preparation can reuse the stored process tensor. A new `dt` or +# `sys_alg` cannot: those choices are built into the cores. +# - Halve `dt` and double `nsteps`, keeping the same final time. The +# first-step jump should drop by about a factor of eight if the +# $\Delta t^3$ error dominates. +# - Replace `Trotter{2}()` by `Trotter{1}()`. The first-order sandwich is +# asymmetric, and its local error is $\mathcal O(\Delta t^2)$ rather than +# $\mathcal O(\Delta t^3)$. The bath propagator stays exact either way. +# - Change the system preparation to `"Dn"`. Changing a bath preparation +# or coupling still requires a new construction. +# +# The workflow remains the same for both baths: construct the process once, +# then contract it for a trajectory or a selected readout. diff --git a/docs/literate/examples/tdvp_time_evolution.jl b/docs/literate/examples/tdvp_time_evolution.jl deleted file mode 100644 index 3b7b314..0000000 --- a/docs/literate/examples/tdvp_time_evolution.jl +++ /dev/null @@ -1,355 +0,0 @@ -# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors #src -# SPDX-License-Identifier: MIT #src -# #src -# File: docs/literate/examples/tdvp_time_evolution.jl #src -# Contributor: Gauthameshwar S. #src -# #src -# Demonstrates two-site TDVP for the same spin chain in Hilbert and Liouville #src -# space, with a compact exact small-system reference. #src - -# # TDVP time evolution -# -# The time-dependent variational principle (TDVP) projects an evolution -# equation onto the manifold of matrix-product states. This example keeps its -# central distinction explicit: -# -# - Hilbert space evolves ``|\psi\rangle`` with ``-iH``. -# - Liouville space evolves ``|\rho\rangle\rangle`` with the Liouvillian -# ``\mathcal L``. -# -# We use two-site TDVP in both cases so the bond dimension can grow, and briefly -# show how global subspace expansion (GSE) supplies that missing growth to -# one-site TDVP. -# -# !!! related "Related material" -# - Tutorial: [Unitary Dynamics](@ref) -# - Theory: [Tensor Networks in Physics](../theory/tensor_networks.md) -# -# !!! script "Companion script" -# Advanced figures for the complete 1TDVP, 1TDVP+GSE, and 2TDVP benchmark are -# generated by -# [`scripts/tdvp_tfim_unitary.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/main/scripts/tdvp_tfim_unitary.jl). - -# ## Transverse-field Ising model -# -# The Hamiltonian and initial state are -# -# ```math -# H=-J\sum_{j=1}^{N-1}Z_jZ_{j+1}-h\sum_{j=1}^{N}X_j, -# \qquad -# |\psi(0)\rangle=|\mathrm{Up}\cdots\mathrm{Up}\rangle . -# ``` - -using ITensors -using LinearAlgebra -using ProcessTensors -import ITensorMPS - -const N = 4 -const J = 1.0 -const h = 1.2 -const dt = 0.1 -const final_time = 2.0 -const nsteps = round(Int, final_time / dt) -const maxdim = 64 -const cutoff = 1e-10 - -sites = siteinds("S=1/2", N) -liouville_sites = liouv_sites(sites) - -H = let H_local = OpSum() - for j in 1:(N - 1) - H_local += -J, "Z", j, "Z", j + 1 - end - for j in 1:N - H_local += -h, "X", j - end - H_local -end - -H_mpo = MPO(H, sites) -L_mpo = liouvillian_mpo( - H, - liouville_sites; - jump_ops=Tuple{Number,String,Int}[], -) - -initial_state = MPS(sites, fill("Up", N)) -initial_density = to_dm(initial_state) -initial_density_liouville = - to_liouville(initial_density; sites=liouville_sites) - -mean_x = let observable = OpSum() - for j in 1:N - observable += 1 / N, "X", j - end - observable -end -mean_x_mpo = MPO(mean_x, sites) -mean_x_liouville = to_liouville(mean_x_mpo; sites=liouville_sites) - -@assert isapprox(real(inner(initial_state, initial_state)), 1; atol=1e-12) - -# ## Exact small-system reference -# -# Exact diagonalization (ED) is practical here only because ``N=4``. It gives -# the untruncated evolution against which we measure the TDVP density-matrix -# error and observable error. Agreement with ED on this small system checks the -# timestep convention, the Hilbert/Liouville mapping, and the projected tensor- -# network evolution before TDVP is used on systems too large for dense methods. - -dimension = prod(dim.(sites)) - -H_tensor = foldl(*, H_mpo) -H_dense = reshape( - ComplexF64.(Array(H_tensor, prime.(sites)..., sites...)), - dimension, - dimension, -) - -state_tensor = foldl(*, initial_state) -state_dense = vec(ComplexF64.(Array(state_tensor, sites...))) -density_dense = state_dense * state_dense' - -mean_x_tensor = foldl(*, mean_x_mpo) -mean_x_dense = reshape( - ComplexF64.(Array(mean_x_tensor, prime.(sites)..., sites...)), - dimension, - dimension, -) - -function exact_density_at( - time::Real, - H_dense::AbstractMatrix, - density0::AbstractMatrix, -) - propagator = exp(-1im * time * H_dense) - return propagator * density0 * propagator' -end - -function exact_sx_trajectory( - H_dense::AbstractMatrix, - density0::AbstractMatrix, - mean_x_dense::AbstractMatrix, - times::AbstractVector, -) - return [ - real(tr(exact_density_at(time, H_dense, density0) * mean_x_dense)) - for time in times - ] -end - -times = collect(range(0.0; step=dt, length=nsteps + 1)) -exact_final_density = - exact_density_at(final_time, H_dense, density_dense) -sx_exact = - exact_sx_trajectory(H_dense, density_dense, mean_x_dense, times) - -# ## Hilbert-space Two-siteTDVP -# -# Schrödinger evolution obeys -# -# ```math -# \frac{d}{dt}|\psi(t)\rangle=-iH|\psi(t)\rangle . -# ``` -# -# Therefore the two-site TDVP (2TDVP) timestep is complex, `-1im * dt`, and the operator is the -# Hamiltonian MPO. We implement the 2TDVP variant with `nsite=2` to allow for -# entanglement and operator-space bonds to grow. - -hilbert_trajectory = let - state = copy(initial_state) - sx = Float64[real(inner(state', mean_x_mpo, state))] - energies = Float64[real(inner(state', H_mpo, state))] - elapsed = 0.0 - for _ in 1:nsteps - elapsed += @elapsed state = tdvp( - H_mpo, - -1im * dt, - state; - time_step=-1im * dt, - nsite=2, - maxdim=maxdim, - cutoff=cutoff, - outputlevel=0, - ) - push!(sx, real(inner(state', mean_x_mpo, state))) - push!(energies, real(inner(state', H_mpo, state))) - end - - (; state, sx, energies, elapsed) -end - -hilbert_density = to_dm(hilbert_trajectory.state) -hilbert_density_tensor = foldl(*, hilbert_density) -hilbert_density_dense = reshape( - ComplexF64.( - Array(hilbert_density_tensor, prime.(sites)..., sites...) - ), - dimension, - dimension, -) - -hilbert_error = - norm(hilbert_density_dense - exact_final_density) / - norm(exact_final_density) -hilbert_energy_drift = - maximum(abs.(hilbert_trajectory.energies .- first(hilbert_trajectory.energies))) - -println("2TDVP on Hilbert space with dt=$(dt)") -println(" Wall time to final state: $(round(hilbert_trajectory.elapsed, digits=4)) s") -println(" Relative error of the final density matrix: $(round(hilbert_error, digits=6))") -println(" Maximum energy drift: $(round(hilbert_energy_drift, sigdigits=4))") -println(" Max bond dim of final state: $(maxlinkdim(hilbert_trajectory.state))") - -@assert maximum(abs.(hilbert_trajectory.sx - sx_exact)) < 0.05 -@assert hilbert_error < 0.05 -@assert hilbert_energy_drift < 1e-6 - -# ## 1TDVP with global subspace expansion -# -# One-site TDVP (1TDVP) projects the evolution onto an MPS manifold with fixed -# bond dimensions. This allows the algorithm to conserve quantities such as the -# system's total energy in a closed system evolution. But one main drawback of this -# technique, compared to the 2TDVP variant, is that the bond dimension stays fixed -# throughout the evolution. If we start with a product state with bond -# dimension one, 1TDVP cannot introduce the new Schmidt directions -# generated by the interacting Hamiltonian. Its projected update can therefore -# become trapped in an undersized variational manifold—the “1TDVP getting -# stuck” behavior visible in the error plots below. -# -# Global subspace expansion enriches the MPS bonds before a one-site sweep. The -# `global_krylov` algorithm adds directions approximating -# ``H|\psi\rangle,H^2|\psi\rangle,\ldots`` and truncates the enlarged basis. -# Importantly, GSE does not change the physical wavefunction. It only rewrites the -# same state in a larger bond-dimension representation, so later 1TDVP sweeps have -# room to leave the original product-state manifold. - -println("Initial state bond dimensions: ", linkdims(initial_state)) - -expanded_core = ITensorMPS.expand( - initial_state.core, - H_mpo.core; - alg="global_krylov", - krylovdim=2, - cutoff=1e-8, - apply_kwargs=(; maxdim=maxdim), -) -ITensorMPS.orthogonalize!(expanded_core, 1) -gse_state = MPS{Hilbert}(expanded_core) - -println("After global subspace expansion bond dimensions: ", linkdims(gse_state)) - -overlap_initial_gse = inner(initial_state, gse_state) -println("Overlap of initial and expanded state: ", overlap_initial_gse) -@assert isapprox(overlap_initial_gse, 1.0; atol=1e-12) - -# With that enlarged representation available, one 1TDVP step can use the new bond directions: - -gse_state_evolved = tdvp( - H_mpo, - -1im * dt, - gse_state; - time_step=-1im * dt, - nsite=1, - maxdim=maxdim, - cutoff=cutoff, - outputlevel=0, -) - -println("Bond dimensions after 1TDVP evolution: ", linkdims(gse_state_evolved)) -@assert maxlinkdim(gse_state) > maxlinkdim(initial_state) - -# In a full trajectory, repeat the expansion periodically before each `nsite=1` update. -# The same construction applies in Liouville space with `L_mpo = liouvillian_mpo(...)`. - -# Plain 1TDVP remains trapped at its initial bond dimension, whereas GSE opens -# useful variational directions and sharply reduces its error. For this problem, -# 2TDVP provides the most accurate bond-growing evolution, while 1TDVP+GSE also -# conserves energy very well. -# -# ![Hilbert-space TDVP and exact mean spin](../assets/examples/tdvp_tfim_unitary_hilbert_dynamics_mx.png) -# ![Hilbert-space TDVP density-matrix error with respect to ED](../assets/examples/tdvp_tfim_unitary_hilbert_rho_error.png) -# ![Hilbert-space TDVP energy drift](../assets/examples/tdvp_tfim_unitary_hilbert_energy_drift.png) -# -# ## Liouville-space TDVP -# -# The vectorized density matrix obeys -# -# ```math -# \frac{d}{dt}|\rho(t)\rangle\rangle -# = -# \mathcal L|\rho(t)\rangle\rangle, -# \qquad -# \mathcal L\rho=-i[H,\rho]. -# ``` -# -# The operator is now `MPO{Liouville}` and the timestep is the real duration -# `dt`, because the factor ``-i`` is already contained in ``\mathcal L``. - -liouville_trajectory = let - density = copy(initial_density_liouville) - sx = Float64[real(inner(mean_x_liouville, density))] - elapsed = 0.0 - for _ in 1:nsteps - elapsed += @elapsed density = tdvp( - L_mpo, - dt, - density; - time_step=dt, - nsite=2, - maxdim=maxdim, - cutoff=cutoff, - outputlevel=0, - ) - push!(sx, real(inner(mean_x_liouville, density))) - end - - (; density, sx, elapsed) -end - -liouville_density = to_hilbert(liouville_trajectory.density) -liouville_density_tensor = foldl(*, liouville_density) -liouville_density_dense = reshape( - ComplexF64.( - Array(liouville_density_tensor, prime.(sites)..., sites...) - ), - dimension, - dimension, -) - -liouville_error = - norm(liouville_density_dense - exact_final_density) / - norm(exact_final_density) - -println("2TDVP on Liouville space with dt=$(dt)") -println(" Wall time to final state: $(round(liouville_trajectory.elapsed, digits=4)) s") -println(" Relative error of the final density matrix: $(round(liouville_error, digits=6))") -println(" Max bond dim of final state: $(maxlinkdim(liouville_trajectory.density))") - -@assert maximum(abs.(liouville_trajectory.sx - sx_exact)) < 0.05 -@assert liouville_error < 0.05 - -# GSE also prevents plain Liouville 1TDVP from remaining confined to its initial -# operator-space bonds, although this method no longer conserves energy. This is because, in Liouville space, TDVP is applied to the vectorise density matrix under the Liouvillian superoperator. The physical energy -# $$E(t) = \mathrm{tr}(\rho(t)H)$$ is not the same variational obhect that Hilbert-space 1TDVP -# conservation argument protects. Therefore, energy conservation in Liouville space should not -# be interpreted in the same way as in Hilbert space. -# -# ![Liouville-space TDVP and exact mean spin](../assets/examples/tdvp_tfim_unitary_liouville_dynamics_mx.png) -# ![Liouville-space TDVP density-matrix error with respect to ED](../assets/examples/tdvp_tfim_unitary_liouville_rho_error.png) -# ![Liouville-space TDVP energy drift](../assets/examples/tdvp_tfim_unitary_liouville_energy_drift.png) -# -# !!! note "Variational meaning" -# Hilbert-space TDVP projects Schrödinger evolution for a pure state. -# Liouville-space TDVP projects the superoperator evolution of a vectorized -# density matrix. They encode the same closed-system physics here, but act -# on different MPS manifolds and generally have different bond dimensions. -# -# !!! summary "Example takeaways" -# - Hilbert TDVP uses `H_mpo` with the complex timestep `-1im * dt`. -# - Liouville TDVP uses `L_mpo = liouvillian_mpo(...)` with the real timestep `dt`. -# - `nsite=2` allows entanglement and operator-space bonds to grow. -# - Global subspace expansion adds Krylov directions so 1TDVP is not trapped -# in the fixed bond dimensions of the initial MPS. -# - Energy is conserved for the Hilbert-space 1TDVP, but not for the Liouville-space 1TDVP. diff --git a/docs/literate/examples/tebd_time_evolution.jl b/docs/literate/examples/tebd_time_evolution.jl deleted file mode 100644 index f65be1b..0000000 --- a/docs/literate/examples/tebd_time_evolution.jl +++ /dev/null @@ -1,259 +0,0 @@ -# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors #src -# SPDX-License-Identifier: MIT #src -# #src -# File: docs/literate/examples/tebd_time_evolution.jl #src -# Contributor: Gauthameshwar S. #src -# #src -# Demonstrates unitary TEBD for the same spin chain in Hilbert and Liouville #src -# space, with a compact exact small-system reference. #src - -# # TEBD time evolution -# -# Time-evolving block decimation (TEBD) approximates the propagator by a -# sequence of local Suzuki–Trotter gates. This example keeps the main comparison -# visible: evolve a pure-state MPS in Hilbert space, then evolve its vectorized -# density matrix in Liouville space. -# -# Both representations describe the same closed-system physics. This page keeps -# a compact Hilbert/Liouville comparison. -# -# !!! related "Related material" -# - Tutorial: [Unitary Dynamics](@ref) -# - Theory: [Tensor Networks in Physics](../theory/tensor_networks.md) -# -# !!! script "Companion script" -# Advanced figures (timestep sweeps, Trotter-order benchmarks, and detailed -# error diagnostics) are generated by -# [`scripts/tebd_tfim_unitary.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/main/scripts/tebd_tfim_unitary.jl). - -# ## Transverse-field Ising model -# -# We study -# -# ```math -# H=-J\sum_{j=1}^{N-1}Z_jZ_{j+1}-h\sum_{j=1}^{N}X_j -# ``` -# -# from the product state -# ``|\psi(0)\rangle=|\mathrm{Up}\cdots\mathrm{Up}\rangle``. - -using ITensors -using LinearAlgebra -using ProcessTensors -using ITensors.Ops: Trotter - -const N = 4 -const J = 1.0 -const h = 1.2 -const dt = 0.1 -const final_time = 2.0 -const nsteps = round(Int, final_time / dt) -const maxdim = 64 -const cutoff = 1e-12 - -sites = siteinds("S=1/2", N) -liouville_sites = liouv_sites(sites) - -H = let H_local = OpSum() - for j in 1:(N - 1) - H_local += -J, "Z", j, "Z", j + 1 - end - for j in 1:N - H_local += -h, "X", j - end - H_local -end - -H_mpo = MPO(H, sites) -initial_state = MPS(sites, fill("Up", N)) -initial_density = to_dm(initial_state) -initial_density_liouville = - to_liouville(initial_density; sites=liouville_sites) - -mean_x = let observable = OpSum() - for j in 1:N - observable += 1 / N, "X", j - end - observable -end -mean_x_mpo = MPO(mean_x, sites) -mean_x_liouville = to_liouville(mean_x_mpo; sites=liouville_sites) - -@assert isapprox(real(inner(initial_state, initial_state)), 1; atol=1e-12) - -# ## Exact small-system reference -# -# Exact diagonalization is practical here only because ``N=4``. The tensor -# contractions below expose the dense Hamiltonian, initial state, and mean-spin -# observable directly. These will later be compared to the TEBD results we obtain -# using our `tebd` function. - -dimension = prod(dim.(sites)) - -H_tensor = foldl(*, H_mpo) -H_dense = reshape( - ComplexF64.(Array(H_tensor, prime.(sites)..., sites...)), - dimension, - dimension, -) - -state_tensor = foldl(*, initial_state) -state_dense = vec(ComplexF64.(Array(state_tensor, sites...))) -density_dense = state_dense * state_dense' - -mean_x_tensor = foldl(*, mean_x_mpo) -mean_x_dense = reshape( - ComplexF64.(Array(mean_x_tensor, prime.(sites)..., sites...)), - dimension, - dimension, -) - -function exact_density_at( - time::Real, - H_dense::AbstractMatrix, - density0::AbstractMatrix, -) - propagator = exp(-1im * time * H_dense) - return propagator * density0 * propagator' -end - -function exact_sx_trajectory( - H_dense::AbstractMatrix, - density0::AbstractMatrix, - mean_x_dense::AbstractMatrix, - times::AbstractVector, -) - return [ - real(tr(exact_density_at(time, H_dense, density0) * mean_x_dense)) - for time in times - ] -end - -times = collect(range(0.0; step=dt, length=nsteps + 1)) -sx_exact = - exact_sx_trajectory(H_dense, density_dense, mean_x_dense, times) - -# ## Hilbert-space TEBD -# -# In Hilbert space, `tebd` applies the Hamiltonian gates directly to -# ``|\psi\rangle``. We use second-order Trotter splitting and record -# ``\langle\bar X\rangle`` after every step. - -hilbert_trajectory = let - state = copy(initial_state) - sx = Float64[real(inner(state', mean_x_mpo, state))] - elapsed = 0.0 - for _ in 1:nsteps - elapsed += @elapsed state = tebd( - state, - H, - dt, - dt; - alg=Trotter{2}(), - maxdim=maxdim, - cutoff=cutoff, - ) - push!(sx, real(inner(state', mean_x_mpo, state))) - end - - (; state, sx, elapsed) -end - -hilbert_density = to_dm(hilbert_trajectory.state) -hilbert_density_tensor = foldl(*, hilbert_density) -hilbert_density_dense = reshape( - ComplexF64.( - Array(hilbert_density_tensor, prime.(sites)..., sites...) - ), - dimension, - dimension, -) - -exact_final_density = - exact_density_at(final_time, H_dense, density_dense) -hilbert_error = - norm(hilbert_density_dense - exact_final_density) / - norm(exact_final_density) - -println("TEBD(2) on Hilbert space with dt=$(dt)") -println(" Wall time to final state: $(round(hilbert_trajectory.elapsed, digits=4)) s") -println(" Norm error of the final matrix: $(round(hilbert_error, digits=6))") -println(" Max bond dim of final state: $(maxlinkdim(hilbert_trajectory.state))") - -@assert all(isfinite, hilbert_trajectory.sx) -@assert hilbert_error < 0.05 - -# The Hilbert-space dynamics and density-matrix error against ED look like: -# -# ![Hilbert-space TEBD and exact mean spin](../assets/examples/tebd_tfim_unitary_hilbert_dynamics_mx.png) -# ![Hilbert-space TEBD error with respect to ED](../assets/examples/tebd_tfim_unitary_hilbert_rho_error.png) -# ## Liouville-space TEBD -# -# In Liouville space, the state is ``|\rho\rangle\rangle`` and the same public -# `tebd` function constructs the commutator Liouvillian internally. No jump -# operators are supplied, so this remains closed unitary dynamics. - -empty_jumps = Tuple{Number,String,Int}[] - -liouville_trajectory = let - density = copy(initial_density_liouville) - sx = Float64[real(inner(mean_x_liouville, density))] - elapsed = 0.0 - for _ in 1:nsteps - elapsed += @elapsed density = tebd( - density, - H, - dt, - dt; - jump_ops=empty_jumps, - alg=Trotter{2}(), - maxdim=maxdim, - cutoff=cutoff, - ) - push!(sx, real(inner(mean_x_liouville, density))) - end - - (; density, sx, elapsed) -end - -liouville_density = to_hilbert(liouville_trajectory.density) -liouville_density_tensor = foldl(*, liouville_density) -liouville_density_dense = reshape( - ComplexF64.( - Array(liouville_density_tensor, prime.(sites)..., sites...) - ), - dimension, - dimension, -) - -liouville_error = - norm(liouville_density_dense - exact_final_density) / - norm(exact_final_density) - -println("TEBD(2) on Liouville space with dt=$(dt)") -println(" Wall time to final state: $(round(liouville_trajectory.elapsed, digits=4)) s") -println(" Norm error of the final matrix: $(round(liouville_error, digits=6))") -println(" Max bond dim of final state: $(maxlinkdim(liouville_trajectory.density))") - -@assert all(isfinite, liouville_trajectory.sx) -@assert maximum(abs.(hilbert_trajectory.sx - sx_exact)) < 0.05 -@assert maximum(abs.(liouville_trajectory.sx - sx_exact)) < 0.05 -@assert liouville_error < 0.05 - -# The Liouville trajectory is consistent with Hilbert TEBD. The final bond -# dimension is typically larger in Liouville space because one evolves a -# vectorized density matrix rather than a pure-state MPS. -# -# The corresponding Liouville-space figures are: -# -# ![Liouville-space TEBD and exact mean spin](../assets/examples/tebd_tfim_unitary_liouville_dynamics_mx.png) -# ![Liouville-space TEBD error with respect to ED](../assets/examples/tebd_tfim_unitary_liouville_rho_error.png) -# -# !!! summary "Example takeaways" -# - Hilbert TEBD evolves `MPS{Hilbert}` directly under the Hamiltonian. -# - Liouville TEBD evolves `MPS{Liouville}` under the corresponding -# commutator, with the same `tebd` entry point. -# - Liouville evolution generally needs a larger bond dimension than the -# matching pure-state Hilbert run. -# - Exact diagonalization is a small-system check; TEBD is the scalable -# tensor-network calculation. diff --git a/docs/literate/examples/thermal_spinboson_ace.jl b/docs/literate/examples/thermal_spinboson_ace.jl index c7a3247..8e938fa 100644 --- a/docs/literate/examples/thermal_spinboson_ace.jl +++ b/docs/literate/examples/thermal_spinboson_ace.jl @@ -4,397 +4,250 @@ # File: docs/literate/examples/thermal_spinboson_ace.jl #src # Contributor: Gauthameshwar S. #src # #src -# Reproduces the driven thermal spin-boson example of Cygorek and Gauger, #src -# J. Chem. Phys. 161, 074111 (2024), using ACE in ProcessTensors.jl. #src +# Literate example: driven population oscillations in a thermal bosonic bath. #src # # Thermal spin-boson dynamics using ACE # -# A coherently driven two-level system has perhaps the cleanest clock in -# quantum dynamics: prepare one level, turn on a resonant transverse drive, and -# the population undergoes perfectly periodic Rabi oscillations. A thermal -# environment spoils that clock. -# -# Here we reproduce the spin-boson example used in the ACE toolkit paper. The -# environment is a continuum of harmonic modes sampled from an Ohmic spectral -# density. Each mode begins in a thermal state and couples to the excited-state -# population of the two-level system. ACE builds the influence of these modes -# one by one and compresses their combined memory into a process-tensor MPO. -# -# The physical question is deliberately simple: -# -# > What becomes of coherent Rabi rotations once the two-level system is allowed -# > to leave distinguishable displacements in a thermal bosonic environment? +# How does a thermal bath change the Rabi oscillations of a driven two-level +# system? We sample an Ohmic spectral density into oscillator modes, prepare +# their thermal states, and use ACE to construct the process seen by the spin. +# The observable is the excited-state population $P_e(t)$, compared with the +# isolated result. # # !!! related "Related material" -# - Tutorial: [Single-Mode Process Tensor](@ref) +# - Tutorial: [Construct a process tensor](@ref) # - Theory: [Process Tensors](../theory/process_tensors.md) # # !!! script "Companion script" -# The full ``60``-mode calculation using the parameters of the ACE toolkit -# paper and the staged figure are generated by -# [`scripts/thermal_spinboson_ace.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/main/scripts/thermal_spinboson_ace.jl). +# [`scripts/thermal_spinboson_ace.jl`](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/better-docs/scripts/thermal_spinboson_ace.jl) +# generates the 60-mode, two-temperature comparison below. The model follows +# Cygorek and Gauger, J. Chem. Phys. **161**, 074111 (2024); the hotter bath +# is an additional comparison on the same oscillator grid. -# ## A driven two-level system +# ## Model and spectral density # -# In units with ``\hbar=1``, the isolated system Hamiltonian is +# We set $\hbar=1$, express frequencies in $\mathrm{ps}^{-1}$, and work in the +# rotating frame of a resonant drive. The model is # # ```math -# H_S=\frac{\Omega}{2}\sigma_x=\Omega S_x. +# H=\Omega S_x+\sum_k\left[ +# \omega_k b_k^\dagger b_k+g_k(b_k+b_k^\dagger)A +# +\frac{g_k^2}{\omega_k}A^2\right], # ``` # -# Starting from ``|g\rangle``, the excited-state population would therefore be +# with the excited-state projector # # ```math -# P_e(t)=\sin^2\!\left(\frac{\Omega t}{2}\right), +# A=|e\rangle\langle e| +# =\frac{I+Z}{2} +# =\frac{I}{2}+S_z. # ``` # -# and the oscillations would continue indefinitely. -# -# The bath changes this by coupling the excited-state projector -# ``A=|e\rangle\langle e|`` to harmonic modes, +# The coupling is a state-dependent force on each oscillator. It vanishes when +# the spin is in $|g\rangle$, and it becomes $g_k(b_k+b_k^\dagger)$ when the +# spin is in $|e\rangle$. A superposition therefore sends the two spin +# components into different bath states, which reduces the spin coherence. +# The spin starts in $|g\rangle$ (`Dn`); `Up` denotes $|e\rangle$. The bath +# starts uncorrelated with it, with each oscillator thermal under its free +# Hamiltonian. The transverse drive does not commute with $A$, so this +# dephasing also changes the population oscillations. +# +# !!! note "Why include the counterterm?" +# Completing the square moves the oscillator by $g_k A/\omega_k$ and +# leaves the static shift $-g_k^2 A^2/\omega_k$. The counterterm in $H$ is +# the opposite shift, $g_k^2 A^2/\omega_k$, including the denominator. +# Because $A^2=A$, the code adds that shift as $(g_k^2/\omega_k)$ times +# `ProjUp`. The fluctuations and the dynamical back-action remain. With a +# finite Fock cutoff, the displaced oscillator itself is also only +# approximated. +# +# The upper panel of the companion figure samples the smooth spectral density # # ```math -# H_E -# = -# \sum_k \omega_k b_k^\dagger b_k -# + -# \sum_k g_k(b_k+b_k^\dagger)A -# + -# \Delta H_{\mathrm{PS}}. +# J(\omega)=0.2\,\omega\exp[-\omega/(3\,\mathrm{ps}^{-1})]. # ``` # -# The final term, -# -# ```math -# \Delta H_{\mathrm{PS}} -# = -# \sum_k\frac{g_k^2}{\omega_k}A, -# ``` -# -# is the usual polaron-shift counterterm. It removes the static energy -# renormalization caused by displacing the oscillators, leaving the comparison -# with the resonantly driven isolated system clean. -# -# The bath is summarized by the Ohmic spectral density used in the ACE toolkit -# example, +# The modes represent that function by a weighted comb, +# ``J(\omega)\approx\sum_k g_k^2\delta(\omega-\omega_k)``. On a uniform midpoint +# grid the weights are # # ```math -# J(\omega) -# = -# 0.2\,\omega\, -# \exp\!\left[-\frac{\omega}{3\,\mathrm{ps}^{-1}}\right]. -# ``` -# -# Instead of treating this continuum analytically, ACE asks for its microscopic -# modes. Dividing ``[\omega_{\min},\omega_{\max}]`` into equal bins gives -# -# ```math -# \omega_k -# = -# \omega_{\min}+\left(k-\frac12\right)\Delta\omega, +# g_k^2=J(\omega_k)\,\Delta\omega, # \qquad -# g_k=\sqrt{J(\omega_k)\Delta\omega}. +# \omega_k=\omega_{\min}+(k-\tfrac12)\Delta\omega. # ``` # -# Increasing the number of modes therefore refines the same spectral -# environment rather than arbitrarily rescaling its interaction strength. - -# ## Assemble a modest thermal bath -# -# The publication calculation uses ``60`` modes. For the executable -# documentation build we keep the same model but use a smaller discretization -# and a short process tensor so ACE construction and ``evolve`` stay cheap. -# The companion script performs the full calculation. +# The bin width belongs in the coupling. Refining the grid should approximate +# the same ``J(\omega)``. using Logging -using Printf +using LinearAlgebra using ITensors using ITensors.Ops: Trotter using ProcessTensors -ohmic_spectral_density(ω) = 0.2 * ω * exp(-ω / 3) - -function thermal_boson_density( - physical_site, - liouville_site, - ω, - thermal_frequency, - local_dim, -) - occupations = 0:(local_dim - 1) - weights = exp.(-ω .* occupations ./ thermal_frequency) - weights ./= sum(weights) - - number_states = [ - MPS([physical_site], [string(n)]) - for n in occupations - ] - density = to_dm(number_states; coeffs=weights) - - return to_liouville( - density; - sites=[liouville_site], - ) -end - -function one_site_density_matrix(ρ) - tensor = foldl(*, ρ) - site = only( - filter( - index -> plev(index) == 0 && hastags(index, "Site"), - inds(tensor), - ), - ) - return ComplexF64.(Array(tensor, prime(site), site)) -end - -const N_bath = 4 -const local_dim = 3 - -const Ω = 3.0 -const ω_min = 0.0 -const ω_max = 30.0 -const thermal_frequency = 1.0 # k_B T / ħ, in ps^-1 - -const dt = 0.20 -const nsteps = 12 -const final_time = (nsteps - 1) * dt - -const ace_cutoff = 1e-5 -const ace_maxdim = 64 +N_bath = 4 +local_dim = 3 +Ω = 3.0 +ω_min, ω_max = 0.0, 30.0 +thermal_frequency = 1.0 # θ = k_B T / ħ, in ps^-1, not kelvin +dt, nsteps = 0.20, 12 +ace_cutoff, ace_maxdim = 1e-5, 64 +spectral_density(ω) = 0.2 * ω * exp(-ω / 3) Δω = (ω_max - ω_min) / N_bath -frequencies = [ - ω_min + (k - 0.5) * Δω - for k in 1:N_bath -] -couplings = sqrt.( - ohmic_spectral_density.(frequencies) .* Δω -) - +frequencies = [ω_min + (k - 0.5) * Δω for k in 1:N_bath] +couplings = sqrt.(spectral_density.(frequencies) .* Δω) @assert all(>(0), frequencies) -@assert all(isfinite, couplings) -# The two-level system starts in |g>. We identify `Dn` with |g> and `Up` -# with |e>, so `ProjUp` is the spin-boson coupling operator A. -system_sites = siteinds("S=1/2", 1) - -system_hamiltonian = OpSum() -system_hamiltonian += Ω, "Sx", 1 +# !!! note "Executable example versus companion figure" +# These cells use four modes, three levels per mode, and end at $t=2.2$ ps. +# Their coarse grid starts at $3.75\,\mathrm{ps}^{-1}$ and misses the +# low-frequency part of the bath. They demonstrate the workflow, not a +# converged continuum result. The figure uses 60 modes, five levels, +# $dt=0.05$ ps, $t=5$ ps, and $\theta=1.5,6\,\mathrm{ps}^{-1}$. -system = spin_system(system_sites, system_hamiltonian) -initial_density = to_dm(MPS(system_sites, ["Dn"])); - -# Each oscillator is an independent [`BosonicMode`](@ref). Its local -# Hamiltonian is ``ω_k b_k†b_k`` and its initial state is the truncated Gibbs -# state +# ## Prepare the spin and thermal modes +# +# `thermal_mode` constructs the Gibbs state in the retained oscillator space: # # ```math -# \rho_k^{\mathrm{th}} -# = -# \frac{1}{Z_k} -# \sum_{n=0}^{M-1} -# e^{-n\omega_k/(k_BT/\hbar)} -# |n\rangle\langle n|. +# \rho_k^{\mathrm{th}}=\frac{1}{Z_{k,M}} +# \sum_{n=0}^{M-1}e^{-n\omega_k/\theta}|n\rangle\langle n|, +# \qquad \theta=k_BT/\hbar. # ``` # -# The coupling `OpSum` uses local site `1` for the bath mode and local site `2` -# for the system. Each mode also carries the polaron-shift counterterm -# ``(g_k^2/ω_k)|e\rangle\langle e|``. +# The coupling `OpSum` uses site 1 for the oscillator and site 2 for the spin. +# Its last term is the counterterm $g_k^2 A^2/\omega_k$. Each mode carries its +# own copy, so the shift is included exactly once. + +system_sites = siteinds("S=1/2", 1) +H_system = OpSum() +H_system += Ω, "Sx", 1 +system = spin_system(system_sites, H_system) +initial_density = to_dm(MPS(system_sites, ["Dn"])) bath_sites = siteinds("Boson", N_bath; dim=local_dim) bath_liouville_sites = liouv_sites(bath_sites) - -modes = [ - let - ωk = frequencies[k] - gk = couplings[k] - - mode_hamiltonian = OpSum() - mode_hamiltonian += ωk, "N", 1 - - mode_coupling = OpSum() - mode_coupling += gk, "A", 1, "ProjUp", 2 - mode_coupling += gk, "Adag", 1, "ProjUp", 2 - mode_coupling += gk^2 / ωk, "ProjUp", 2 - - initial_mode_density = thermal_boson_density( - bath_sites[k], - bath_liouville_sites[k], - ωk, - thermal_frequency, - local_dim, - ) - - bosonic_mode( - [bath_liouville_sites[k]], - mode_hamiltonian, - initial_mode_density; - coupling=mode_coupling, - ) - end for k in 1:N_bath -]; - -# There are no direct oscillator-oscillator interactions. ACE can therefore -# absorb their influences sequentially. The Dense-construction size warning is -# expected and is silenced here because this example never builds a joint bath -# tensor. -bath = with_logger(NullLogger()) do - bosonic_bath(modes) -end; - -println("Thermal spin-boson ACE setup") -@printf(" bath modes: %d\n", N_bath) -@printf(" local boson dimension: %d\n", local_dim) -@printf(" drive Ω: %.3f ps^-1\n", Ω) -@printf(" frequency window: [%.1f, %.1f] ps^-1\n", ω_min, ω_max) -@printf(" frequency spacing Δω: %.3f ps^-1\n", Δω) -@printf(" k_B T / ħ: %.3f ps^-1\n", thermal_frequency) -@printf(" timestep dt: %.3f ps\n", dt) -@printf(" final time: %.2f ps\n", final_time) -@printf(" ACE threshold ε: %.1e\n", ace_cutoff) - -# ## Compress the thermal environment -# -# A direct bath density matrix for ``N`` truncated oscillators lives in a -# Liouville space of dimension ``M^{2N}``. ACE never constructs that joint -# object. It builds the influence of one oscillator, joins it to the accumulated -# process tensor, and performs forward/backward SVD sweeps before moving to the -# next mode. -# -# Singular directions are retained when ``σ_i > ε σ_1``. - -build_time = @elapsed begin - process_tensor = build_process_tensor( - system; - method=ACE( - cutoff=ace_cutoff, - maxdim=ace_maxdim, - ), - environment=bath, - dt=dt, - nsteps=nsteps, - sys_alg=Trotter{2}(), - combine_alg=Trotter{2}(), - progress=false, - ) +modes = BosonicMode[] +for k in 1:N_bath + ωk, gk = frequencies[k], couplings[k] + H_mode = OpSum() + H_mode += ωk, "N", 1 + coupling = OpSum() + coupling += gk, "A", 1, "ProjUp", 2 + coupling += gk, "Adag", 1, "ProjUp", 2 + coupling += gk^2 / ωk, "ProjUp", 2 + push!(modes, thermal_mode([bath_liouville_sites[k]], H_mode, + thermal_frequency; coupling=coupling)) end +# Silence the bath-size warning intended for a joint Dense construction. +bath = with_logger(() -> bosonic_bath(modes), NullLogger()) -println(process_tensor) - -bath_hilbert_dimension = BigInt(local_dim)^N_bath -bath_liouville_dimension = BigInt(local_dim)^(2N_bath) -max_pt_bond = maxlinkdim(process_tensor) - -println("ACE compression diagnostics") -println(" formal bath Hilbert dimension: $bath_hilbert_dimension") -println(" formal bath Liouville dimension: $bath_liouville_dimension") -@printf(" maximum PT bond dimension: %d\n", max_pt_bond) -@printf(" process-tensor build time: %.3f s\n", build_time) - -max_pt_bond < ace_maxdim || error( - "ACE reached the maxdim safety cap; increase ace_maxdim.", -) - -# ## Watch the Rabi clock lose its rhythm +# ## Construct and evaluate the process # -# Once the process tensor is built, the bath no longer appears explicitly. -# We simply pass the ground-state preparation through the stored multi-time -# environment and read out ``P_e(t)``. - -evolution_time = @elapsed begin - trajectory = evolve(process_tensor, initial_density) -end +# ACE combines the independent mode influences and compresses the temporal +# bonds. Its relative threshold retains singular values $\sigma_i>\epsilon +# \sigma_1$, subject to `maxdim`. The full bath density matrix, with formal +# dimension $M^{2N}$ in Liouville space, is never assembled. -excited_projector = ComplexF64.( - Array( - op("ProjUp", system_sites[1]), - prime(system_sites[1]), - system_sites[1], - ), +process_tensor = build_process_tensor( + system; environment=bath, dt=dt, nsteps=nsteps, + method=ACE(cutoff=ace_cutoff, maxdim=ace_maxdim, compression=:canonzip), + sys_alg=Trotter{2}(), combine_alg=Trotter{2}(), progress=false, ) +trajectory = evolve(process_tensor, initial_density); +println((element_type=eltype(trajectory.states_liouville),)) +println(sprint(show, foldl(*, last(trajectory.states_liouville)))) -excited_population = Float64[] -trace_errors = Float64[] - -for ρ in trajectory.states_hilbert - ρ_matrix = one_site_density_matrix(ρ) - trace_value = tr(ρ_matrix) +# For this one-spin output, the excited population is the first diagonal entry +# divided by the trace. The helper is reused at every time; raw trace drift is +# reported separately. No density-matrix eigenvalues are clipped. - push!( - excited_population, - real(tr(excited_projector * ρ_matrix) / trace_value), - ) - push!(trace_errors, abs(trace_value - 1)) +function one_spin_matrix(ρ) + tensor = foldl(*, ρ) + site = only(filter(i -> plev(i) == 0 && hastags(i, "Site"), inds(tensor))) + return ComplexF64.(Array(tensor, prime(site), site)) end -max_trace_error = maximum(trace_errors) - -@assert all(isfinite, excited_population) -@assert all(value -> -2e-3 ≤ value ≤ 1 + 2e-3, excited_population) +states = one_spin_matrix.(trajectory.states_hilbert) +traces = tr.(states) +@assert all(z -> isfinite(z) && abs(z) > 1e-12, traces) +population = [real(ρ[1, 1] / z) for (ρ, z) in zip(states, traces)] +isolated_population = sin.(Ω .* trajectory.times ./ 2) .^ 2 +max_trace_error = maximum(abs.(traces .- 1)) +println((final_time=last(trajectory.times), final_population=last(population), + isolated_final_population=last(isolated_population), + max_trace_error=max_trace_error, max_pt_bond=maxlinkdim(process_tensor))) +@assert all(p -> isfinite(p) && -2e-3 <= p <= 1 + 2e-3, population) @assert max_trace_error < 2e-3 -println("Reduced-system diagnostics") -@printf(" P_e(0): %.6f\n", first(excited_population)) -@printf(" P_e(t_final): %.6f\n", last(excited_population)) -@printf(" maximum trace error: %.3e\n", max_trace_error) -@printf(" trajectory evolution time: %.3f s\n", evolution_time) - -# ## What the bath does +# ## Interpret the loss of Rabi contrast # -# The companion calculation below restores the published ``60``-mode -# discretization. The upper panel shows the Ohmic spectral density sampled on a -# uniform frequency grid. The lower panel compares the isolated Rabi clock with -# ACE trajectories at two bath temperatures on that same oscillator grid. +# ![Ohmic spectral density and thermal damping of driven population oscillations](../assets/examples/thermal_spinboson_ace.png) # -# ![Ohmic spectral density and damping of Rabi oscillations by a thermal spin-boson environment](../assets/examples/thermal_spinboson_ace.png) +# The upper panel shows where the continuum is sampled. Most spectral weight +# lies near the exponential cutoff scale; the midpoint grid also resolves the +# low-frequency modes that the four-mode example misses. # -# Without the bath, the drive continually rotates population between ``|g>`` -# and ``|e>``. Coupling ``|e>`` and ``|e>`` populations rather than -# continuing to execute coherent rotations. +# In the lower panel, the isolated spin follows # -# Temperature adds another ingredient: the oscillators are not initially in -# vacuum, so both thermal fluctuations and the coherent system-driven -# displacements contribute to the environmental back-action. The two ACE curves -# use exactly the same frequencies, couplings, and spectral density; only their -# initial Gibbs temperatures differ. The hotter ``k_{\mathrm B}T/\hbar=6`` -# ``\mathrm{ps}^{-1}`` bath erases the oscillation contrast faster than the -# ``k_{\mathrm B}T/\hbar=1.5`` ``\mathrm{ps}^{-1}`` bath and drives -# ``P_e(t)`` toward ``1/2`` sooner. +# ```math +# P_e^{\mathrm{isolated}}(t)=\sin^2(\Omega t/2). +# ``` +# +# It repeatedly reaches populations zero and one, with period +# $2\pi/\Omega\simeq2.09$ ps. The bath-coupled curves +# develop lower peaks and higher troughs: population transfer becomes less +# complete on successive cycles. The hotter curve has a smaller first peak +# and is closer to $1/2$ by the end of the plotted interval. +# +# The $|g\rangle$ component leaves each oscillator undisplaced, while +# $|e\rangle$ drives it with $g_k(b_k+b_k^\dagger)$. The bath therefore retains +# which-path information, and the coherence that sustains the driven rotations +# decays. Temperature changes the initial oscillator +# fluctuations; here it changes the damping even though $J(\omega)$ and the +# couplings are held fixed. This is a comparison over the displayed time window, +# not a general assertion that every hotter bath damps every system faster. # -# Here “thermalising to a 50–50 spin state” refers to the observed loss of Rabi -# contrast and approach to equal populations in this continuously driven, -# projector-coupled model. It should not be confused with undriven energy -# relaxation to a Gibbs population set by a transverse exchange interaction. -# The process tensor captures this temperature-dependent history before we -# choose how to probe the two-level system. +# !!! note "Equal populations do not establish thermalisation" +# $P_e\simeq1/2$ specifies one observable. It neither shows that the spin is +# maximally mixed nor establishes a Gibbs state: off-diagonal coherence may +# remain. A finite, discretised bath can also exhibit later recurrences. # -# !!! note "Numerical accuracy" -# The companion benchmark follows the ACE toolkit values -# ``dt=0.05`` ps, ``N=60``, local boson dimension ``5``, and -# ``ε=10^{-5}``. Convergence can be checked independently by reducing -# `dt`, increasing the local boson dimension, refining the frequency -# discretization, and tightening the ACE threshold. +# ### Resolve the thermal oscillator space +# +# The local cutoff is especially important when $\theta/\omega_k$ is large. +# For an untruncated free oscillator, the total initial Gibbs probability above +# the retained levels is +# +# ```math +# \Pr(n\ge M)=e^{-M\omega_k/\theta}. +# ``` # -# !!! summary "Example takeaways" -# - A thermal spin-boson bath is specified microscopically by sampling a -# spectral density into oscillator frequencies and couplings. -# - Each [`BosonicMode`](@ref) carries its own Hamiltonian, Gibbs state, and -# system-mode interaction; the modes remain independent before ACE -# combines their influences. -# - ACE converts an exponentially large formal bosonic environment into a -# compressed temporal process tensor using the relative singular-value -# threshold ``σ_i > ε σ_1``. -# - The bath destroys the coherence sustaining the Rabi oscillations, so -# their contrast decays and the driven spin approaches equal ground- and -# excited-state populations, ``P_e=1/2``. -# - On the same mode grid and spectral density, the hotter Gibbs bath -# suppresses coherence faster and reaches the equal-population regime -# sooner. -# - After construction, the same thermal process tensor can be reused for -# new system preparations, controls, observables, and multi-time probes. +# It is a useful preparation diagnostic, not a bound on the eventual population +# error. + +thermal_tail = thermal_frequency == 0 ? zeros(N_bath) : + exp.(-local_dim .* frequencies ./ thermal_frequency) +println((largest_omitted_Gibbs_weight=maximum(thermal_tail),)) + +# At the companion grid's lowest frequency $\omega_1=0.25\,\mathrm{ps}^{-1}$, +# five levels omit about 0.43 of the infinite-oscillator Gibbs weight at +# $\theta=1.5$, and 0.81 at $\theta=6$. The simulated truncated Gibbs states +# are normalised, but those numbers make local-dimension convergence essential +# before interpreting the curves quantitatively as a continuum thermal bath. +# Bath-induced displacement can require additional levels even at zero temperature. +# Timestep, frequency window/grid, and ACE truncation must also be converged; +# a small trace error or an unreached bond cap does not settle these questions. +# +# !!! tip "Try changing" +# These edits test which changes in Rabi contrast are physical and which +# come from discretisation or compression. +# +# - Increase `local_dim` at fixed mode grid and temperature, particularly +# for the hotter bath. Do the peak heights and troughs stabilise? +# - Place more samples where $|J(\omega)|$ is largest and fewer where it is +# small. Give each sample a bin width or quadrature weight so that +# $g_k^2$ still represents the same $J(\omega)$. Does that redistribution +# change the Rabi oscillations? diff --git a/docs/literate/tutorials/02_liouville_basics.jl b/docs/literate/tutorials/02_liouville_basics.jl index 53e5238..b5b6c3f 100644 --- a/docs/literate/tutorials/02_liouville_basics.jl +++ b/docs/literate/tutorials/02_liouville_basics.jl @@ -437,23 +437,26 @@ println("MPO type: ", typeof(L_mpo_open)) sites_L_for_state = liouv_sites(sites) ρL_demo = to_liouville(ρ; sites=sites_L_for_state) L_from_hilbert_sites = liouvillian_mpo(H, sites) -sL_on_generator = only(siteinds(L_from_hilbert_sites)) - -println("Index on ρL: ", sites_L_for_state[1]) -println("Index on liouvillian_mpo: ", sL_on_generator) -println("Same index object? ", sites_L_for_state[1] == sL_on_generator) - -@assert sites_L_for_state[1] != sL_on_generator - -try - apply(L_from_hilbert_sites, ρL_demo) - error("expected apply to fail on mismatched Liouville indices") -catch err - println("apply fails as expected: ", typeof(err).name.name) -end - -# Contractions such as `apply(L_from_hilbert_sites, ρL_demo)` then -# fail because ITensors matches legs by index identity, not by appearance. +generator_sites = only(siteinds(L_from_hilbert_sites)) +generator_index = only(i for i in generator_sites if plev(i) == 0) +state_index = only(siteinds(ρL_demo)) + +println("Index on ρL: ", state_index) +println("Unprimed index on L: ", generator_index) +println("Same index object? ", state_index == generator_index) + +@assert state_index != generator_index + +mismatched = apply(L_from_hilbert_sites, ρL_demo) +mismatched_tensor = only(mismatched) +@assert hasind(mismatched_tensor, state_index) +@assert length(inds(mismatched_tensor)) == 3 +println("Indices left on the tensor: ", length(inds(mismatched_tensor))) + +# `apply` returns, but it does not act with this generator on `ρL`. A matched +# one-site contraction leaves a single site index. Here the state's index is +# still present, together with the generator's legs, because ITensors contracts +# indices only when they are the same object. # The fix is to pass the **same** Liouville sites to every constructor: # # ```julia diff --git a/docs/literate/tutorials/03_process_tensor_singlemode.jl b/docs/literate/tutorials/03_process_tensor_singlemode.jl new file mode 100644 index 0000000..730d4b6 --- /dev/null +++ b/docs/literate/tutorials/03_process_tensor_singlemode.jl @@ -0,0 +1,267 @@ +# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors #src +# SPDX-License-Identifier: MIT #src +# #src +# File: docs/literate/tutorials/03_process_tensor_singlemode.jl #src +# Contributor: Gauthameshwar S. #src +# #src +# Constructs a one-mode spin–boson process tensor and inspects its time legs. #src + +# # Construct a process tensor +# +# A process tensor stores how an open system responds to a sequence of +# operations. This page builds that object for one spin coupled to one +# truncated boson. The spin is the system we will probe. The boson is the bath. +# +# The next tutorial keeps this process tensor fixed and changes the experiment. +# Vectorisation is assumed only where a bath state is stored; see +# [Liouville-Space Basics](@ref) for that conversion. + +# ## Setup + +using ITensors +using ProcessTensors +using ITensors.Ops: Exact, Trotter + +# ## Spin–boson ingredients +# +# The model is a spin–boson Hamiltonian with one truncated oscillator: +# +# ```math +# H_S = h S^x, +# \qquad +# H_B = \omega a^\dagger a, +# \qquad +# H_{SB} = g S^z (a + a^\dagger). +# ``` +# +# The boson starts in the vacuum ``|0\rangle``. The displacement coupling +# ``a + a^\dagger`` acts on that state. A number coupling ``a^\dagger a`` would +# not: the vacuum is its ground state, so the interaction would be zero. +# +# The system preparation is not part of the process tensor. It is supplied later, +# when the process is evaluated. + +h = 0.6 +ω = 1.1 +g = 2.0 +n_max = 2 + +system_sites = siteinds("S=1/2", 1) + +H_S = OpSum() +H_S += h, "Sx", 1 + +system = spin_system(system_sites, H_S) + +println("System:") +println(system) + +@assert system isa SpinSystem +@assert length(system.sites) == 1 + +#- + +boson_sites = siteinds("Boson", 1; dim=n_max + 1) +boson_sites_L = liouv_sites(boson_sites) + +H_B = OpSum() +H_B += ω, "N", 1 + +ψB0 = MPS(boson_sites, ["0"]) +ρB0 = to_dm(ψB0) +ρB0_L = to_liouville(ρB0; sites=boson_sites_L) + +H_SB = OpSum() +H_SB += g, "A", 1, "Sz", 2 +H_SB += g, "Adag", 1, "Sz", 2 + +mode = bosonic_mode(boson_sites_L, H_B, ρB0_L; coupling=H_SB) +environment = bosonic_bath([mode]) + +println("Environment:") +println(environment) + +@assert mode isa BosonicMode +@assert environment isa BosonicBath +@assert dim(only(boson_sites)) == n_max + 1 + +# The coupling `OpSum` uses a local two-site convention: site `1` is the boson +# and site `2` is the spin. `bosonic_mode` stores the boson in Liouville space +# because the process tensor is built from density matrices. The Hilbert boson +# is truncated at occupations ``n = 0, 1, 2``. The `n_max` field printed with +# the mode is one less than the Liouville dimension of that site, not this +# occupation cutoff. + +# ## [Time grid and Dense construction](@id building-the-process-tensor) +# +# `dt` is the propagation interval between intervention slots. `nsteps` is the +# number of those intervals. Slot `step` is where an operation can act between +# the output of time `step - 1` and the input of time `step`. +# +# ```julia +# pt = build_process_tensor(system; environment, dt, nsteps, method=Dense()) +# ``` +# +# `method=Dense()` keeps the joint system–bath Liouville space. For this single +# truncated boson that space is small enough to exponentiate exactly. +# `alg=Exact()` builds that joint step directly, and `sys_alg=Trotter{2}()` +# places the free spin map symmetrically around it. The free spin map is already +# stored in the process tensor, so the default instrument between slots is an +# identity. + +dt = 0.1 +nsteps = 8 + +pt = build_process_tensor( + system; + environment=environment, + dt=dt, + nsteps=nsteps, + method=Dense(), + alg=Exact(), + sys_alg=Trotter{2}(), + progress=false, + verbose=false, +) + +println("Spin–boson process tensor:") +println(pt) + +@assert pt isa ProcessTensor +@assert pt.nsteps == nsteps +@assert pt.dt == dt +@assert pt.environment isa BosonicBath + +# ## What the object stores +# +# ```@raw html +#
+# Process cores and the intervention that joins adjacent intervals. Time runs right to left. +# Process cores and the intervention that joins adjacent intervals. Time runs right to left. +#
+# ``` +# +# Core ``Q^{[k]}`` owns the interval ``[t_k, t_{k+1}]``. The intervention at the +# boundary joins output ``o_{k-1}`` to input ``i_k``. +# +# Each time label has an input leg and an output leg. Input legs have prime +# level `1`. Output legs have prime level `0`. Both carry a `tstep` tag. +# +# Intervention slot `step` connects the output leg at `step - 1` to the input +# leg at `step`. For `step = 1` that is output `tstep=0` and input `tstep=1`. + +println("Input leg at time 0:") +println(input_sites(pt, 0)) + +println("Output leg at time 0:") +println(output_sites(pt, 0)) + +out_prev, in_curr = coupling_times(pt, 1) + +println("Legs connected by slot 1:") +println(out_prev) +println(in_curr) + +@assert plev(only(input_sites(pt, 0))) == 1 +@assert plev(only(output_sites(pt, 0))) == 0 +@assert only(out_prev) == only(output_sites(pt, 0)) +@assert only(in_curr) == only(input_sites(pt, 1)) + +# The bonds between cores carry the boson from one slot to the next. +# `Dense()` keeps that space uncompressed, so the bond dimension is the stored +# bath Liouville space. It is not a witness that the dynamics are non-Markovian. + +println("Temporal bond dimension = ", maxlinkdim(pt.core)) + +@assert maxlinkdim(pt.core) > 1 + +# !!! warning "Reuse Liouville indices" +# Process-tensor contractions depend on exact ITensor index identity. Use +# the sites stored by the system, the boson, and the process tensor. Do not +# build a fresh index that only looks similar. + +# ## Dense and ACE constructors +# +# `build_process_tensor` takes the same system, environment, and time grid for +# every builder. `method` selects the builder. +# +# `Dense()`, used above, is the right choice when the joint bath space still fits +# in one exact step, as it does for this one oscillator. +# +# `ACE()` is the other constructor. It joins independent bath modes one at a time +# and compresses the temporal bonds. `cutoff` is the relative singular-value +# threshold ``\sigma_i > \varepsilon \sigma_1``. It is a convergence parameter, not +# a guaranteed error on an observable. The default compression schedule is +# `:zipup_cpp`, which truncates each temporal bond during the forward join in +# the C++ ACE order. `:canonzip` joins one mode fully, then truncates from +# the final time back to the initial time. +# +# The same spin–boson model can be built with ACE. One mode is not compressed +# much, but the call and the printed object are the same ones used for a large +# bath. + +pt_ace = build_process_tensor( + system; + environment=environment, + dt=dt, + nsteps=nsteps, + method=ACE(cutoff=1e-8, compression=:zipup_cpp), + sys_alg=Trotter{2}(), + progress=false, + verbose=false, +) + +println("ACE process tensor:") +println(pt_ace) + +@assert pt_ace isa ProcessTensor +@assert pt_ace.nsteps == nsteps +@assert pt_ace.dt == dt + +# !!! tip "Progress and verbose output" +# Building the process tensor is often the expensive step of the workflow. +# `progress=true` shows a transient bar while that build runs, and +# `verbose=true` keeps a short log of the major stages after it finishes. +# The defaults are `progress=:auto` and `verbose=false`. The calls on this +# page use `progress=false` so the rendered output stays a plain printout. +# See [Advanced Usage](../advanced_usage.md) for the combinations to use +# locally, in a notebook, or on a cluster. + +# ## Save the process tensor and reuse it +# +# The expensive object is the temporal MPO. ITensorMPS writes that MPO to HDF5. +# The `ProcessTensor` wrapper itself is not an HDF5 type, so the file stores +# `pt.core`. Reading it back and passing the same system, bath, and time grid +# restores a process tensor whose site indices still match the original ones. + +using HDF5 +using ITensorMPS: MPO as ITensorMPO + +pt_file = joinpath(tempdir(), "spin_boson_process_tensor.h5") + +h5open(pt_file, "w") do file + write(file, "process_tensor", pt.core) +end + +pt_core = h5open(pt_file, "r") do file + read(file, "process_tensor", ITensorMPO) +end + +pt_loaded = ProcessTensor(pt_core, system, environment, dt, nsteps) + +println("Loaded process tensor:") +println(pt_loaded) +println("Saved file: ", pt_file) + + +# What stays fixed is this object: the spin, the boson, the coupling, the time +# grid, and the loaded tensor. What changes in the next tutorial is only the +# operations applied to the spin. +# +# [Explore a process with instruments](@ref "Process tensor instruments") prepares that spin, reads out +# trajectories and probabilities, and compares experiments on this same process. + +# !!! related "Related material" +# - Theory: [Process Tensors](../theory/process_tensors.md) +# - [Liouville-Space Basics](@ref) — vectorisation of the bath state +# - [Explore a process with instruments](@ref "Process tensor instruments") — experiments on this process tensor diff --git a/docs/literate/tutorials/03_unitary_dynamics.jl b/docs/literate/tutorials/03_unitary_dynamics.jl deleted file mode 100644 index 030c6c6..0000000 --- a/docs/literate/tutorials/03_unitary_dynamics.jl +++ /dev/null @@ -1,513 +0,0 @@ -# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors #src -# SPDX-License-Identifier: MIT #src -# #src -# File: docs/literate/tutorials/03_unitary_dynamics.jl #src -# Contributor: Gauthameshwar S. #src -# #src -# Literate tutorial source: unitary TEBD/TDVP in Hilbert and Liouville space. #src - -# # Unitary Dynamics -# -# This tutorial continues from [Liouville-Space Basics](@ref). -# -# So far we know how to build MPS/MPO objects and vectorize density matrices. -# Now we **evolve** them in time for a closed spin chain. -# -# The central question is: If we evolve a pure state in Hilbert space, and evolve -# its density matrix in Liouville space, do we get the same observables? -# -# For unitary dynamics the answer should be yes. We will check this on a tiny -# four-site chain where exact diagonalization (ED) is still possible. - -# ## Setup - -using ITensors -import ITensorMPS -import LinearAlgebra -using ProcessTensors -using ITensors.Ops: Trotter - -# -# `ITensorMPS.jl` already provides MPS/MPO objects, `OpSum`, gate application, -# TEBD, and TDVP. Refer to the [ITensorMPS time evolution docs](https://docs.itensor.org/ITensorMPS/stable/tutorials/MPSTimeEvolution.html) for more details. -# `ProcessTensors.jl` builds on this rather than replacing it. -# -# The nontrivial extension here is that the same time-evolution language is made -# available for: -# -# - Hilbert-space states, `MPS{Hilbert}`, -# - Hilbert-space operators, `MPO{Hilbert}`, -# - vectorized density matrices, `MPS{Liouville}`, -# - Liouville-space generators, `MPO{Liouville}`. -# -# This tutorial first reviews Hilbert-space TEBD/TDVP, then shows how the same -# physics can be evolved in Liouville space. - -# ## Model and exact diagonalization -# -# We use a transverse-field Ising chain on $N=4$ spins: -# -# ```math -# H = H_X + H_{ZZ}, -# \qquad -# H_{ZZ} = -J\sum_{j=1}^{N-1} S^z_j S^z_{j+1}, -# \qquad -# H_X = -h\sum_{j=1}^{N} S^x_j. -# ``` -# -# The initial state is the product state $|\uparrow\rangle^{\otimes N}$. -# -# Because $2^4 = 16$, we can form the dense Hamiltonian matrix and compare -# tensor-network evolution against the exact unitary -# -# ```math -# |\psi(t)\rangle = e^{-iHt}|\psi(0)\rangle. -# ``` - -N = 4 -J = 1.0 -h = 1.2 -dt = 0.05 -T = 1.0 -maxdim = 32 -cutoff = 1e-10 -sample_times = collect(range(0, T; length=5)) - -function tfim_hamiltonian(N; J=1.0, h=1.2) - os = OpSum() - for j in 1:(N - 1) - os += -J, "Sz", j, "Sz", j + 1 - end - for j in 1:N - os += -h, "Sx", j - end - return os -end - -sites = siteinds("S=1/2", N) -H = tfim_hamiltonian(N; J, h) -H_mpo = MPO(H, sites) -ψ0 = MPS(sites, fill("Up", N)) - -println("Hamiltonian OpSum:") -println(H) - -@assert ψ0 isa MPS{Hilbert} -@assert H_mpo isa MPO{Hilbert} - -# To perform the classical Exact Diagonalisation (ED) dynamics, we need to construct the dense matrices and vectors -# from the MPO and MPS objects. Once we have them, we can use LAPACK's `exp` function to compute the unitary operator -# and use it to predict the time dynamics. -# -# !!! note "Small dense helper functions" -# The next few functions contract MPOs to dense matrices for **validation -# only** on this tiny chain. They are not scalable -# to larger systems like tensor-networks. - -function dense_mpo_matrix(W, sites) - T = foldl(*, W) - D = prod(dim.(sites)) - A = Array(T, prime.(sites)..., sites...) - return reshape(ComplexF64.(A), D, D) -end - -function local_sz_matrices(sites, N) - mats = Vector{Matrix{ComplexF64}}(undef, N) - for j in 1:N - os = OpSum() - os += 1.0, "Sz", j - mats[j] = dense_mpo_matrix(MPO(os, sites), sites) - end - return mats -end - -function mean_sz_from_density(ρ_dense, Sz_dense, N) - total = 0.0 - for j in 1:N - total += real(LinearAlgebra.tr(Sz_dense[j] * ρ_dense)) - end - return total / N -end - -H_dense = dense_mpo_matrix(H_mpo, sites) -Sz_dense = local_sz_matrices(sites, N) - -ψ0_dense = let - Tψ = foldl(*, ψ0) - vec(ComplexF64.(Array(Tψ, sites...))) -end; - -# At time $t$ the exact energy and mean magnetization are -# $E(t)=\operatorname{Tr}(H\rho(t))$ and -# $\langle S^z\rangle = \frac{1}{N}\sum_j \operatorname{Tr}(S^z_j \rho(t))$, -# computed fully in the dense ED reference below. - -function exact_energy_and_mz(t, H_dense, ψ0_dense, Sz_dense, N) - ψt = iszero(t) ? ψ0_dense : LinearAlgebra.exp(-1im * t * H_dense) * ψ0_dense - ρ = ψt * ψt' - E = real(LinearAlgebra.tr(H_dense * ρ)) - mz = mean_sz_from_density(ρ, Sz_dense, N) - return E, mz, ρ -end - -E0, mz0, _ = exact_energy_and_mz(0.0, H_dense, ψ0_dense, Sz_dense, N) -E1, mz1, _ = exact_energy_and_mz(1.0, H_dense, ψ0_dense, Sz_dense, N) -println("Exact reference at t = 0: E = ", E0, ", mean ⟨Sz⟩ = ", mz0) -println("Exact reference at t = 1: E = ", E1, ", mean ⟨Sz⟩ = ", mz1) - -# ## TEBD time evolution -# -# **Time-evolving block decimation (TEBD)** approximates the short-time propagator -# -# ```math -# U(\Delta t) = e^{-iH\Delta t} -# ``` -# -# by a product of **local gates**. The gates come from a Suzuki–Trotter -# factorization of $H = H_X + H_{ZZ}$. -# -# For a second-order Trotter step (`Trotter{2}()`), schematically -# -# ```math -# e^{-i(H_X + H_{ZZ})\Delta t} \approx -# e^{-iH_X \Delta t/2}\, -# e^{-iH_{ZZ}\Delta t}\, -# e^{-iH_X \Delta t/2} -# + \mathcal{O}(\Delta t^3). -# ``` -# -# `ProcessTensors.jl` builds these gates through `ProcessTensors.trotter_gates` -# and applies them repeatedly in `tebd`. - -# ### Inspecting the Trotter gates -# -# `ProcessTensors.trotter_gates` expands one Trotter step into local ITensor -# gates. Orders `1` and `2` use the `ITensors.Ops` factorization; even orders -# `n >= 4` are built recursively with Yoshida's symmetric fractal composition in -# ProcessTensors.jl. -# For the specified Hamiltonian, we would have four on-site terms and three -# two-site terms corresponding to each term in the Hamiltonian. So in the -# first-order Trotter, we would expect a total of seven gates, and for the -# second-order Trotter, we would expect twice that. - -println("Gates per Trotter step on this chain:") -for order in (1, 2, 4) - alg = Trotter{order}() - step_gates = ProcessTensors.trotter_gates(H, sites, -im * dt; alg=alg) - println(" Trotter{", order, "}: ", length(step_gates), " gates") -end - -gates = ProcessTensors.trotter_gates(H, sites, -im * dt; alg=Trotter{2}()) -println("Indices of the first Trotter{2} gate: ", inds(gates[1])) - -# !!! note "Higher Trotter orders" -# `Trotter{4}()`, `Trotter{6}()`, and other even orders are supported via -# Yoshida fractal composition in this package. Odd orders `>= 3` are not implemented yet. - -# ### Evolving with `tebd` -# -# The call -# -# ```julia -# tebd(ψ, H, dt, Δt; alg=Trotter{2}()) -# ``` -# -# applies `round(Δt/dt)` Trotter steps to evolve from the current MPS for a -# duration `Δt`. Truncation is controlled by `maxdim` and `cutoff`. -# -# The energy is $\langle H\rangle = \langle\psi|H|\psi\rangle$, computed as -# `real(inner(ψ', H_mpo, ψ))`. - -ψ1 = tebd(ψ0, H, dt, dt; alg=Trotter{2}(), maxdim=maxdim, cutoff=cutoff) - -E1 = real(inner(ψ1', H_mpo, ψ1)) -mz1 = sum(expect(ψ1, "Sz")) / N - -println("After one TEBD step (Δt = ", dt, "):") -println(" energy = ", E1) -println(" mean ⟨Sz⟩ = ", mz1) - -@assert ψ1 isa MPS{Hilbert} - -# ### Trotter order comparison -# -# Higher Trotter order usually reduces splitting error at fixed `dt`. Here is a -# compact check at the final time $T$: - -_, _, ρ_exact_T = exact_energy_and_mz(T, H_dense, ψ0_dense, Sz_dense, N) - -println() -println("TEBD density-matrix error at t = ", T, ":") -for alg in (Trotter{1}(), Trotter{2}(), Trotter{4}()) - ψ_alg = tebd(ψ0, H, dt, T; alg, maxdim=maxdim, cutoff=cutoff) - ρ_alg = dense_mpo_matrix(to_dm(ψ_alg), sites) - err = LinearAlgebra.norm(ρ_alg - ρ_exact_T) / max(LinearAlgebra.norm(ρ_exact_T), eps()) - println(" ", typeof(alg), " ρ_err = ", round(err, digits=4)) -end - -# We must note, however, that this accuracy comes at a cost of more gate operations -# while contracting them with the state. In principle, we could go to even higher-order -# Trotter, but the numerical cost would be significant. -# -# ### TEBD vs exact evolution -# -# Instead of looking only at the final time, let us sample the trajectory. This -# shows whether the tensor-network evolution follows the exact curve, not just -# whether it lands close at one point. - -function compare_tebd(sample_times, ψ0, H, H_mpo, sites, H_dense, ψ0_dense, Sz_dense) - ψ = ψ0 - t_prev = 0.0 - println() - println("TEBD vs exact (Trotter{2}):") - println(" t E_exact E_tebd mz_exact mz_tebd") - println(" " * "-"^52) - for t in sample_times - if t > 0.0 - ψ = tebd(ψ, H, dt, t - t_prev; alg=Trotter{2}(), maxdim=maxdim, cutoff=cutoff) - t_prev = t - end - E_ex, mz_ex, ρ_ex = exact_energy_and_mz(t, H_dense, ψ0_dense, Sz_dense, N) - E_tebd = real(inner(ψ', H_mpo, ψ)) - mz_tebd = sum(expect(ψ, "Sz")) / N - ρ_tebd = dense_mpo_matrix(to_dm(ψ), sites) - ρ_err = LinearAlgebra.norm(ρ_tebd - ρ_ex) / max(LinearAlgebra.norm(ρ_ex), eps()) - println(" $(lpad(round(t, digits=2), 5)) ", - lpad(round(E_ex, digits=4), 9), " ", - lpad(round(E_tebd, digits=4), 9), " ", - lpad(round(mz_ex, digits=4), 9), " ", - lpad(round(mz_tebd, digits=4), 9), - " ρ_err=", round(ρ_err, digits=4)) - end -end - -compare_tebd(sample_times, ψ0, H, H_mpo, sites, H_dense, ψ0_dense, Sz_dense) - -# !!! note "Energy conservation and numerical drift" -# In exact unitary dynamics with a time-independent Hamiltonian, energy is -# conserved. Any drift in the TEBD/TDVP energy is a numerical error from -# Trotter splitting, time-step error, or MPS truncation. - -# ## TDVP time evolution -# -# **Time-dependent variational principle (TDVP)** takes a different view. Instead -# of applying a product of fixed gates, TDVP projects the Schrödinger equation -# -# ```math -# \frac{d}{dt}|\psi\rangle = -iH|\psi\rangle -# ``` -# -# onto the tangent space of the MPS manifold at the current state. This results -# in a more accurate time dynamics where conserved quantities remain conserved -# during the dynamics. However, the TDVP algorithm is more computationally expensive -# and also contains additional projection errors onto the subspace you restrain your -# wavefunction to. For more details on the TDVP algorithm, refer to -# [TensorNetwork.org](https://tensornetwork.org/mps/algorithms/timeevo/tdvp.html). -# -# `ITensorMPS.jl` implements the algorithm. `ProcessTensors.jl` forwards the -# call on the wrapped `.core` object and returns `MPS{Hilbert}`. -# -# The Hamiltonian is passed as an `MPO{Hilbert}` and the evolution time enters -# as a **complex** number: -# -# ```math -# |\psi(t)\rangle \approx \mathrm{TDVP}\big(H,\,-it,\,|\psi(0)\rangle\big). -# ``` -# -# So the second argument is `-im * t`, and the integrator step is `-im * dt`. - -# ### Evolving with `tdvp` - -ψ_tdvp = tdvp( - H_mpo, - -im * dt, - ψ0; - time_step=-im * dt, - nsite=2, - maxdim=maxdim, - cutoff=cutoff, - outputlevel=0, -) - -E_tdvp = real(inner(ψ_tdvp', H_mpo, ψ_tdvp)) -mz_tdvp = sum(expect(ψ_tdvp, "Sz")) / N - -println("After one TDVP step (Δt = ", dt, "):") -println(" energy = ", E_tdvp) -println(" mean ⟨Sz⟩ = ", mz_tdvp) - -@assert ψ_tdvp isa MPS{Hilbert} - -# ### TDVP vs exact evolution -# -# Now we do a direct comparison of our time evolution with ED and print the energy and magnetization -# for each time step. - -function compare_tdvp(sample_times, ψ0, H_mpo, sites, H_dense, ψ0_dense, Sz_dense) - ψ = ψ0 - t_prev = 0.0 - println() - println("TDVP vs exact:") - println(" t E_exact E_tdvp mz_exact mz_tdvp ρ_err") - println(" " * "-"^52) - for t in sample_times - if t > 0.0 - ψ = tdvp( - H_mpo, - -im * (t - t_prev), - ψ; - time_step=-im * dt, - nsite=2, - maxdim=maxdim, - cutoff=cutoff, - outputlevel=0, - ) - t_prev = t - end - E_ex, mz_ex, ρ_ex = exact_energy_and_mz(t, H_dense, ψ0_dense, Sz_dense, N) - E_tdvp = real(inner(ψ', H_mpo, ψ)) - mz_tdvp = sum(expect(ψ, "Sz")) / N - ρ_tdvp = dense_mpo_matrix(to_dm(ψ), sites) - ρ_err = LinearAlgebra.norm(ρ_tdvp - ρ_ex) / max(LinearAlgebra.norm(ρ_ex), eps()) - println(" $(lpad(round(t, digits=2), 5)) ", - lpad(round(E_ex, digits=4), 9), " ", - lpad(round(E_tdvp, digits=4), 9), " ", - lpad(round(mz_ex, digits=4), 9), " ", - lpad(round(mz_tdvp, digits=4), 9), - " ", round(ρ_err, digits=6)) - end -end - -compare_tdvp(sample_times, ψ0, H_mpo, sites, H_dense, ψ0_dense, Sz_dense) - -# ## [Hilbert versus Liouville evolution](@id hilbert-liouville-tdvp) -# -# The same unitary physics can be written in Liouville space. A pure state -# $\rho = |\psi\rangle\langle\psi|$ obeys -# -# ```math -# \frac{d\rho}{dt} = -i[H,\rho], -# \qquad -# |\rho(t)\rangle\rangle = e^{\mathcal{L}_H t}|\rho(0)\rangle\rangle, -# ``` -# -# with $\mathcal{L}_H = -iH_L + iH_R$. The Liouville generator is available as -# `liouvillian_mpo(H, sites_L)`. -# -# For TDVP the time argument is **`T`**, not `-im * T`, because the factor -# $-i$ is already inside the Liouville MPO. - -ρ0 = to_dm(ψ0) -sites_L = liouv_sites(sites) -ρL0 = to_liouville(ρ0; sites=sites_L) -L_mpo = liouvillian_mpo(H, sites_L) - -# ### Evolving with `tdvp` in Liouville space -# -# The Liouville analogue of the Hilbert TDVP call is -# -# ```julia -# tdvp(L_mpo, Δt, ρL0; time_step=dt, nsite=2, maxdim, cutoff) -# ``` -# -# Here `ρL0` is an `MPS{Liouville}` and `L_mpo` is the Liouville generator from -# `liouvillian_mpo`. The time argument is **`Δt`**, not `-im * Δt`. - -ρL1 = tdvp( - L_mpo, - dt, - ρL0; - time_step=dt, - nsite=2, - maxdim=maxdim, - cutoff=cutoff, - outputlevel=0, -) - -ρ1_liouville = dense_mpo_matrix(to_hilbert(ρL1), sites) -E_l1 = real(LinearAlgebra.tr(H_dense * ρ1_liouville)) -mz_l1 = mean_sz_from_density(ρ1_liouville, Sz_dense, N) - -println("After one Liouville TDVP step (Δt = ", dt, "):") -println(" energy = ", E_l1) -println(" mean ⟨Sz⟩ = ", mz_l1) -println(" Tr(ρ) = ", LinearAlgebra.tr(ρ1_liouville)) - -@assert ρL1 isa MPS{Liouville} - -# ### Hilbert vs Liouville TDVP -# -# Here we perform the time evolution of our initial state in a TFIM in both the -# Hilbert and Liouville spaces and see if they match. -# The strongest check is whether the density matrices agree, not just individual -# observables. We also print $\operatorname{Tr}(\rho)$ from the Liouville route. - -function compare_hilbert_liouville(sample_times, ψ0, H_mpo, L_mpo, sites, H_dense, Sz_dense) - ψ = ψ0 - ρL = ρL0 - t_prev = 0.0 - println() - println("Hilbert vs Liouville TDVP:") - println(" t E_Hilbert E_Liouv mz_Hilbert mz_Liouv Tr(ρ_L) ρ_err") - println(" " * "-"^68) - for t in sample_times - if t > 0.0 - Δt = t - t_prev - ψ = tdvp( - H_mpo, - -im * Δt, - ψ; - time_step=-im * dt, - nsite=2, - maxdim=maxdim, - cutoff=cutoff, - outputlevel=0, - ) - ρL = tdvp( - L_mpo, - Δt, - ρL; - time_step=dt, - nsite=2, - maxdim=maxdim, - cutoff=cutoff, - outputlevel=0, - ) - t_prev = t - end - E_h = real(inner(ψ', H_mpo, ψ)) - mz_h = sum(expect(ψ, "Sz")) / N - ρ_h = dense_mpo_matrix(to_dm(ψ), sites) - ρ_l = dense_mpo_matrix(to_hilbert(ρL), sites) - E_l = real(LinearAlgebra.tr(H_dense * ρ_l)) - mz_l = mean_sz_from_density(ρ_l, Sz_dense, N) - tr_ρ_l = LinearAlgebra.tr(ρ_l) - ρ_hl_err = LinearAlgebra.norm(ρ_h - ρ_l) / max(LinearAlgebra.norm(ρ_h), eps()) - println(" $(lpad(round(t, digits=2), 5)) ", - lpad(round(E_h, digits=4), 9), " ", - lpad(round(E_l, digits=4), 9), " ", - lpad(round(mz_h, digits=4), 9), " ", - lpad(round(mz_l, digits=4), 9), " ", - lpad(round(real(tr_ρ_l), digits=4), 7), " ", - round(ρ_hl_err, digits=4)) - end -end - -compare_hilbert_liouville(sample_times, ψ0, H_mpo, L_mpo, sites, H_dense, Sz_dense) - -# ### Summary -# -# - `ITensorMPS.jl` provides TEBD/TDVP; `ProcessTensors.jl` extends them to typed -# Hilbert/Liouville MPS and MPO objects. -# - TEBD approximates $e^{-iH\Delta t}$ by Trotter gates from `OpSum`. -# - TDVP projects Schrödinger evolution onto the MPS manifold; pass `-im * t`. -# - Liouville TDVP evolves `MPS{Liouville}` with `liouvillian_mpo`; pass `T`. -# - Reuse `sites_L` across `to_liouville` and `liouvillian_mpo`. -# -# !!! related "Related examples" -# - [Laser-driven TDVP dynamics](../examples/laser_driven_tdvp.md) — time-dependent -# Hilbert-space midpoint TDVP -# - [TEBD time evolution](../examples/tebd_time_evolution.md) — Hilbert/Liouville TEBD comparison -# - [TDVP time evolution](../examples/tdvp_time_evolution.md) — Hilbert/Liouville TDVP comparison -# -# Next: [Dissipative Dynamics](@ref), where jump terms make Liouville space essential. diff --git a/docs/literate/tutorials/04_dissipative_dynamics.jl b/docs/literate/tutorials/04_dissipative_dynamics.jl deleted file mode 100644 index ec693f6..0000000 --- a/docs/literate/tutorials/04_dissipative_dynamics.jl +++ /dev/null @@ -1,513 +0,0 @@ -# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors #src -# SPDX-License-Identifier: MIT #src -# #src -# File: docs/literate/tutorials/04_dissipative_dynamics.jl #src -# Contributor: Gauthameshwar S. #src -# #src -# Literate tutorial source: dissipative open dynamics in Liouville space. #src - -# # Dissipative Dynamics -# - -# In the unitary-dynamics tutorial, Liouville space was a second route to the -# same closed-system physics. For open systems, that picture becomes primary: -# the state is generally a mixed **density matrix** `ρ`, not a pure wavefunction, -# and the dynamics is usually written as a master equation rather than a -# Schrödinger equation. -# -# After vectorisation, -# -# ```math -# \rho \longmapsto |\rho\rangle\rangle, -# ``` -# -# a Liouvillian generator becomes an ordinary linear operator on the -# vectorised density, -# -# ```math -# |\rho\rangle\rangle \mapsto \mathcal{L}|\rho\rangle\rangle. -# ``` -# -# The familiar MPS/MPO workflow then carries over with different types and -# indices. Here is a quick summary table of how the Hilbert and Liouville -# space handles the same ideas in different ways. -# -# ```@raw html -# -# -# -# -# -# -# -# -# -# -# -# -# -#
ConceptHilbert spaceLiouville space
Physical objectpure or mixed ket $|\psi\rangle$ or density $\rho$vectorised density $|\rho\rangle\rangle$
Operator / generatorHamiltonian $H$Liouvillian superoperator $\mathcal{L}$
Site indicesphysical sitesliouv_sites(sites) (local dim $d^2$)
Closed-system dynamics$\mathrm{i}\hbar\,\partial_t |\psi\rangle = H|\psi\rangle$$\mathrm{i}\hbar\,\partial_t |\rho\rangle\rangle = [H,\cdot]|\rho\rangle\rangle$
Open-system dynamicsnot possible with a single-state Schrödinger equationlocal master equation for $\rho$, linear in $|\rho\rangle\rangle$
One-step propagator (TEBD)$e^{-iH\Delta t}$$e^{\mathcal{L}\Delta t}$
Package evolution callstebd(ψ, H, …) / tdvp(H_mpo, …)tebd(ρL, H, …; jump_ops=…) / tdvp(L_mpo, …)
-# ``` -# -# The tutorial below demonstrates the local Liouvillian action, -# and time evolves with Liouville TEBD and TDVP on a single-spin and two-spin model. -# -# ## Setup -# -# The helper functions below are for small dense checks in this tutorial. - -using ITensors -import ITensorMPS -import LinearAlgebra -using ProcessTensors -using ITensors.Ops: Trotter - -roundreal(x; digits=8) = round(real(x); digits=digits) - -function dense_mpo_matrix(ρL::AbstractMPS{Liouville}, sites) - W = to_hilbert(ρL) - T = foldl(*, W) - - A = Array(T, prime.(sites)..., sites...) - D = prod(dim.(sites)) - - return reshape(ComplexF64.(A), D, D) -end - -function density_matrix_properties(ρ::AbstractMatrix) - ρc = ComplexF64.(ρ) - ρh = (ρc + ρc') / 2 - - trace = LinearAlgebra.tr(ρc) - hermiticity = LinearAlgebra.norm(ρc - ρc') / max(LinearAlgebra.norm(ρc), eps(Float64)) - min_eig = minimum(real.(LinearAlgebra.eigvals(LinearAlgebra.Hermitian(ρh)))) - - return (; trace, hermiticity, min_eig) -end - -# ## [A single spin with amplitude damping](@id dissipative-lindblad-mpo) -# -# We begin with one spin-1/2 system. The Hamiltonian is -# -# ```math -# H = -# \frac{\omega}{2}S^z. -# ``` -# -# The dissipative jump is -# -# ```math -# L = S^-. -# ``` -# -# This describes amplitude damping: population flows from the upper spin state -# into the lower spin state. -# -# We choose the initial state -# -# ```math -# |+\rangle = -# \frac{1}{\sqrt{2}} -# \left( -# |\uparrow\rangle -# + -# |\downarrow\rangle -# \right). -# ``` -# -# This state has both population and coherence, so damping has something visible -# to act on. -# -# ### Dissipative jump operators -# -# The full Markovian generator has Hamiltonian and dissipative parts, -# -# ```math -# \frac{d\rho}{dt} -# = -# -i[H,\rho] -# + -# \sum_\mu -# \gamma_\mu -# \left( -# L_\mu \rho L_\mu^\dagger -# - -# \frac{1}{2} -# \{L_\mu^\dagger L_\mu,\rho\} -# \right). -# ``` -# -# In `ProcessTensors.jl`, local jumps are passed as tuples: -# -# ```julia -# jump_ops = [(γ, "S-", 1)] -# ``` -# -# This means: add the dissipator $S^-$ with jump rate $\gamma$ at site 1 -# such that the local Liouvillian action of this jump is given by -# -# ```math -# \mathcal{D}[S^-](\rho) = -# S^-\rho S^+ -# - -# \frac{1}{2} -# \{S^+ S^-,\rho\}. -# ``` -# -# For this single-spin model the Liouville generator is -# -# ```math -# \mathcal{L} -# = -# -i[H,\cdot] -# + -# \gamma\mathcal{D}[S^-](\cdot). -# ``` - -# !!! warning "Rate convention" -# The tuple `(γ, "S-", 1)` stores the jump rate `γ`. The package inserts -# `γ * D[S-](ρ)`. Do not pass `sqrt(γ)` in this tuple unless you deliberately -# want the operator coefficient itself to contain a square root. - -ω = 1.3 -γ = 0.4 - -sites = siteinds("S=1/2", 1) -sites_L = liouv_sites(sites) - -ψ0 = MPS(ComplexF64[1 / sqrt(2), 1 / sqrt(2)], sites) -ρ0 = to_dm(ψ0) -ρL0 = to_liouville(ρ0; sites=sites_L) - -println("Initial Liouville state:") -println(ρL0) - -@assert ψ0 isa MPS{Hilbert} -@assert ρ0 isa MPO{Hilbert} -@assert ρL0 isa MPS{Liouville} - -# The Hamiltonian is still written as an ordinary Hilbert-space `OpSum`. -# Dissipation enters through `jump_ops`, as introduced above. -# To see how the `OpSum` terms construct a dissipative $\mathcal{L}$, see the -# [Liouville superoperators and OpSums](@ref liouville-superoperators-and-opsums) -# section of the Liouville space theory page. - -H = OpSum() -H += (ω / 2), "Sz", 1 - -jump_ops = [(γ, "S-", 1)] - -# Build the Liouville-space generator MPO. - -L_mpo = liouvillian_mpo(H, sites_L; jump_ops=jump_ops) - -println("Liouvillian MPO:") -println(L_mpo) - -@assert L_mpo isa MPO{Liouville} - -# !!! tip "Primary package pattern" -# The key construction is: -# -# ```julia -# L_mpo = liouvillian_mpo(H, sites_L; jump_ops=jump_ops) -# ``` -# -# This is the dissipative analogue of building a Hamiltonian MPO. - -# ### Checking the local Liouvillian action -# -# For one spin, the vectorised density matrix has four components: -# -# ```math -# |\rho\rangle\rangle -# = -# \begin{pmatrix} -# \rho_{00}\\ -# \rho_{10}\\ -# \rho_{01}\\ -# \rho_{11} -# \end{pmatrix}. -# ``` -# -# For the model above, the analytical action is -# -# ```math -# \mathcal{L} -# \begin{pmatrix} -# \rho_{00}\\ -# \rho_{10}\\ -# \rho_{01}\\ -# \rho_{11} -# \end{pmatrix} -# = -# \begin{pmatrix} -# -\gamma\rho_{00}\\ -# (i\omega/2-\gamma/2)\rho_{10}\\ -# (-i\omega/2-\gamma/2)\rho_{01}\\ -# \gamma\rho_{00} -# \end{pmatrix}. -# ``` -# -# Let us check that the package MPO produces exactly this local action. - -sL = only(sites_L) - -ρvec0 = ComplexF64.(Array(ρL0[1], sL)) -ρ00, ρ10, ρ01, ρ11 = ρvec0 - -dρ_expected = ComplexF64[ - -γ * ρ00, - (1im * ω / 2 - γ / 2) * ρ10, - (-1im * ω / 2 - γ / 2) * ρ01, - γ * ρ00, -] - -dρ_from_mpo = ComplexF64.(Array(L_mpo[1] * ρL0[1], prime(sL))) - -println("‖L|ρ⟩⟩ - analytical formula‖ = ", - LinearAlgebra.norm(dρ_from_mpo - dρ_expected)) - -@assert LinearAlgebra.norm(dρ_from_mpo - dρ_expected) < 1e-10 - -# !!! note "Why this check matters" -# `liouvillian_mpo` is not just wrapping a dense matrix. It builds the local -# superoperator structure from the Hamiltonian and the jump terms. The -# single-spin formula lets us see that structure explicitly. - -# ## [Evolving the density matrix](@id dissipative-evolving-density-matrix) -# -# We now time evolve the density matrix. -# -# In Hilbert space, unitary TEBD used gates for -# -# ```math -# e^{-iH\Delta t}. -# ``` -# -# In Liouville space, dissipative TEBD uses gates for -# -# ```math -# e^{\mathcal{L}\Delta t}. -# ``` -# -# The package call is deliberately simple: -# -# ```julia -# ρL_t = tebd(ρL0, H, dt, T; jump_ops=jump_ops) -# ``` -# -# Notice that we pass the Hilbert-space Hamiltonian `H` and the dissipative jumps. -# The Liouville generator is built internally. -# -# On this one-site validation example we use `Trotter{4}()` so the gate -# approximation stays close to the dense `exp(TL)` reference below. - -dt = 0.02 -T = 0.2 - -ρL_tebd = tebd( - ρL0, - H, - dt, - T; - jump_ops=jump_ops, - alg=Trotter{4}(), - maxdim=16, - cutoff=1e-12, -) - -ρ_tebd = dense_mpo_matrix(ρL_tebd, sites) - -println("Density matrix after Liouville TEBD:") -println(roundreal.(ρ_tebd)) - -metrics_tebd = density_matrix_properties(ρ_tebd) - -println("Trace after TEBD: ", real(metrics_tebd.trace)) -println("Hermiticity defect: ", metrics_tebd.hermiticity) -println("Minimum eigenvalue: ", metrics_tebd.min_eig) - -@assert ρL_tebd isa MPS{Liouville} -@assert abs(real(metrics_tebd.trace) - 1) < 1e-6 -@assert metrics_tebd.hermiticity < 1e-6 -@assert metrics_tebd.min_eig > -1e-6 - -# !!! info "Physical sanity checks" -# A density matrix should remain trace-one, Hermitian, and positive -# semidefinite. Small violations usually indicate numerical errors from -# truncation, time stepping, or an inconsistent Liouville-index convention. - -# ### Exact dense check for the tiny system -# -# Since this is only one spin, we can also build the dense Liouvillian matrix -# and compare against -# -# ```math -# |\rho(T)\rangle\rangle_{\mathrm{exact}} -# = -# e^{T\mathcal{L}} -# |\rho(0)\rangle\rangle. -# ``` -# -# This is a validation check, not the scalable algorithm. - -L_dense = Matrix(Array(L_mpo[1], prime(sL), sL)) - -ρvec_exact = LinearAlgebra.exp(T * L_dense) * ρvec0 -ρ_exact = reshape(ρvec_exact, 2, 2) - -err_tebd = LinearAlgebra.norm(ρ_tebd - ρ_exact) / - max(LinearAlgebra.norm(ρ_exact), eps(Float64)) - -println("Relative TEBD error against dense exp(TL): ", err_tebd) - -@assert err_tebd < 1e-8 - -# ## Scaling the dissipative dynamics -# -# The single-spin example was chosen because it has a clean analytical check. -# The package interface, however, is already many-body. -# -# `ITensorMPS.tdvp` currently requires at least two sites, so we demonstrate -# both TEBD and TDVP on a two-spin model: -# -# ```math -# H = -# J S^z_1S^z_2 -# + -# h(S^x_1+S^x_2), -# ``` -# -# with local amplitude damping on both sites: -# -# ```math -# L_1 = S^-_1, -# \qquad -# L_2 = S^-_2. -# ``` - -chain_sites = siteinds("S=1/2", 2) -chain_sites_L = liouv_sites(chain_sites) - -ψ_chain0 = MPS(chain_sites, ["Up", "Dn"]) -ρ_chain0 = to_dm(ψ_chain0) -ρL_chain0 = to_liouville(ρ_chain0; sites=chain_sites_L) - -H_chain = OpSum() -H_chain += 1.0, "Sz", 1, "Sz", 2 -H_chain += 0.4, "Sx", 1 -H_chain += 0.4, "Sx", 2 - -chain_jumps = [(0.15, "S-", 1), (0.15, "S-", 2)] - -L_chain = liouvillian_mpo(H_chain, chain_sites_L; jump_ops=chain_jumps) - -chain_dt = 0.025 -chain_T = 0.1 - -ρL_chain_t = tebd( - ρL_chain0, - H_chain, - chain_dt, - chain_T; - jump_ops=chain_jumps, - alg=Trotter{2}(), - maxdim=32, - cutoff=1e-12, -) - -ρ_chain_t = dense_mpo_matrix(ρL_chain_t, chain_sites) -chain_metrics = density_matrix_properties(ρ_chain_t) - -println("Two-spin dissipative TEBD:") -println(" trace = ", real(chain_metrics.trace)) -println(" hermiticity defect = ", chain_metrics.hermiticity) -println(" min eigenvalue = ", chain_metrics.min_eig) -println(" max bond dimension = ", maxlinkdim(ρL_chain_t)) - -@assert abs(real(chain_metrics.trace) - 1) < 1e-6 -@assert chain_metrics.hermiticity < 1e-6 -@assert chain_metrics.min_eig > -1e-6 -@assert maxlinkdim(ρL_chain_t) ≤ 32 - -# ### TDVP uses the same Liouville MPO -# -# TEBD is not the only option. We can also evolve the same vectorised density -# matrix with TDVP on the two-site chain: -# -# ```julia -# ρL_tdvp = tdvp(L_chain, chain_T, ρL_chain0; time_step=chain_dt) -# ``` - -ρL_chain_tdvp = tdvp( - L_chain, - chain_T, - ρL_chain0; - time_step=chain_dt, - nsite=1, - maxdim=32, - cutoff=1e-12, - outputlevel=0, -) - -ρ_chain_tdvp = dense_mpo_matrix(ρL_chain_tdvp, chain_sites) -chain_tdvp_metrics = density_matrix_properties(ρ_chain_tdvp) - -println("Two-spin dissipative TDVP:") -println(" trace = ", real(chain_tdvp_metrics.trace)) -println(" hermiticity defect = ", chain_tdvp_metrics.hermiticity) -println(" min eigenvalue = ", chain_tdvp_metrics.min_eig) - -@assert ρL_chain_tdvp isa MPS{Liouville} -@assert abs(real(chain_tdvp_metrics.trace) - 1) < 1e-6 -@assert chain_tdvp_metrics.hermiticity < 1e-6 -@assert chain_tdvp_metrics.min_eig > -1e-6 - -# !!! note "TEBD and TDVP in this tutorial" -# TEBD exposes the gate-based picture of `exp(𝓛Δt)`. -# TDVP exposes the MPO-based picture of evolving inside an MPS manifold. -# Both are useful once the density matrix has been written as -# `MPS{Liouville}`. - -# !!! tip "The important scaling pattern" -# The single-spin and two-spin examples use the same command pattern: -# -# ```julia -# sites_L = liouv_sites(sites) -# ρL0 = to_liouville(to_dm(ψ0); sites=sites_L) -# ρL_t = tebd(ρL0, H, dt, T; jump_ops=jump_ops) -# ``` -# -# The local Liouville dimension is larger, but the workflow remains -# MPS/MPO-like. - -# ### Summary -# -# In this tutorial, we learned that: -# -# - dissipative dynamics is naturally formulated for density matrices, -# - density matrices become `MPS{Liouville}` after vectorisation, -# - Liouvillian generators become `MPO{Liouville}`, -# - local jumps are passed as tuples such as `(γ, "S-", site)`, -# - `liouvillian_mpo(H, sites_L; jump_ops=jump_ops)` builds the generator, -# - `tebd(ρL0, H, dt, T; jump_ops=jump_ops)` evolves the density matrix by -# Liouville-space TEBD, -# - `tdvp(L_mpo, T, ρL0; time_step=dt)` evolves the same object using the -# Liouville MPO, -# - trace, Hermiticity, and positivity are the basic sanity checks for -# dissipative density-matrix dynamics. -# -# !!! related "Related material" -# - Theory: [Quantum States and Liouville Space](../theory/liouville_space.md) -# - [Dissipative spin chain](../examples/dissipative_spin.md) — bulk amplitude -# damping with Liouville TEBD -# - [Boundary-driven spin chain](../examples/boundary_driven_spin_chain.md) — -# opposing edge reservoirs and spin current with Liouville TDVP -# - [Driven-dissipative Bose–Hubbard](../examples/driven_dissipative_bose_hubbard.md) -# — midpoint Liouville TDVP with a time-dependent pump and local loss -# -# The next tutorial, [Single-Mode Process Tensor](@ref), moves beyond fixed -# Markovian Liouvillian generators. Process tensors describe reduced dynamics -# with memory, where the environment cannot be compressed into a time-local list -# of jump operators. diff --git a/docs/literate/tutorials/04_process_tensor_instruments.jl b/docs/literate/tutorials/04_process_tensor_instruments.jl new file mode 100644 index 0000000..eab2eb0 --- /dev/null +++ b/docs/literate/tutorials/04_process_tensor_instruments.jl @@ -0,0 +1,366 @@ +# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors #src +# SPDX-License-Identifier: MIT #src +# #src +# File: docs/literate/tutorials/04_process_tensor_instruments.jl #src +# Contributor: Gauthameshwar S. #src +# #src +# Probes one fixed spin–boson process tensor with preparations, controls, #src +# measurements, and a causal break. #src + +# # Process tensor instruments +# +# In this tutorial, we fix a process tensor corresponding to the truncated spin–boson model, +# as constructed in [Construct a process tensor](@ref). The system consists of a spin-1/2 coupled to a single bosonic mode +# (initially in the vacuum state) via the displacement-type interaction ``g S^z(a + a^\dagger)``. +# +# We showcase how different quantum operations—such as state preparations, unitary controls, +# measurements, and causal breaks—can be interleaved at the discrete time steps to probe non-Markovian dynamics. +# The tutorial demonstrates the use of the ProcessTensors.jl interface for: +# - Preparing the system in various quantum states at arbitrary steps, +# - Applying arbitrary local unitaries and measurements between evolution steps, +# - Implementing an explicit "causal break" to reset the system state, +# - Inspecting the resulting quantum trajectories and process outputs. +# +# This page serves as a hands-on guide for exploring operations and instruments available in the process-tensor approach +# and for understanding how interventions at different points affect the system's evolution. + +# ## Setup +# +# This page rebuilds that process tensor so it can be run in a fresh session. +# The parameters match the construction tutorial. + +using ITensors +using ProcessTensors +using ITensors.Ops: Exact, Trotter + +h = 0.6 +ω = 1.1 +g = 2.0 +n_max = 2 +dt = 0.1 +nsteps = 8 + +system_sites = siteinds("S=1/2", 1) + +H_S = OpSum() +H_S += h, "Sx", 1 +system = spin_system(system_sites, H_S) + +boson_sites = siteinds("Boson", 1; dim=n_max + 1) +boson_sites_L = liouv_sites(boson_sites) + +H_B = OpSum() +H_B += ω, "N", 1 +ρB0_L = to_liouville(to_dm(MPS(boson_sites, ["0"])); sites=boson_sites_L) + +H_SB = OpSum() +H_SB += g, "A", 1, "Sz", 2 +H_SB += g, "Adag", 1, "Sz", 2 + +environment = bosonic_bath([ + bosonic_mode(boson_sites_L, H_B, ρB0_L; coupling=H_SB), +]) + +pt = build_process_tensor( + system; + environment=environment, + dt=dt, + nsteps=nsteps, + method=Dense(), + alg=Exact(), + sys_alg=Trotter{2}(), +) + +ρ_plus = to_dm(MPS(ComplexF64[1, 1] / sqrt(2), system_sites)) +ρ_up = to_dm(MPS(system_sites, ["Up"])) +ρ_dn = to_dm(MPS(system_sites, ["Dn"])) + +Sz = OpSum() +Sz += 1.0, "Sz", 1 +Sz_L = to_liouville(MPO(Sz, system_sites); sites=system.sites) + +println("Rebuilt process tensor:") +println(pt) + +@assert pt.nsteps == nsteps +@assert pt.dt == dt +@assert abs(tr(ρ_plus) - 1) < 1e-12 + +# ## [Reduced trajectory](@id evolving-reduced-states) +# +# The first question is the reduced spin trajectory generated by one preparation. +# +# `evolve` is the convenience form of that experiment: prepare the spin, apply +# the identity at the intermediate slots, and leave each output available to +# read. The explicit schedule for the same idea comes in the next sections. +# The preparation here is ``|\uparrow\rangle``. + +trajectory = evolve(pt, ρ_up) + +println("Sample times:") +println(trajectory.times) + +@assert length(trajectory.times) == nsteps +@assert length(trajectory.states_hilbert) == nsteps +@assert all(ρ -> abs(tr(ρ) - 1) < 1e-8, trajectory.states_hilbert) + +#- + +mz = [ + real(inner(Sz_L, to_liouville(ρ; sites=system.sites))) + for ρ in trajectory.states_hilbert +] + +println("⟨Sz⟩ with the boson:") +println(mz) + +@assert all(isfinite, mz) + +# A trajectory with no boson shows whether the oscillator influences the spin. +# Build the same spin and the same grid with `environment = nothing`, then call +# `evolve` again. + +pt_isolated = build_process_tensor( + system; + dt=dt, + nsteps=nsteps, + method=Dense(), + alg=Exact(), + sys_alg=Trotter{2}(), +) + +trajectory_isolated = evolve(pt_isolated, ρ_up) +mz_isolated = [ + real(inner(Sz_L, to_liouville(ρ; sites=system.sites))) + for ρ in trajectory_isolated.states_hilbert +] + +println("Final ⟨Sz⟩ with the boson = ", last(mz)) +println("Final ⟨Sz⟩ for the isolated spin = ", last(mz_isolated)) + +# The two final values differ because the boson influences the spin. A changed +# trajectory does not, by itself, establish that the influence is non-Markovian. + +@assert abs(last(mz) - last(mz_isolated)) > 1e-4 + +# ## [Closed experiments](@id instrument-schedules) +# +# ```@raw html +#
+# The same process returns an open state, an outcome probability, or an observable contraction. +# The same process returns an open state, an outcome probability, or an observable contraction. +#
+# ``` +# +# Each experiment below uses a fresh schedule. `pt` does not change. +# `seq += instrument, step` inserts that instrument at the slot. + +# ### Prepare and close the process +# +# Prepare ``|\uparrow\rangle``, the same state used for `evolve`, and trace the final +# output. Every leg is then closed, so +# `evaluate_process` returns a scalar. For this trace-preserving experiment the +# scalar is the total probability, and it is one. + +seq_closed = default_schedule(pt) +seq_closed += state_preparation(ρ_up), 0 +seq_closed += trace_out(), pt.nsteps + +probability = evaluate_process(pt, seq_closed) + +println("Closed-process probability:") +println(probability) + +@assert probability isa ComplexF64 +@assert abs(probability - 1) < 1e-8 + +# ### Measure a final observable +# +# Replace the final trace by ``S^z``. The scalar is now the expectation value +# ``\operatorname{Tr}(S^z\rho)``, and it matches the last point of `evolve`. + +seq_sz = default_schedule(pt) +seq_sz += state_preparation(ρ_up), 0 +seq_sz += observable_measurement(Sz), pt.nsteps + +sz_final = evaluate_process(pt, seq_sz) + +println("Final ⟨Sz⟩ from the schedule:") +println(sz_final) + +@assert abs(real(sz_final) - last(mz)) < 1e-8 + +# ### Insert a control pulse +# +# The process tensor already contains the free spin propagation. An extra +# `unitary_propagation` at one slot is an additional control, not a second copy +# of that free map. The Hamiltonian is applied on the system's Liouville sites. +# +# Compare the final ``S^z`` with and without an ``S^y`` pulse at slot 2. At that +# early slot the spin is still close to ``|\uparrow\rangle``, so an ``S^z`` +# pulse would barely move it. + +H_pulse = OpSum() +H_pulse += 8.0, "Sy", 1 + +seq_pulse = default_schedule(pt) +seq_pulse += state_preparation(ρ_up), 0 +seq_pulse += unitary_propagation(H_pulse, system.sites), 2 +seq_pulse += observable_measurement(Sz), pt.nsteps + +sz_pulse = evaluate_process(pt, seq_pulse) + +println("Final ⟨Sz⟩ with the pulse:") +println(sz_pulse) +println("Final ⟨Sz⟩ without the pulse:") +println(sz_final) + +@assert abs(sz_pulse - sz_final) > 1e-4 + +# ## Outcomes and open legs + +# ### Leave the final output open +# +# `open_output()` does not apply a physical map. It leaves the final reduced +# state uncontracted, which is the same kind of object `evolve` reads along the +# trajectory. + +seq_open = default_schedule(pt) +seq_open += state_preparation(ρ_up), 0 +seq_open += open_output(), pt.nsteps + +ρ_open = evaluate_process(pt, seq_open) + +println("Open-output type:") +println(typeof(ρ_open)) + +ρ_open_L = to_liouville(to_hilbert(ρ_open); sites=system.sites) +sz_open = real(inner(Sz_L, ρ_open_L)) + +println("⟨Sz⟩ from the open output:") +println(sz_open) + +@assert ρ_open isa MPS{Liouville} +@assert abs(tr(to_hilbert(ρ_open)) - 1) < 1e-8 +@assert abs(sz_open - last(mz)) < 1e-8 + +# ### Select one measurement outcome +# +# A spin-up effect is the completely positive map +# ``\rho \mapsto P_\uparrow \rho P_\uparrow``, with +# +# ```math +# P_\uparrow = \tfrac{1}{2}I + S^z. +# ``` +# +# The map does not preserve the trace. Closing the process after it returns the +# probability of that outcome, not one. + +P_up = OpSum() +P_up += 0.5, "Id", 1 +P_up += 1.0, "Sz", 1 +P_up_mpo = MPO(P_up, system_sites) +spin_up_effect = left_right_operator(P_up_mpo, P_up_mpo) + +seq_outcome = default_schedule(pt) +seq_outcome += state_preparation(ρ_plus), 0 +seq_outcome += spin_up_effect, 2 +seq_outcome += trace_out(), pt.nsteps + +prob_up = evaluate_process(pt, seq_outcome) + +println("Probability of spin up at slot 2:") +println(prob_up) + +@assert 0 < real(prob_up) < 1 + +# The instrument above is stored lazily and materialised when the process tensor +# is contracted. A materialised ITensor for the same map represents the same +# intervention; the lazy form is the one used here. + +# ### Recover the conditional state +# +# Leave the output open after the same effect. The returned state is +# unnormalised. Its trace is the outcome probability. + +seq_conditional = default_schedule(pt) +seq_conditional += state_preparation(ρ_plus), 0 +seq_conditional += spin_up_effect, 2 +seq_conditional += open_output(), pt.nsteps + +ρ_conditional = evaluate_process(pt, seq_conditional) +conditional_trace = tr(to_hilbert(ρ_conditional)) + +println("Trace of the unnormalised conditional state:") +println(conditional_trace) + +@assert ρ_conditional isa MPS{Liouville} +@assert abs(conditional_trace - prob_up) < 1e-8 + +# ## A causal break +# +# Select spin up and then prepare ``|\uparrow\rangle``. The selection discards +# the spin's own record, and the repreparation puts the same state back. The +# final ``S^z`` contraction is still multiplied by the probability of that +# selection. Divide by the branch probability before comparing the two +# preparations. A zero probability would leave the conditional expectation +# undefined. + +break_instrument = observable_measurement(P_up) * state_preparation(ρ_up) + +seq_prob_plus = default_schedule(pt) +seq_prob_plus += state_preparation(ρ_plus), 0 +seq_prob_plus += break_instrument, 2 +seq_prob_plus += trace_out(), pt.nsteps +p_from_plus = evaluate_process(pt, seq_prob_plus) + +seq_break_plus = default_schedule(pt) +seq_break_plus += state_preparation(ρ_plus), 0 +seq_break_plus += break_instrument, 2 +seq_break_plus += observable_measurement(Sz), pt.nsteps + +seq_prob_down = default_schedule(pt) +seq_prob_down += state_preparation(ρ_dn), 0 +seq_prob_down += break_instrument, 2 +seq_prob_down += trace_out(), pt.nsteps +p_from_down = evaluate_process(pt, seq_prob_down) + +seq_break_down = default_schedule(pt) +seq_break_down += state_preparation(ρ_dn), 0 +seq_break_down += break_instrument, 2 +seq_break_down += observable_measurement(Sz), pt.nsteps + +@assert abs(p_from_plus) > 1e-8 +@assert abs(p_from_down) > 1e-8 +sz_from_plus = evaluate_process(pt, seq_break_plus) / p_from_plus +sz_from_down = evaluate_process(pt, seq_break_down) / p_from_down + +println(( + p_from_plus=p_from_plus, + p_from_down=p_from_down, + conditional_sz_from_plus=sz_from_plus, + conditional_sz_from_down=sz_from_down, +)) + +# The selection probabilities differ by about two orders of magnitude, so the +# unnormalised contractions differ even though both branches reprepare +# ``|\uparrow\rangle``. After dividing, the conditional expectations agree to +# a few parts in ``10^4``. On this early break the two histories do not leave +# a visible difference in the final ``\langle S^z\rangle``. That agreement is +# one observable on this short window. It does not show that the process is +# Markovian. + +@assert abs(p_from_plus - p_from_down) > abs(sz_from_plus - sz_from_down) +@assert all(isfinite, (real(sz_from_plus), real(sz_from_down))) + +# !!! info "Larger experiments to try next" +# The instruments on this page are the same operations used in the longer +# examples. Multi-time correlations insert operators at several slots of one +# process tensor. A tester carries an extra ancilla from one intervention to +# the next. +# - [Multi-time correlations](../examples/multitime_correlations.md) +# - [Noisy quantum circuits with a memory-bearing tester](../examples/noisy_quantum_circuit_tester.md) +# +# !!! related "Related material" +# - [Construct a process tensor](@ref) — the spin–boson model used here +# - Theory: [Process Tensors](../theory/process_tensors.md) diff --git a/docs/literate/tutorials/05_process_tensor_singlemode.jl b/docs/literate/tutorials/05_process_tensor_singlemode.jl deleted file mode 100644 index f058b30..0000000 --- a/docs/literate/tutorials/05_process_tensor_singlemode.jl +++ /dev/null @@ -1,715 +0,0 @@ -# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors #src -# SPDX-License-Identifier: MIT #src -# #src -# File: docs/literate/tutorials/05_process_tensor_singlemode.jl #src -# Contributor: Gauthameshwar S. #src -# #src -# Literate tutorial source: building and using a single-mode process tensor. #src - -# # Single-Mode Process Tensor -# -# The previous tutorials described density matrices, Liouville-space dynamics, -# unitary evolution, and dissipative Lindblad evolution. -# -# Those descriptions are still time-local. At each time step, we apply a fixed -# map to the current density matrix: -# -# ```math -# |\rho(t+\Delta t)\rangle\rangle -# = -# \mathcal{E}_{\Delta t} -# |\rho(t)\rangle\rangle. -# ``` -# -# A process tensor is more general. It stores how a system responds to a whole -# sequence of interventions over time. -# -# Instead of asking only for a state trajectory, we can ask: -# -# - What happens if I prepare this state at the beginning? -# - What if I insert an observable at an intermediate time? -# - What if I leave one output open and inspect the reduced state? -# - What if I postselect on an outcome? -# - What if I use the same environment influence with different instruments? -# -# In `ProcessTensors.jl`, a process tensor is represented as a Liouville-space -# MPO over time legs. -# -# ```text -# time 0 time 1 time 2 -# -# input input input -# | | | -# [ PT ] —— memory —— [ PT ] —— memory —— [ PT ] -# | | | -# output output output -# ``` -# -# The memory links carry the influence of the environment from one time step to -# the next. Instruments are then contracted onto the input/output legs. - -# ## Setup -# -using ITensors -import LinearAlgebra -using ProcessTensors -using ITensors.Ops: Exact, Trotter - -roundreal(x; digits=8) = round(real(x); digits=digits) - -# ## Ingredients to build a process tensor -# -# A process tensor needs four physical ingredients: -# -# - a system, -# - an environment, -# - a system-environment coupling, -# - a time grid. -# -# In this tutorial, the system is one spin: -# -# ```math -# H_S = h S^x_S. -# ``` -# -# The environment is one spin bath mode: -# -# ```math -# H_B = \omega_B S^z_B. -# ``` -# -# The system and bath interact through -# -# ```math -# H_{SB} = g S^z_B S^z_S. -# ``` -# -# This is the smallest nontrivial setting where the process tensor has a memory -# link. The environment is finite, so it can carry information from one time to -# another. - -# ### System -# -# We build the system from ordinary Hilbert-space spin sites. The constructor -# `spin_system` converts them internally to canonical Liouville sites. - -system_sites = siteinds("S=1/2", 1) - -h = 0.7 - -H_S = OpSum() -H_S += h, "Sx", 1 - -system = spin_system(system_sites, H_S) - -println("System:") -println(system) - -@assert system isa SpinSystem -@assert length(system.sites) == 1 -@assert dim(only(system.sites)) == 4 - -# The initial system state is -# -# ```math -# |+\rangle = -# \frac{|\uparrow\rangle + |\downarrow\rangle}{\sqrt{2}}. -# ``` - -ψS0 = MPS(ComplexF64[1 / sqrt(2), 1 / sqrt(2)], system_sites) -ρS0 = to_dm(ψS0) - -@assert ρS0 isa MPO{Hilbert} -@assert abs(tr(ρS0) - 1) < 1e-12 - -# ### Bath mode -# -# The bath mode is also a spin. Bath modes are stored in Liouville space because -# the process tensor is built from density matrices and superoperators. - -bath_sites = siteinds("S=1/2", 1) -bath_sites_L = liouv_sites(bath_sites) - -ωB = 1.1 - -H_B = OpSum() -H_B += (ωB / 2), "Sz", 1 - -ψB0 = MPS(bath_sites, ["Dn"]) -ρB0 = to_dm(ψB0) -ρB0_L = to_liouville(ρB0; sites=bath_sites_L) - -@assert ρB0_L isa MPS{Liouville} - -# The coupling OpSum is written in a local two-site convention: -# -# - site `1` is the bath mode, -# - site `2` is the system coupling site. -# -# This local convention lets the same coupling be inserted into the joint -# bath-system Liouville propagator. - -g = 0.35 - -H_SB = OpSum() -H_SB += g, "Sz", 1, "Sz", 2 - -mode = spin_mode( - bath_sites_L, - H_B, - ρB0_L; - coupling=H_SB, -) - -environment = spin_bath([mode]) - -println("Environment:") -println(environment) - -@assert mode isa SpinMode -@assert environment isa SpinBath - -# !!! tip "Nearby variation: use a bosonic mode" -# The spin bath can be changed into a bosonic bath by changing only the mode -# constructor and the site family. -# -# ```julia -# boson_sites = siteinds("Boson", 1; dim=4) -# boson_sites_L = liouv_sites(boson_sites) -# -# H_boson = OpSum() -# H_boson += Ω, "N", 1 -# -# ψb0 = MPS(boson_sites, ["0"]) -# ρb0_L = to_liouville(to_dm(ψb0); sites=boson_sites_L) -# -# coupling_boson = OpSum() -# coupling_boson += g, "N", 1, "Sz", 2 -# -# mode_b = bosonic_mode( -# boson_sites_L, -# H_boson, -# ρb0_L; -# n_max=3, -# coupling=coupling_boson, -# ) -# -# environment_b = bosonic_bath([mode_b]) -# ``` -# -# The process-tensor construction call is unchanged. Only the bath model has -# changed. - -# ## [Building the process tensor](@id building-the-process-tensor) -# -# The time grid is set by `dt` and `nsteps`. -# -# The call -# -# ```julia -# pt = build_process_tensor(system; environment, dt, nsteps) -# ``` -# -# builds one process-tensor core per time step. -# -# By default, the system's own one-step Liouville propagation is embedded into -# the cores. This means the default instrument between time steps is the identity -# operation, not an extra system propagator. -# -# `sys_alg` chooses the *timestep sandwich order* of those Exact free-system maps -# around the bath(+coupling) core: -# -# - `Trotter{1}()` (default): asymmetric ``Q · M(Δt)`` -# - `Trotter{2}()`: symmetric ``M(Δt/2) · Q · M(Δt/2)`` (usually smaller -# time-discretization error at fixed coarse ``Δt``) -# -# This is not a TEBD gate decomposition of the system Liouvillian; single-site -# system maps are always Exact ED. - -dt = 0.05 -nsteps = 5 - -pt = build_process_tensor( - system; - environment=environment, - dt=dt, - nsteps=nsteps, - alg=Exact(), - sys_alg=Trotter{2}(), -) - -println("Process tensor with one spin bath mode:") -println(pt) - -@assert pt isa ProcessTensor -@assert pt.nsteps == nsteps -@assert pt.dt == dt -@assert pt.environment isa SpinBath - -# It is useful to compare this with the no-environment baseline. -# -# With `environment=nothing`, the object stores the time-local system -# propagation without an explicit bath memory link. - -pt_markov = build_process_tensor( - system; - dt=dt, - nsteps=nsteps, -) - -println("No-environment baseline:") -println(pt_markov) - -println("maxlinkdim(pt_markov) = ", maxlinkdim(pt_markov)) -println("maxlinkdim(pt) = ", maxlinkdim(pt)) - -@assert maxlinkdim(pt) >= maxlinkdim(pt_markov) - -# In the Markovian limit, the process tensor decomposes to a product MPO-like object in time. -# Since there is no memory link between interventions, the process becomes a product of the -# same system unitary at each time step. -# -# ### Time legs -# -# At every process-tensor time label `k`, there is an input leg and an output -# leg. -# -# In this package: -# -# - input legs have prime level `1`, -# - output legs have prime level `0`, -# - both carry a `tstep=k` tag. -# - both have the same `tags`, `ID`, and liouville `dim`. -# -# These helpers let us inspect the legs without manually searching through -# ITensor indices. - -println("Input leg at time 0:") -println(input_sites(pt, 0)) - -println("Output leg at time 0:") -println(output_sites(pt, 0)) - -println("Legs connected by the evolve slot at step 1:") -println(coupling_times(pt, 1)) - -@assert plev(only(input_sites(pt, 0))) == 1 -@assert plev(only(output_sites(pt, 0))) == 0 - -# The `coupling_times` returns an output index of `tstep=0` and an input index of `tstep=1`. -# This is because while propagating the initial state in a process tensor, we contract the -# input leg of `tstep=0` with the output leg of `tstep=1` of the previous time step. If there is -# no intervention at this timestep, and we let the system do its unitary evolution, we embed an -# `IdentityOperation` at this timestep. -# -# !!! warning "Reuse Liouville indices" -# Process-tensor contractions depend on exact ITensor index identity. Use -# the sites stored by the system, bath modes, and process tensor instead of -# recreating visually similar indices. - -# ## [Evolving reduced states](@id evolving-reduced-states) -# -# The simplest way to use a process tensor is to ask for the reduced system -# states generated from an initial density matrix. -# -# The high-level call is: -# -# ```julia -# trajectory = evolve(pt, ρS0) -# ``` -# -# It returns: -# -# - `trajectory.times`, -# - `trajectory.states_liouville`, -# - `trajectory.states_hilbert`. -# -# The Hilbert states are density-matrix MPOs reconstructed from the -# Liouville-space outputs. - -trajectory = evolve(pt, ρS0) - -println("Sample times:") -println(trajectory.times) - -println("Number of returned states:") -println(length(trajectory.states_hilbert)) - -@assert length(trajectory.times) == nsteps -@assert length(trajectory.states_hilbert) == nsteps -@assert all(ρ -> abs(tr(ρ) - 1) < 1e-8, trajectory.states_hilbert) - -# Let us measure one simple observable along the trajectory: -# -# ```math -# \langle S^z(t)\rangle = -# \operatorname{Tr}[S^z\rho(t)] -# = \langle\langle S^z | \rho(t) \rangle\rangle. -# ``` -# -# We vectorize both $\rho(t)$ and $S^z$ on the **same** Liouville sites, then use -# the two-argument overlap `inner(Sz_L, ρL)` from [Liouville-Space Basics](@ref). - -Sz = OpSum() -Sz += 1.0, "Sz", 1 - -Sz_mpo = MPO(Sz, system_sites) -system_sites_L = liouv_sites(system_sites) -Sz_L = to_liouville(Sz_mpo; sites=system_sites_L) - -mz = [ - begin - ρL = to_liouville(ρ; sites=system_sites_L) - real(inner(Sz_L, ρL)) - end - for ρ in trajectory.states_hilbert -] - -println("⟨Sz⟩ along the process-tensor trajectory:") -println(roundreal.(mz)) - -@assert all(isfinite, mz) - -# Now compare with the no-environment baseline. The code is identical; only the -# process tensor changes. - -trajectory_markov = evolve(pt_markov, ρS0) - -mz_markov = [ - begin - ρL = to_liouville(ρ; sites=system_sites_L) - real(inner(Sz_L, ρL)) - end - for ρ in trajectory_markov.states_hilbert -] - -println("Final ⟨Sz⟩ without explicit bath = ", roundreal(last(mz_markov))) -println("Final ⟨Sz⟩ with spin bath = ", roundreal(last(mz))) - -# The two results need not agree. The whole point of the process tensor is that -# the bath can carry memory between time steps. - -@assert isfinite(last(mz_markov)) -@assert isfinite(last(mz)) - -# ## [Instrument schedules](@id instrument-schedules) -# -# A process tensor becomes useful when we contract it with instruments. -# -# An instrument says what we do at a given time leg: -# -# - prepare a state, -# - insert an observable, -# - connect one time to the next, -# - trace out a leg, -# - measure an outcome, or -# - leave an output open. -# -# The default schedule uses `identity_operation()` between time steps: no extra -# intervention is inserted beyond the propagation already stored in the process -# tensor. - -seq = default_schedule(pt) - -println("Default schedule:") -println(seq) - -@assert seq isa InstrumentSeq - -# ### Normalization as a fully closed process -# -# If every leg is closed, `evaluate_process` returns a scalar. -# -# The schedule below prepares `ρS0` at the beginning and traces the final output. -# Physically, this asks for the total probability of the process. - -seq_norm = default_schedule(pt) -add!(seq_norm, state_preparation(ρS0), 0) -add!(seq_norm, trace_out(), pt.nsteps) - -norm_val = evaluate_process(pt, seq_norm) - -println("Closed process value:") -println(norm_val) - -@assert norm_val isa ComplexF64 -@assert abs(norm_val - 1) < 1e-8 - -# ### Leaving an output open -# -# Leaving the final output leg open returns the final reduced system state. -# `open_output()` is bookkeeping only: it materializes as `ITensor(1.0)` and does -# not insert a physical map, so the declared output index stays uncontracted. - -seq_open = default_schedule(pt) -add!(seq_open, state_preparation(ρS0), 0) -add!(seq_open, open_output(), pt.nsteps) - -open_result = evaluate_process(pt, seq_open) - -println("Open-output result type:") -println(typeof(open_result)) - -@assert open_result isa MPS{Liouville} - -# ### Final expectation value -# -# To compute a final observable, replace the final trace with an observable -# insertion. -# -# This gives -# -# ```math -# \operatorname{Tr}[S^z\rho(t_{\mathrm{final}})] -# = \langle\langle \rho(t_{\mathrm{final}}) | S^z \rangle\rangle. -# ``` - -seq_final_sz = default_schedule(pt) -add!(seq_final_sz, state_preparation(ρS0), 0) -add!(seq_final_sz, observable_measurement(Sz), pt.nsteps) - -final_sz_from_schedule = evaluate_process(pt, seq_final_sz) - -println("Final ⟨Sz⟩ from schedule:") -println(final_sz_from_schedule) - -println("Final ⟨Sz⟩ from evolve:") -println(last(mz)) - -@assert abs(real(final_sz_from_schedule) - last(mz)) < 1e-8 - -# For ordinary state trajectories, prefer `evolve(pt, ρ0)`. `OpenOutput` is the -# lower-level schedule ingredient that makes such state extraction possible. - -# ### Lazy instruments and dense instruments -# -# Every schedule above used instruments **lazily**: `add!` stores what we want -# to do, and the package materializes the corresponding ITensor only when the -# process tensor is contracted — much like an `OpSum` before `MPO(...)`. -# -# Sometimes we want to inspect or define the dense map ourselves. The package -# supports both paths: -# -# - **lazy path:** high-level instruments such as `ObservableMeasurement`, -# `IdentityOperation`, `OpenOutput`, and `left_right_operator`, -# - **dense path:** materialize with `instrument_itensor`, or wrap a custom map -# in `CustomTwoLegInstrument`. -# -# The same trace-reducing postselection illustrates both paths. -# -# A spin-up projector is -# -# ```math -# P_\uparrow = -# |\uparrow\rangle\langle\uparrow| -# = -# \frac{1}{2}I + S^z, -# ``` -# -# and the corresponding map is $\rho \mapsto P_\uparrow \rho P_\uparrow$. This -# map is not trace preserving; the closed scalar from `evaluate_process` is the -# probability that postselection succeeds. - -Pup = OpSum() -Pup += 0.5, "Id", 1 -Pup += 1.0, "Sz", 1 - -Pup_mpo = MPO(Pup, system_sites) - -lazy_filter = left_right_operator(Pup_mpo, Pup_mpo) - -seq_filter_lazy = default_schedule(pt) -add!(seq_filter_lazy, state_preparation(ρS0), 0) -add!(seq_filter_lazy, lazy_filter, 2) -add!(seq_filter_lazy, trace_out(), pt.nsteps) - -prob_filter_lazy = evaluate_process(pt, seq_filter_lazy) - -println("Postselected trace from lazy instrument:") -println(prob_filter_lazy) - -@assert 0 <= real(prob_filter_lazy) <= 1 + 1e-10 - -# Materialize the same instrument on the concrete process-tensor legs. -# `coupling_times(pt, step)` returns `(out_prev, in_curr)`: the output leg at -# `step - 1` and the input leg at `step`. - -out_prev, in_curr = coupling_times(pt, 2) - -dense_filter_tensor = ProcessTensors.Instruments.instrument_itensor( - lazy_filter, - in_curr, - out_prev, - 2, -) - -println("Dense filter instrument:") -println(dense_filter_tensor) - -dense_filter = custom_twoleg_instrument( - dense_filter_tensor, - in_curr, - out_prev, -) - -seq_filter_dense = default_schedule(pt) -add!(seq_filter_dense, state_preparation(ρS0), 0) -add!(seq_filter_dense, dense_filter, 2) -add!(seq_filter_dense, trace_out(), pt.nsteps) - -prob_filter_dense = evaluate_process(pt, seq_filter_dense) - -println("Postselected trace from dense custom instrument:") -println(prob_filter_dense) - -@assert abs(prob_filter_dense - prob_filter_lazy) < 1e-10 - -# We can also contract the materialized tensors by hand. Most users should prefer -# `evaluate_process`; the loop below shows what the high-level call is doing. - -prob_filter_manual = let - dense_instruments = ProcessTensors.Instruments.create_instruments(pt, seq_filter_dense) - manual_result = pt.core[1] * dense_instruments[1] - for step in 1:(pt.nsteps - 1) - manual_result *= dense_instruments[step + 1] - manual_result *= pt.core[step + 1] - end - final_instr = ProcessTensors.Instruments.resolve_instrument( - seq_filter_dense, - pt.nsteps, - seq_filter_dense.default, - ) - final_out, _ = coupling_times(pt, pt.nsteps) - manual_result *= ProcessTensors.Instruments.instrument_itensor( - final_instr, - final_out, - pt.nsteps - 1, - ) - ComplexF64(scalar(manual_result)) -end - -println("Postselected trace from manual dense contraction:") -println(prob_filter_manual) - -@assert abs(prob_filter_manual - prob_filter_lazy) < 1e-10 - -# !!! note "Lazy versus dense instruments" -# The lazy path is the recommended interface: -# -# ```julia -# add!(seq, left_right_operator(Pup_mpo, Pup_mpo), 2) -# ``` -# -# The dense path is useful when you want to inspect the ITensor, define a -# custom operation, or debug a contraction: -# -# ```julia -# dense = ProcessTensors.Instruments.instrument_itensor(instr, in_curr, out_prev, step) -# custom = custom_twoleg_instrument(dense, in_curr, out_prev) -# ``` -# -# Both paths describe the same physical intervention when the dense tensor is -# materialized from the same lazy instrument. - -# ### Collapse and reprepare at one time step -# -# A causal intervention can filter the state leaving time step `k-1` and set the -# state entering time step `k`. For a spin-up measurement followed by repreparing -# $|\!\uparrow\rangle$, use a [`ProductInstrument`](@ref): -# -# - [`observable_measurement`](@ref) on the **output** leg (`plev=0`), -# - [`state_preparation`](@ref) on the **input** leg (`plev=1`, the default). -# -# The product order does not matter as long as the two factors sit on opposite -# legs. -# -# This is a completely positive map. It is **not** trace preserving when the -# measurement is probabilistic: the closed scalar from `evaluate_process` is the -# probability of the outcome, not `1`. -# -# This post-selection step says we measure the system in the spin-up state, -# and then prepare the system in the spin-up state after realising that outcome. - -Pup = OpSum() -Pup += 0.5, "Id", 1 -Pup += 1.0, "Sz", 1 - -ρ_up = to_dm(MPS(system_sites, ["Up"])) - -seq_filter = default_schedule(pt) -add!(seq_filter, state_preparation(ρS0), 0) -add!(seq_filter, observable_measurement(Pup) * state_preparation(ρ_up), 2) -add!(seq_filter, trace_out(), pt.nsteps) - -prob_filter = evaluate_process(pt, seq_filter) - -println("Probability of spin-up and reprepare at t=2:") -println(prob_filter) - -@assert 0 <= real(prob_filter) <= 1 + 1e-10 - -# !!! note "ProductInstrument rules" -# Multiply any two single-leg instruments with `*` when one targets the -# output leg (`plev=0`) and one the input leg (`plev=1`). Indices can be -# bound lazily at `add!` or supplied explicitly on each factor. Prepare the -# initial state at time zero separately with `StatePreparation`, as above. - -# ### [Two-time correlation preview](@id two-time-correlation-preview) -# -# Two-time correlations are built from instrument schedules too. -# The function `two_time_correlation_seq` constructs the instrument schedule for -# -# ```math -# \langle A(t_A)B(t_B)\rangle. -# ``` -# -# Here we only show the usage pattern. A full discussion of multi-time -# correlations belongs in the examples section. - -seq_corr = two_time_correlation_seq( - pt, - (Sz, 1), - (Sz, 3); - rho0=ρS0, -) - -corr = evaluate_process(pt, seq_corr) - -println("Two-time correlation preview:") -println(corr) - -@assert corr isa ComplexF64 -@assert isfinite(real(corr)) - -# !!! info "Monitoring long runs" -# Larger baths and longer schedules benefit from live progress feedback. -# For a dynamic spinner animation and progress bars during CPU-heavy steps, use -# at least two threads in your julia command: `julia --project=. -t 2` when -# launching Julia. See [Advanced Usage](@ref) for execution modes, verbose logging, -# threading, and recommended setups. - -# ### Summary -# -# In this tutorial, we learned that: -# -# - a process tensor stores a multi-time open-system process, -# - `build_process_tensor` needs a system, an optional environment, `dt`, and -# `nsteps`, -# - a bath mode stores its initial state, Hamiltonian, Liouville sites, and -# system-mode coupling, -# - input/output time legs are exposed by `input_sites` and `output_sites`, -# - `evolve(pt, ρ0)` returns reduced density states over time, -# - `evaluate_process(pt, seq)` contracts a process tensor with an instrument -# schedule, -# - fully closed schedules return scalars, -# - open-output schedules return state-like Liouville objects, -# - observables, collapse maps, and two-time correlations are all expressed -# as instrument schedules, -# - lazy instruments defer materialization until contraction; use -# `instrument_itensor` and `CustomTwoLegInstrument` for dense control, -# -# !!! related "Related examples" -# - [Spin-bath process tensor](../examples/spin_bath_process_tensor.md) — -# single- and multimode process-tensor construction -# - [Multi-time correlations](../examples/multitime_correlations.md) — -# sequential two-time correlators on a reusable process tensor diff --git a/docs/literate/tutorials/05_unitary_dynamics.jl b/docs/literate/tutorials/05_unitary_dynamics.jl new file mode 100644 index 0000000..aa3d484 --- /dev/null +++ b/docs/literate/tutorials/05_unitary_dynamics.jl @@ -0,0 +1,241 @@ +# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors #src +# SPDX-License-Identifier: MIT #src +# #src +# File: docs/literate/tutorials/05_unitary_dynamics.jl #src +# Contributor: Gauthameshwar S. #src +# #src +# Literate tutorial source: a short introduction to unitary TEBD and TDVP. #src + +# # Unitary Dynamics +# +# This tutorial introduces two ways to evolve a closed spin chain: time-evolving +# block decimation (TEBD) and the time-dependent variational principle (TDVP). +# We build one small model, evolve it with each method, and interpret a compact +# set of diagnostics. A final comparison shows how the same physics appears in +# Liouville space. +# +# These are additional time-evolution tools provided by `ProcessTensors.jl`. +# For construction and reuse of a process tensor, start with +# [Construct a process tensor](@ref). + +# ## Setup +# +# `ProcessTensors.jl` supplies the typed Hilbert/Liouville interface and the +# `tebd` driver. Its `tdvp` methods forward to `ITensorMPS.jl`. + +using ITensors +import ITensorMPS +import LinearAlgebra as LA +using ProcessTensors +using ITensors.Ops: Trotter + +# Two short helpers keep repeated diagnostics readable. Dense conversion is +# used only for this four-spin check; its cost grows exponentially with size. +# `unitary_checks` returns labelled values using Julia's built-in named tuples. + +function dense_matrix(W, sites) + D = prod(dim.(sites)) + return reshape(ComplexF64.(Array(foldl(*, W), prime.(sites)..., sites...)), D, D) +end + +function unitary_checks(ψ, H_mpo, initial_energy) + norm2 = real(inner(ψ, ψ)) + energy = real(inner(ψ', H_mpo, ψ)) / norm2 + return (; norm_error=abs(norm2 - 1), energy_drift=abs(energy - initial_energy), + mean_sz=sum(expect(ψ, "Sz")) / length(ψ), bond=maxlinkdim(ψ)) +end; + +# ## A small Ising chain +# +# We use open boundaries and spin operators $S^\alpha=\sigma^\alpha/2$: +# +# ```math +# H=-J\sum_{j=1}^{N-1}S^z_jS^z_{j+1}-h\sum_{j=1}^{N}S^x_j, +# \qquad |\psi_0\rangle=|\uparrow\rangle^{\otimes N}. +# ``` +# +# The transverse field rotates the initially polarised spins; the interaction +# can generate entanglement. The mean longitudinal magnetisation changes while +# the energy of this time-independent Hamiltonian remains constant in exact +# evolution. We use $\hbar=1$. + +function ising_hamiltonian(N, J, h) + os = OpSum() + for j in 1:(N - 1) + os += -J, "Sz", j, "Sz", j + 1 + end + for j in 1:N + os += -h, "Sx", j + end + return os +end + +N = 4 +J, h = 1.0, 1.2 +dt, T = 0.05, 1.0 +maxdim, cutoff = 32, 1e-10 +sites = siteinds("S=1/2", N) +ψ0 = MPS(sites, fill("Up", N)) +H = ising_hamiltonian(N, J, h) +H_mpo = MPO(H, sites) +E0 = real(inner(ψ0', H_mpo, ψ0)); + +#- +println(unitary_checks(ψ0, H_mpo, E0)) +@assert isapprox(real(inner(ψ0, ψ0)), 1; atol=1e-12) + +# ## TEBD: apply local gates +# +# TEBD splits a short-time propagator into local gates, applies them to the +# MPS, and truncates the resulting bonds. For two Hamiltonian pieces, a +# second-order splitting has the schematic form +# +# ```math +# e^{-i(H_A+H_B)\Delta t} +# =e^{-iH_A\Delta t/2}e^{-iH_B\Delta t}e^{-iH_A\Delta t/2} +# +\mathcal O(\Delta t^3). +# ``` +# +# The package constructs gates from the terms of the Hamiltonian `OpSum`. +# Pass real durations `dt` and `T`; for a Hilbert MPS, `tebd` supplies the +# factor $-i$. Choose `T/dt` to be an integer. + +ψ_tebd = tebd( + ψ0, H, dt, T; + alg=Trotter{2}(), maxdim=maxdim, cutoff=cutoff, progress=false, +); + +#- +println(unitary_checks(ψ_tebd, H_mpo, E0)) + +# !!! note "Two separate accuracy controls" +# Smaller `dt` reduces Trotter splitting error. Larger `maxdim` and a tighter +# `cutoff` reduce bond truncation. Second-order splitting has a global error +# of order $\Delta t^2$ at fixed duration before truncation dominates. +# Higher orders require more gates and do not remove truncation error. +# Check convergence in both the time step and the retained bond space. + +# ## TDVP: evolve within an MPS manifold +# +# TDVP projects $\partial_t|\psi\rangle=-iH|\psi\rangle$ onto the tangent +# space of an MPS manifold and integrates the resulting equations. It works +# with the Hamiltonian MPO, so it is also useful when the Hamiltonian is less +# convenient to express as a short sequence of local gates. +# +# Here `nsite=2` updates two neighbouring tensors at a time, allowing bond +# growth followed by truncation. The MPO is $H$, so both the total evolution +# parameter and the step include $-i$. + +ψ_tdvp = tdvp( + H_mpo, -im * T, ψ0; + time_step=-im * dt, nsite=2, maxdim=maxdim, cutoff=cutoff, + normalize=false, outputlevel=0, +); + +#- +println(unitary_checks(ψ_tdvp, H_mpo, E0)) + +# !!! info "One-site TDVP and global subspace expansion" +# One-site TDVP (`nsite=1`) keeps the existing bond dimensions. Starting from +# a product state, increasing `maxdim` alone does not supply the missing +# entangled directions. Global subspace expansion (GSE) enriches the bond +# basis with Krylov directions before one-site evolution. For this short +# tutorial, two-site TDVP provides bond growth directly. +# +# !!! note "Conservation is useful, but not an accuracy certificate" +# Ideal fixed-manifold Hilbert-space TDVP preserves norm and energy for a +# time-independent Hermitian Hamiltonian. Finite solver tolerances and +# two-site truncation can introduce drift. A restricted one-site trajectory +# can conserve energy while giving inaccurate observables. TDVP also has +# projection and integration errors; it is not automatically more accurate +# than TEBD. + +# ## A compact check against exact evolution +# +# Four spins give a dense Hamiltonian of size $16\times16$. We use its matrix +# exponential once to check the final density operator. This comparison is +# insensitive to an overall phase of the wavefunction and tests more than one +# observable. It is a small-system reference, not a scalable evolution method. + +H_dense = dense_matrix(H_mpo, sites) +ψ0_dense = vec(ComplexF64.(Array(foldl(*, ψ0), sites...))) +ψ_exact = LA.exp(-im * T * H_dense) * ψ0_dense +ρ_exact = ψ_exact * ψ_exact' +ρ_tebd = dense_matrix(to_dm(ψ_tebd), sites) +ρ_tdvp = dense_matrix(to_dm(ψ_tdvp), sites) + +errors = ( + tebd=LA.norm(ρ_tebd - ρ_exact) / LA.norm(ρ_exact), + tdvp=LA.norm(ρ_tdvp - ρ_exact) / LA.norm(ρ_exact), +) +println(errors) +@assert all(isfinite, values(errors)) +@assert maximum(values(errors)) < 1e-2 + +# The displayed values are relative Frobenius errors. The assertions are loose +# regression checks for this small demonstration, not general accuracy targets. +# The norm and energy diagnostics above provide complementary information. +# For a convergence study, repeat with `dt/2` and then tighter bond controls; +# agreeing with another approximate method alone is not a reference solution. + +# ## [Hilbert versus Liouville evolution](@id hilbert-liouville-tdvp) +# +# The same closed-system density operator obeys +# +# ```math +# \partial_t|\rho\rangle\rangle=\mathcal L_H|\rho\rangle\rangle, +# \qquad \mathcal L_H\rho=-i[H,\rho]. +# ``` +# +# Reuse the same Liouville indices for the initial density and generator. +# Both TEBD and TDVP can evolve this representation. + +sites_L = liouv_sites(sites) +ρL0 = to_liouville(to_dm(ψ0); sites=sites_L) +L_mpo = liouvillian_mpo(H, sites_L); + +#- +ρL_tebd = tebd( + ρL0, H, dt, T; + alg=Trotter{2}(), maxdim=maxdim, cutoff=cutoff, progress=false, +); +ρL_tdvp = tdvp( + L_mpo, T, ρL0; + time_step=dt, nsite=2, maxdim=maxdim, cutoff=cutoff, + normalize=false, updater_kwargs=(; ishermitian=false), outputlevel=0, +); + +# !!! note "The generator determines the TDVP time argument" +# With `H_mpo`, pass `-im * T` and `time_step=-im * dt`. +# With `L_mpo`, pass `T` and `time_step=dt`: the generator already contains +# $-i$. A Liouvillian is generally non-Hermitian, so the local solver is +# told this explicitly. `normalize=false` avoids normalising the Liouville +# vector's Euclidean norm, which represents purity rather than trace. + +ρ_from_L_tebd = dense_matrix(to_hilbert(ρL_tebd), sites) +ρ_from_L_tdvp = dense_matrix(to_hilbert(ρL_tdvp), sites) +liouville_errors = ( + tebd=LA.norm(ρ_from_L_tebd - ρ_exact) / LA.norm(ρ_exact), + tdvp=LA.norm(ρ_from_L_tdvp - ρ_exact) / LA.norm(ρ_exact), +) +println(liouville_errors) +@assert all(isfinite, values(liouville_errors)) +@assert maximum(values(liouville_errors)) < 1e-2 + +# !!! info "Different representations, different numerical constraints" +# An approximate pure-state MPS still defines a positive operator +# $|\psi\rangle\langle\psi|$, although its norm and observables can have +# errors. A general Liouville MPS does not enforce trace, Hermiticity, or +# positivity. Hilbert-space TDVP conservation arguments do not automatically +# protect the physical energy $\operatorname{Tr}(H\rho)$ in Liouville space. +# See [Checking physicality](@ref dynamics-physicality) for practical checks. +# +# Liouville evolution also has a larger local dimension. For a pure state with +# Schmidt rank $\chi$, its density operator has operator-Schmidt rank $\chi^2$ +# across the same cut. The exact physics agrees, but the two numerical +# representations can require different bond dimensions and show different errors. +# +# !!! related "Continue learning" +# - [Dissipative Dynamics](@ref): add jump operators and check density-matrix physicality. +# - [Laser-driven TDVP dynamics](../examples/laser_driven_tdvp.md): time-dependent spin driving. +# - [Construct a process tensor](@ref): build a reusable multi-time process. diff --git a/docs/literate/tutorials/06_dissipative_dynamics.jl b/docs/literate/tutorials/06_dissipative_dynamics.jl new file mode 100644 index 0000000..2b74bad --- /dev/null +++ b/docs/literate/tutorials/06_dissipative_dynamics.jl @@ -0,0 +1,219 @@ +# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors #src +# SPDX-License-Identifier: MIT #src +# #src +# File: docs/literate/tutorials/06_dissipative_dynamics.jl #src +# Contributor: Gauthameshwar S. #src +# #src +# Literate tutorial source: dissipative TEBD/TDVP and concise physicality checks. #src + +# # Dissipative Dynamics +# +# This tutorial evolves a small spin system with coherent interactions and local +# amplitude damping. We construct its Liouville-space state, evolve it with TEBD +# and TDVP, and check both the physical output and numerical accuracy. +# +# The model is a Markovian master equation with specified jump operators. +# These time-evolution tools complement the process-tensor workflows introduced +# in [Construct a process tensor](@ref). For the algorithm introductions, +# see [Unitary Dynamics](@ref); for vectorisation, see [Liouville-Space Basics](@ref). + +# ## Setup + +using ITensors +import ITensorMPS +import LinearAlgebra as LA +using ProcessTensors +using ITensors.Ops: Trotter + +# Two helpers are reused for both algorithms. Dense reconstruction is suitable +# only for this two-spin demonstration. Diagnostics are returned as named tuples, +# so Julia displays their labels without a custom printing routine. + +function dense_density(ρL, sites) + W = to_hilbert(ρL) + D = prod(dim.(sites)) + return reshape(ComplexF64.(Array(foldl(*, W), prime.(sites)..., sites...)), D, D) +end + +function density_checks(ρ) + scale = max(LA.norm(ρ), eps(Float64)) + ρh = LA.Hermitian((ρ + ρ') / 2) + return (; trace_error=abs(LA.tr(ρ) - 1), + hermiticity_error=LA.norm(ρ - ρ') / scale, + min_eigenvalue=minimum(LA.eigvals(ρh))) +end; + +# ## [A spin pair with amplitude damping](@id dissipative-lindblad-mpo) +# +# We use spin operators $S^\alpha=\sigma^\alpha/2$ and set $\hbar=1$: +# +# ```math +# H=J S^z_1S^z_2+h(S^x_1+S^x_2), +# \qquad +# \dot\rho=-i[H,\rho]+\gamma\sum_{j=1}^2\mathcal D[S^-_j](\rho), +# ``` +# +# where $\mathcal D[L](\rho)=L\rho L^\dagger-\{L^\dagger L,\rho\}/2$. +# Damping transfers population from `Up` to `Dn`; the transverse field mixes +# those states, and the interaction can generate correlations. We start with +# both spins up, so the initial mean magnetisation is $1/2$. +# +# A single two-spin model is enough to demonstrate both algorithms and retains +# a small dense reference. All Liouville objects share the same `sites_L`. + +J, h, γ = 1.0, 0.4, 0.15 +dt, T = 0.025, 0.5 +maxdim, cutoff = 16, 1e-12 +sites = siteinds("S=1/2", 2) +sites_L = liouv_sites(sites) +ψ0 = MPS(sites, ["Up", "Up"]) +ρL0 = to_liouville(to_dm(ψ0); sites=sites_L) + +H = OpSum() +H += J, "Sz", 1, "Sz", 2 +H += h, "Sx", 1 +H += h, "Sx", 2 +jump_ops = [(γ, "S-", 1), (γ, "S-", 2)] +L_mpo = liouvillian_mpo(H, sites_L; jump_ops=jump_ops); + +# !!! note "Jump rates and generator conventions" +# `(γ, "S-", j)` contributes `γ * D[S-]` at site `j`; the first entry is +# the rate, not its square root. `L_mpo` already includes the commutator's +# factor $-i$ and acts as $\partial_t|\rho\rangle\rangle=\mathcal L|\rho\rangle\rangle$. +# The propagator is $e^{T\mathcal L}$. + +# ## [Liouville-space TEBD](@id dissipative-evolving-density-matrix) +# +# TEBD approximates $e^{\mathcal L\Delta t}$ with local gates and truncates +# the resulting MPS bonds. Supply the Hamiltonian `OpSum` and `jump_ops`; +# the Liouville generator is assembled internally from the state's indices. + +ρL_tebd = tebd( + ρL0, H, dt, T; + jump_ops=jump_ops, alg=Trotter{2}(), + maxdim=maxdim, cutoff=cutoff, progress=false, +); + +# To read out the magnetisation, vectorise the observable on the same indices. +# The overlap gives $\operatorname{Tr}(\bar S^z\rho)$, with +# $\bar S^z=(S^z_1+S^z_2)/2$. We retain the complex result so any spurious +# imaginary part remains visible. + +Sz_mean = OpSum() +Sz_mean += 0.5, "Sz", 1 +Sz_mean += 0.5, "Sz", 2 +Sz_L = to_liouville(MPO(Sz_mean, sites); sites=sites_L); +println((initial=inner(Sz_L, ρL0), tebd=inner(Sz_L, ρL_tebd))) + +# !!! note "Dissipative splitting and physicality" +# A Trotter approximation is CPTP if each factor is itself a forward-time +# CPTP map. Splitting a dissipator into individual algebraic terms does not +# automatically meet that condition. Higher-order compositions can also +# contain negative substeps. Use convergence and physicality checks rather +# than assuming that a higher Trotter order guarantees a physical result. +# Bond truncation adds a separate approximation. + +# ## Liouville-space TDVP +# +# TDVP evolves the vectorised density within an MPS manifold, using `L_mpo` +# directly. We use two-site updates so the initial product density can develop +# operator-space correlations. Its total time and step are real because the +# generator already contains the Hamiltonian factor $-i$. + +ρL_tdvp = tdvp( + L_mpo, T, ρL0; + time_step=dt, nsite=2, maxdim=maxdim, cutoff=cutoff, + normalize=false, updater_kwargs=(; ishermitian=false), outputlevel=0, +); + +#- +println((tebd=inner(Sz_L, ρL_tebd), tdvp=inner(Sz_L, ρL_tdvp))) + +# !!! info "What changes from pure-state TDVP?" +# The Liouvillian is generally non-Hermitian, and the Euclidean norm of a +# Liouville vector measures purity, not trace. We therefore use a +# non-Hermitian local solver and keep `normalize=false`. Physical energy +# need not be conserved in this dissipative model. One-site TDVP still has +# fixed bond dimensions; GSE can enrich its basis but does not enforce +# trace or positivity. See [Unitary Dynamics](@ref) for that distinction. + +# ## [Checking physicality](@id dynamics-physicality) +# +# A physical deterministic output has unit trace, is Hermitian, and is positive +# semidefinite. TEBD truncation and splitting, or TDVP projection and numerical +# integration, do not generally impose all of these constraints on a Liouville +# MPS. This applies even when the underlying evolution is unitary. +# +# Reconstruct the small density matrices and inspect three labelled numbers: + +ρ_tebd = dense_density(ρL_tebd, sites) +ρ_tdvp = dense_density(ρL_tdvp, sites) +tebd_checks = density_checks(ρ_tebd) +tdvp_checks = density_checks(ρ_tdvp) +println(tebd_checks) +println(tdvp_checks) + +# | Diagnostic | Interpretation | +# |:--|:--| +# | `trace_error` | $\vert\operatorname{Tr}\rho-1\vert$, including any imaginary trace error | +# | `hermiticity_error` | Relative Frobenius norm of $\rho-\rho^\dagger$ | +# | `min_eigenvalue` | Smallest eigenvalue of $(\rho+\rho^\dagger)/2$ | +# +# Interpret the eigenvalue together with the Hermiticity error: a non-Hermitian +# matrix is already unphysical. The Hermitian part is formed only to diagnose +# the result; the evolved state is not replaced by it. Tiny negative values can +# reflect numerical error and should shrink under suitable convergence checks. + +@assert tebd_checks.trace_error < 1e-3 +@assert tdvp_checks.trace_error < 1e-3 +@assert tebd_checks.hermiticity_error < 1e-3 +@assert tdvp_checks.hermiticity_error < 1e-3 +@assert tebd_checks.min_eigenvalue > -1e-3 +@assert tdvp_checks.min_eigenvalue > -1e-3 + +# !!! warning "Normalisation does not repair positivity" +# Rescaling by the trace can restore unit trace, but it does not remove +# negative eigenvalues or projection errors. Euclidean normalisation is +# different again: it fixes $\operatorname{Tr}(\rho^\dagger\rho)$. +# For a large network, trace and observable checks remain accessible; +# positivity checks on small reduced states are useful but do not certify +# positivity of the complete many-body density operator. + +# ### Accuracy against a small dense reference +# +# Passing physicality checks does not establish accuracy. Here the Liouville +# dimension is only $4^2=16$, so we can compare both final states with the dense +# matrix exponential. Extract the generator and state using the same local +# Liouville ordering; no global density-matrix reshuffling is needed. + +D_L = prod(dim.(sites_L)) +L_dense = reshape( + ComplexF64.(Array(foldl(*, L_mpo), prime.(sites_L)..., sites_L...)), D_L, D_L, +) +v0 = vec(ComplexF64.(Array(foldl(*, ρL0), sites_L...))) +v_exact = LA.exp(T * L_dense) * v0 +v_tebd = vec(ComplexF64.(Array(foldl(*, ρL_tebd), sites_L...))) +v_tdvp = vec(ComplexF64.(Array(foldl(*, ρL_tdvp), sites_L...))) + +errors = ( + tebd=LA.norm(v_tebd - v_exact) / LA.norm(v_exact), + tdvp=LA.norm(v_tdvp - v_exact) / LA.norm(v_exact), +) +println(errors) +@assert all(isfinite, values(errors)) +@assert maximum(values(errors)) < 1e-3 + +# These assertions are regression checks for this tiny model, not general +# physicality or accuracy thresholds. Tighten `dt` and the bond controls to +# establish convergence for the observables in a larger calculation. Monitor +# intermediate times as well as the endpoint when studying a full trajectory. +# +# The workflow is now complete: specify `H` and the jumps, construct the +# Liouville state, evolve, then inspect observables and diagnostics. The examples +# below apply this pattern to larger physical models. +# +# !!! related "Continue learning" +# - [Dissipative spin chain](../examples/dissipative_spin.md): interacting spins with local damping. +# - [Driven-dissipative Bose–Hubbard](../examples/driven_dissipative_bose_hubbard.md): driven bosons with loss. +# - [Quantum States and Liouville Space](../theory/liouville_space.md): vectorisation and map conventions. +# - [Construct a process tensor](@ref): retain environmental influence across multiple intervention times. diff --git a/docs/make.jl b/docs/make.jl index 92b29da..877b243 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -32,17 +32,18 @@ const EXAMPLE_ASSETS = joinpath(DOCS_ROOT, "src", "assets", "examples") const SCRIPT_FIGURES = normpath(joinpath(DOCS_ROOT, "..", "scripts", "figures")) const TUTORIAL_GROUPS = [ - ("Foundations", [ + ("Tensor-network foundations", [ ("00_itensor_basics.jl", "itensor_basics", "ITensor Basics"), ("01_mps_mpo_basics.jl", "mps_mpo_basics", "MPS and MPO Basics"), ("02_liouville_basics.jl", "liouville_basics", "Liouville-Space Basics"), ]), - ("Dynamics", [ - ("03_unitary_dynamics.jl", "unitary_dynamics", "Unitary Dynamics"), - ("04_dissipative_dynamics.jl", "dissipative_dynamics", "Dissipative Dynamics"), - ]), ("Process tensors", [ - ("05_process_tensor_singlemode.jl", "process_tensor_singlemode", "Single-Mode Process Tensor"), + ("03_process_tensor_singlemode.jl", "process_tensor_singlemode", "Construct a process tensor"), + ("04_process_tensor_instruments.jl", "process_tensor_instruments", "Process tensor instruments"), + ]), + ("Additional dynamics tools", [ + ("05_unitary_dynamics.jl", "unitary_dynamics", "Unitary Dynamics"), + ("06_dissipative_dynamics.jl", "dissipative_dynamics", "Dissipative Dynamics"), ]), ] @@ -75,42 +76,33 @@ tutorial_sidebar = [ ] const LITERATE_EXAMPLES = [ - ("tebd_time_evolution.jl", "tebd_time_evolution", "TEBD time evolution"), - ("tdvp_time_evolution.jl", "tdvp_time_evolution", "TDVP time evolution"), - ("laser_driven_tdvp.jl", "laser_driven_tdvp", "Laser-driven TDVP dynamics"), - ("dissipative_spin.jl", "dissipative_spin", "Dissipative spin chain"), - ("boundary_driven_spin_chain.jl", "boundary_driven_spin_chain", "Boundary-driven spin chain"), - ("driven_dissipative_bose_hubbard.jl", "driven_dissipative_bose_hubbard", "Driven-dissipative Bose–Hubbard"), ("spin_bath_process_tensor.jl", "spin_bath_process_tensor", "Spin-bath process tensor"), ("central_spin_ace.jl", "central_spin_ace", "Central-spin dynamics using ACE"), ("thermal_spinboson_ace.jl", "thermal_spinboson_ace", "Thermal spin-boson dynamics using ACE"), ("noisy_quantum_circuit_tester.jl", "noisy_quantum_circuit_tester", "Noisy quantum circuit and testers"), + ("ramsey_povm.jl", "ramsey_povm", "Ramsey readouts as a probe of bath memory"), ("multitime_correlations.jl", "multitime_correlations", "Multi-time correlations"), + ("dissipative_spin.jl", "dissipative_spin", "Dissipative spin chain"), + ("driven_dissipative_bose_hubbard.jl", "driven_dissipative_bose_hubbard", "Driven-dissipative Bose–Hubbard"), + ("laser_driven_tdvp.jl", "laser_driven_tdvp", "Laser-driven TDVP dynamics"), ] const EXAMPLE_GROUPS = [ - ("Time evolution algorithms", [ - ("TEBD time evolution", "tebd_time_evolution"), - ("TDVP time evolution", "tdvp_time_evolution"), - ]), - ("Dissipative dynamics", [ - ("Dissipative spin chain", "dissipative_spin"), - ("Boundary-driven spin chain", "boundary_driven_spin_chain"), - ("Driven-dissipative Bose–Hubbard", "driven_dissipative_bose_hubbard"), - ]), - ("Driven systems", [ - ("Laser-driven TDVP dynamics", "laser_driven_tdvp"), - ]), ("Process tensors", [ ("Spin-bath process tensor", "spin_bath_process_tensor"), ("Central-spin dynamics using ACE", "central_spin_ace"), ("Thermal spin-boson dynamics using ACE", "thermal_spinboson_ace"), - ("Testers and noisy quantum qubits", "noisy_quantum_circuit_tester"), ]), ("Instruments and correlations", [ - ("Instrument sequences", "instrument_sequences"), + ("Testers and noisy quantum qubits", "noisy_quantum_circuit_tester"), + ("Ramsey POVM measurements", "ramsey_povm"), ("Multi-time correlations", "multitime_correlations"), ]), + ("Additional time evolution", [ + ("Dissipative spin chain", "dissipative_spin"), + ("Driven-dissipative Bose–Hubbard", "driven_dissipative_bose_hubbard"), + ("Laser-driven TDVP dynamics", "laser_driven_tdvp"), + ]), ] example_stems = Set(stem for (_, pages) in EXAMPLE_GROUPS for (_, stem) in pages) @@ -150,26 +142,18 @@ for (src, stem, _) in LITERATE_EXAMPLES end stage_example_figures([ - "tebd_tfim_unitary_hilbert_dynamics_mx.png", - "tebd_tfim_unitary_hilbert_rho_error.png", - "tebd_tfim_unitary_liouville_dynamics_mx.png", - "tebd_tfim_unitary_liouville_rho_error.png", - "tdvp_tfim_unitary_hilbert_dynamics_mx.png", - "tdvp_tfim_unitary_hilbert_energy_drift.png", - "tdvp_tfim_unitary_hilbert_rho_error.png", - "tdvp_tfim_unitary_liouville_dynamics_mx.png", - "tdvp_tfim_unitary_liouville_energy_drift.png", - "tdvp_tfim_unitary_liouville_rho_error.png", "laser_driven_tdvp.png", "tebd_tfim_dissipative_dynamics_nup.png", "tebd_tfim_dissipative_dynamics_mx.png", - "boundary_driven_xxz_transport.png", "driven_dissipative_bose_hubbard.png", "pt_tfim_singlemode.png", "pt_tfim_multimode.png", "central_spin_ace.png", "thermal_spinboson_ace.png", "noisy_quantum_circuit_tester.png", + "noisy_quantum_circuit_tester_protocol.png", + "ramsey_povm_protocol.png", + "ramsey_povm_records.png", "pt_multitime_correlations.png", ]) @@ -185,10 +169,11 @@ makedocs(; authors="Gauthameshwar and contributors", sitename="ProcessTensors.jl", format=Documenter.HTML(; + prettyurls=true, canonical="https://Gauthameshwar.github.io/ProcessTensors.jl", edit_link="main", collapselevel=1, - assets=String["assets/admonitions.css"], + assets=String["assets/admonitions.css", "assets/themed-figures.css", "assets/feature-clips.css"], ), pages=[ "Home" => "index.md", @@ -208,4 +193,5 @@ makedocs(; deploydocs(; repo="github.com/Gauthameshwar/ProcessTensors.jl", devbranch="main", + push_preview=true, ) diff --git a/docs/src/api.md b/docs/src/api.md index d1d6dea..046c431 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -213,6 +213,7 @@ spin_mode bosonic_bath spin_bath mode_initial_states +thermal_mode ``` ```@docs diff --git a/docs/src/assets/admonitions.css b/docs/src/assets/admonitions.css index 2155053..7fcb188 100644 --- a/docs/src/assets/admonitions.css +++ b/docs/src/assets/admonitions.css @@ -26,3 +26,4 @@ html.theme--documenter-dark .admonition.is-category-related > .admonition-header background-color: transparent; color: #9b6bdb; } + diff --git a/docs/src/assets/animations/ace-dark-poster.png b/docs/src/assets/animations/ace-dark-poster.png new file mode 100644 index 0000000..ed9bbc3 Binary files /dev/null and b/docs/src/assets/animations/ace-dark-poster.png differ diff --git a/docs/src/assets/animations/ace-dark.gif b/docs/src/assets/animations/ace-dark.gif new file mode 100644 index 0000000..4fd110c Binary files /dev/null and b/docs/src/assets/animations/ace-dark.gif differ diff --git a/docs/src/assets/animations/ace-light-poster.png b/docs/src/assets/animations/ace-light-poster.png new file mode 100644 index 0000000..0245adf Binary files /dev/null and b/docs/src/assets/animations/ace-light-poster.png differ diff --git a/docs/src/assets/animations/ace-light.gif b/docs/src/assets/animations/ace-light.gif new file mode 100644 index 0000000..70eb47f Binary files /dev/null and b/docs/src/assets/animations/ace-light.gif differ diff --git a/docs/src/assets/animations/contraction-dark-poster.png b/docs/src/assets/animations/contraction-dark-poster.png new file mode 100644 index 0000000..4d00779 Binary files /dev/null and b/docs/src/assets/animations/contraction-dark-poster.png differ diff --git a/docs/src/assets/animations/contraction-dark.gif b/docs/src/assets/animations/contraction-dark.gif new file mode 100644 index 0000000..d04ab7f Binary files /dev/null and b/docs/src/assets/animations/contraction-dark.gif differ diff --git a/docs/src/assets/animations/contraction-light-poster.png b/docs/src/assets/animations/contraction-light-poster.png new file mode 100644 index 0000000..37e15f2 Binary files /dev/null and b/docs/src/assets/animations/contraction-light-poster.png differ diff --git a/docs/src/assets/animations/contraction-light.gif b/docs/src/assets/animations/contraction-light.gif new file mode 100644 index 0000000..615f15c Binary files /dev/null and b/docs/src/assets/animations/contraction-light.gif differ diff --git a/docs/src/assets/animations/instruments-dark-poster.png b/docs/src/assets/animations/instruments-dark-poster.png new file mode 100644 index 0000000..de028af Binary files /dev/null and b/docs/src/assets/animations/instruments-dark-poster.png differ diff --git a/docs/src/assets/animations/instruments-dark.gif b/docs/src/assets/animations/instruments-dark.gif new file mode 100644 index 0000000..186296c Binary files /dev/null and b/docs/src/assets/animations/instruments-dark.gif differ diff --git a/docs/src/assets/animations/instruments-light-poster.png b/docs/src/assets/animations/instruments-light-poster.png new file mode 100644 index 0000000..6662ada Binary files /dev/null and b/docs/src/assets/animations/instruments-light-poster.png differ diff --git a/docs/src/assets/animations/instruments-light.gif b/docs/src/assets/animations/instruments-light.gif new file mode 100644 index 0000000..2dad3e7 Binary files /dev/null and b/docs/src/assets/animations/instruments-light.gif differ diff --git a/docs/src/assets/feature-clips.css b/docs/src/assets/feature-clips.css new file mode 100644 index 0000000..1801ac3 --- /dev/null +++ b/docs/src/assets/feature-clips.css @@ -0,0 +1,78 @@ +/* Homepage feature clips (docs/animations/*.py). + * Every clip image starts hidden so Documenter's `article img { display: block }` + * cannot stack the light and dark assets. Animated GIFs are shown only when the + * reader has not requested reduced motion; posters are shown only when they have. + */ + +article .feature-clip { + margin: 0.85rem auto 1.15rem; + text-align: center; +} + +article .feature-clip img { + width: min(100%, 34em); + height: auto; + margin-left: auto; + margin-right: auto; +} + +/* Beat themed-figures.css, whose dark-theme rule is + `html.theme--… .theme-figure-dark { display: block !important }`. */ +article .feature-clip img.clip-motion, +article .feature-clip img.clip-poster { + display: none !important; +} + +article .feature-clip img[src*="contraction-"] { + width: min(100%, 58em); +} + +article .feature-clip figcaption { + margin-top: 0.35rem; + font-size: 0.92em; + opacity: 0.85; +} + +@media (prefers-reduced-motion: no-preference) { + article .feature-clip img.theme-figure-light.clip-motion { + display: block !important; + } + + html.theme--documenter-dark article .feature-clip img.theme-figure-light.clip-motion, + html.theme--catppuccin-mocha article .feature-clip img.theme-figure-light.clip-motion, + html.theme--catppuccin-macchiato article .feature-clip img.theme-figure-light.clip-motion, + html.theme--catppuccin-frappe article .feature-clip img.theme-figure-light.clip-motion { + display: none !important; + } + + html.theme--documenter-dark article .feature-clip img.theme-figure-dark.clip-motion, + html.theme--catppuccin-mocha article .feature-clip img.theme-figure-dark.clip-motion, + html.theme--catppuccin-macchiato article .feature-clip img.theme-figure-dark.clip-motion, + html.theme--catppuccin-frappe article .feature-clip img.theme-figure-dark.clip-motion { + display: block !important; + } +} + +@media (prefers-reduced-motion: reduce) { + article .feature-clip img.clip-motion { + display: none !important; + } + + article .feature-clip img.clip-poster.theme-figure-light { + display: block !important; + } + + html.theme--documenter-dark article .feature-clip img.clip-poster.theme-figure-light, + html.theme--catppuccin-mocha article .feature-clip img.clip-poster.theme-figure-light, + html.theme--catppuccin-macchiato article .feature-clip img.clip-poster.theme-figure-light, + html.theme--catppuccin-frappe article .feature-clip img.clip-poster.theme-figure-light { + display: none !important; + } + + html.theme--documenter-dark article .feature-clip img.clip-poster.theme-figure-dark, + html.theme--catppuccin-mocha article .feature-clip img.clip-poster.theme-figure-dark, + html.theme--catppuccin-macchiato article .feature-clip img.clip-poster.theme-figure-dark, + html.theme--catppuccin-frappe article .feature-clip img.clip-poster.theme-figure-dark { + display: block !important; + } +} diff --git a/docs/src/assets/themed-figures.css b/docs/src/assets/themed-figures.css new file mode 100644 index 0000000..13c9057 --- /dev/null +++ b/docs/src/assets/themed-figures.css @@ -0,0 +1,81 @@ +/* Automatic theme adaptation for theory SVGs. + * Toggles between light and dark SVG assets according to Documenter's active theme. + */ + +article .theme-figure, +.admonition .theme-figure { + margin: 0.85rem auto 1.15rem; + text-align: center; +} + +article .theme-figure img, +.admonition .theme-figure img { + width: 100%; + height: auto; + margin-left: auto; + margin-right: auto; +} + +/* Specific width caps matching TikZ font scaling */ +article .theme-figure img[src*="column_major_vectorisation"], +.admonition .theme-figure img[src*="column_major_vectorisation"] { + width: min(100%, 26.3em); +} + +article .theme-figure img[src*="tensor_contractions"], +.admonition .theme-figure img[src*="tensor_contractions"] { + width: min(100%, 36.5em); +} + +article .theme-figure img[src*="mps_vocabulary"], +.admonition .theme-figure img[src*="mps_vocabulary"] { + width: min(100%, 44.0em); +} + +article .theme-figure img[src*="quantum_channel"], +.admonition .theme-figure img[src*="quantum_channel"] { + width: min(100%, 18.1em); +} + +article .theme-figure img[src*="channel_to_multitime_process"], +.admonition .theme-figure img[src*="channel_to_multitime_process"] { + width: min(100%, 32.0em); +} + +article .theme-figure img[src*="pt_anatomy"], +.admonition .theme-figure img[src*="pt_anatomy"] { + width: min(100%, 38.3em); +} + +article .theme-figure img[src*="process_contractions"], +.admonition .theme-figure img[src*="process_contractions"] { + width: min(100%, 34.7em); +} + +article .theme-figure img[src*="closing_a_leg"], +.admonition .theme-figure img[src*="closing_a_leg"] { + width: min(100%, 32.0em); +} + +/* Visibility toggle */ +.theme-figure-light { + display: block; +} + +.theme-figure-dark { + display: none; +} + +html.theme--documenter-dark .theme-figure-light, +html.theme--catppuccin-mocha .theme-figure-light, +html.theme--catppuccin-macchiato .theme-figure-light, +html.theme--catppuccin-frappe .theme-figure-light { + display: none !important; +} + +html.theme--documenter-dark .theme-figure-dark, +html.theme--catppuccin-mocha .theme-figure-dark, +html.theme--catppuccin-macchiato .theme-figure-dark, +html.theme--catppuccin-frappe .theme-figure-dark { + display: block !important; +} diff --git a/docs/src/assets/theory/channel_to_multitime_process-dark.svg b/docs/src/assets/theory/channel_to_multitime_process-dark.svg new file mode 100644 index 0000000..6d6ef59 --- /dev/null +++ b/docs/src/assets/theory/channel_to_multitime_process-dark.svg @@ -0,0 +1,794 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/channel_to_multitime_process-light.svg b/docs/src/assets/theory/channel_to_multitime_process-light.svg new file mode 100644 index 0000000..664cf90 --- /dev/null +++ b/docs/src/assets/theory/channel_to_multitime_process-light.svg @@ -0,0 +1,794 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/closing_a_leg-dark.svg b/docs/src/assets/theory/closing_a_leg-dark.svg new file mode 100644 index 0000000..3ee2971 --- /dev/null +++ b/docs/src/assets/theory/closing_a_leg-dark.svg @@ -0,0 +1,322 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/closing_a_leg-light.svg b/docs/src/assets/theory/closing_a_leg-light.svg new file mode 100644 index 0000000..b9184d7 --- /dev/null +++ b/docs/src/assets/theory/closing_a_leg-light.svg @@ -0,0 +1,322 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/column_major_vectorisation-dark.svg b/docs/src/assets/theory/column_major_vectorisation-dark.svg new file mode 100644 index 0000000..fbf780f --- /dev/null +++ b/docs/src/assets/theory/column_major_vectorisation-dark.svg @@ -0,0 +1,255 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/column_major_vectorisation-light.svg b/docs/src/assets/theory/column_major_vectorisation-light.svg new file mode 100644 index 0000000..8527a29 --- /dev/null +++ b/docs/src/assets/theory/column_major_vectorisation-light.svg @@ -0,0 +1,255 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/mpo_to_liouville_mps-dark.svg b/docs/src/assets/theory/mpo_to_liouville_mps-dark.svg new file mode 100644 index 0000000..c5e8c90 --- /dev/null +++ b/docs/src/assets/theory/mpo_to_liouville_mps-dark.svg @@ -0,0 +1,814 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/mpo_to_liouville_mps-light.svg b/docs/src/assets/theory/mpo_to_liouville_mps-light.svg new file mode 100644 index 0000000..b4ef982 --- /dev/null +++ b/docs/src/assets/theory/mpo_to_liouville_mps-light.svg @@ -0,0 +1,814 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/mps_vocabulary-dark.svg b/docs/src/assets/theory/mps_vocabulary-dark.svg new file mode 100644 index 0000000..2802fff --- /dev/null +++ b/docs/src/assets/theory/mps_vocabulary-dark.svg @@ -0,0 +1,574 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/mps_vocabulary-light.svg b/docs/src/assets/theory/mps_vocabulary-light.svg new file mode 100644 index 0000000..d4b6618 --- /dev/null +++ b/docs/src/assets/theory/mps_vocabulary-light.svg @@ -0,0 +1,574 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/process_contractions-dark.svg b/docs/src/assets/theory/process_contractions-dark.svg new file mode 100644 index 0000000..b983ff0 --- /dev/null +++ b/docs/src/assets/theory/process_contractions-dark.svg @@ -0,0 +1,782 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/process_contractions-light.svg b/docs/src/assets/theory/process_contractions-light.svg new file mode 100644 index 0000000..20464d2 --- /dev/null +++ b/docs/src/assets/theory/process_contractions-light.svg @@ -0,0 +1,782 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/pt_anatomy-dark.svg b/docs/src/assets/theory/pt_anatomy-dark.svg new file mode 100644 index 0000000..e0b6132 --- /dev/null +++ b/docs/src/assets/theory/pt_anatomy-dark.svg @@ -0,0 +1,615 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/pt_anatomy-light.svg b/docs/src/assets/theory/pt_anatomy-light.svg new file mode 100644 index 0000000..9ea51ce --- /dev/null +++ b/docs/src/assets/theory/pt_anatomy-light.svg @@ -0,0 +1,615 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/quantum_channel-dark.svg b/docs/src/assets/theory/quantum_channel-dark.svg new file mode 100644 index 0000000..206214f --- /dev/null +++ b/docs/src/assets/theory/quantum_channel-dark.svg @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/quantum_channel-light.svg b/docs/src/assets/theory/quantum_channel-light.svg new file mode 100644 index 0000000..498f908 --- /dev/null +++ b/docs/src/assets/theory/quantum_channel-light.svg @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/tensor_contractions-dark.svg b/docs/src/assets/theory/tensor_contractions-dark.svg new file mode 100644 index 0000000..7bc6a68 --- /dev/null +++ b/docs/src/assets/theory/tensor_contractions-dark.svg @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/assets/theory/tensor_contractions-light.svg b/docs/src/assets/theory/tensor_contractions-light.svg new file mode 100644 index 0000000..11eddb3 --- /dev/null +++ b/docs/src/assets/theory/tensor_contractions-light.svg @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/examples/instrument_sequences.md b/docs/src/examples/instrument_sequences.md deleted file mode 100644 index 1ea666a..0000000 --- a/docs/src/examples/instrument_sequences.md +++ /dev/null @@ -1,16 +0,0 @@ -```@meta -CurrentModule = ProcessTensors -``` - -# Instrument sequences - -Building and modifying `InstrumentSeq` schedules: state preparation, measurements, -trace-outs, left/right operator actions, and open outputs on process tensors. - -!!! related "Related material" - - Tutorial: [Single-Mode Process Tensor](../tutorials/process_tensor_singlemode.md) - - Theory: [Process Tensors](../theory/process_tensors.md) - - API: `InstrumentSeq`, `default_schedule`, `evaluate_process` (see [API Reference](../api.md)) - -!!! note "Documentation in progress" - A full Literate example for this page is planned. diff --git a/docs/src/index.md b/docs/src/index.md index 4858c95..f04732d 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1,175 +1,105 @@ # ProcessTensors.jl -`ProcessTensors.jl` is a Julia package for MPS-based open quantum dynamics, Liouville-space simulation, and process tensors. - ---- - -## Why this project exists - -Most open-quantum-softwares that exist today handles Markovian -dynamics well, but say little about what happens when the -environment remembers. As quantum devices push -into regimes of strong coupling and structured environments, memory effects -become the rule rather than the exception, and **process tensors** have -emerged as the natural language for describing them. - -The tools for working with process tensors, however, remain -sparse for a field this active. `ProcessTensors.jl` exists to -close that gap. This package primarily uses MPS/MPO infrastructure to -define and manipulate process tensors in a memory-efficient way. -It's built natively on `ITensorMPS.jl`, deliberately -separating low-level tensor-network machinery from high-level physics workflows, -so that the codes appear as natural and close to the theory. -Alongside the code, the documentation is written to teach the basics -of Liouville-space and process-tensor formalism, paired with runnable -examples that mirror the underlying equations closely. - -Today, `ProcessTensors.jl` covers the essentials: single-mode and small -multimode process tensors, spin and bosonic baths, reduced dynamics, and -multi-time correlations. The next concrete milestone is extending this to -large, realistic non-Markovian environments via the ACE algorithm — a -near-term, committed goal rather than a distant aspiration. - -Beyond that, the longer-term vision is for this package to grow from a -clean, accessible implementation into a genuine research platform: a place -where process-tensor and open-system tensor-network algorithms — -TEMPO, PT-TEMPO, TEDOPA, and other influence-functional-based methods — can -be implemented side by side, benchmarked against each other, taught to -newcomers, and reused by researchers who'd rather build on solid -infrastructure than rebuild it from scratch. - ---- - -## Where to start - -Most of what makes this package distinctive appears in two tutorials: - -* **[Dissipative Dynamics](tutorials/dissipative_dynamics.md)** — open-system evolution in Liouville space: Lindblad generators, `liouvillian_mpo`, TEBD/TDVP on density matrices. -* **[Single-Mode Process Tensor](tutorials/process_tensor_singlemode.md)** — process-tensor construction, bath memory, instruments, `evolve`, and `evaluate_process`. - -Everything else in the documentation supports those two pages: ITensor syntax, MPS/MPO objects, Hilbert-versus-Liouville conventions, and closed-system dynamics as stepping stones. - -Choose a path that suits you best: - -| Background | Suggested route | -| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **You code and know the theory.** | Start with [Dissipative Dynamics](tutorials/dissipative_dynamics.md), then [Single-Mode Process Tensor](tutorials/process_tensor_singlemode.md). Skim [Hilbert and Liouville Space](theory/liouville_space.md) and [Process Tensors](theory/process_tensors.md) for conventions only. Use [Examples](examples/tebd_time_evolution.md) for scripts and [API Reference](api.md) for the function list. | -| **You know the theory but are new to ITensors.** | [ITensor Basics](tutorials/itensor_basics.md) → [MPS and MPO Basics](tutorials/mps_mpo_basics.md) → [Liouville-Space Basics](tutorials/liouville_basics.md) → the two core tutorials above. Use theory pages as a convention dictionary, not the main route. | -| **You are new to open quantum systems or tensor networks in Julia.** | [Installation](installation.md) → [ITensor Basics](tutorials/itensor_basics.md) → [MPS and MPO Basics](tutorials/mps_mpo_basics.md) → [Liouville-Space Basics](tutorials/liouville_basics.md) → [Unitary Dynamics](tutorials/unitary_dynamics.md) → [Dissipative Dynamics](tutorials/dissipative_dynamics.md) → [Single-Mode Process Tensor](tutorials/process_tensor_singlemode.md). Read theory when a concept or notation is unclear. | - -!!! note "Tutorials are cumulative" - Later tutorials assume syntax and conventions from earlier ones, especially ITensor index identity, shared `sites_L`, and density-matrix vectorisation. - -!!! tip "Theory pages" - Use the theory section as a convention dictionary, not as a prerequisite course for every tutorial. - -!!! tip "Performance and feedback (Advanced Usage)" - Long runs such as `build_process_tensor`, `evolve`, `evaluate_process`, and `tebd` support - `progress` and `verbose` keywords. To maximise performance in benchmarks, parameter sweeps, - or HPC jobs, disable transient progress bars (`progress=false`) and choose whether you want - durable milestone logs (`verbose=true`) or a silent run (`verbose=false`). See - [Advanced Usage](advanced_usage.md) for the recommended combinations, threading notes, and - demo scripts under `scripts/terminal/`. - ---- - -## Quick start - -If you already know where you are headed, the snippet below builds a one-spin system coupled to one spin bath mode and constructs a process tensor. For context and checks along the way, use [Single-Mode Process Tensor](tutorials/process_tensor_singlemode.md). - -```julia -using ITensors -using ProcessTensors - -dt = 0.1 -nsteps = 24 - -# Physical Hilbert-space sites -sys = siteinds("S=1/2", 1) -bath = siteinds("S=1/2", 1) - -# Liouville-space sites -sysL = liouv_sites(sys) -bathL = liouv_sites(bath) - -# System Hamiltonian -Hsys = OpSum() -Hsys += 1.0, "Sx", 1 -system = spin_system(sys, Hsys) +`ProcessTensors.jl` is an ITensor-native framework for studying quantum systems +with environmental memory. Construct a process tensor from a microscopic +environment, then reuse it with different preparations, controls, measurements, +and multi-time probes. + +These tutorials connect the physical picture, tensor-network representation, +and runnable Julia code, helping you learn the process-tensor framework while +using it to design numerical experiments. + +## Build once, explore many experiments + +When a system interacts with an environment, its present reduced state may not +contain all the information needed to predict its future. The environment can +retain information about earlier interactions and interventions. A process tensor +describes how the system responds to interventions at different times, including +the influence of environmental memory. ProcessTensors.jl represents this +multi-time response using matrix product operators, allowing the same process +tensor to be reused across different experiments. + +```@raw html +
+ Time runs right to left. Two environment modes over three time steps are absorbed column by column and compressed into a three-core process tensor with downward system legs. + Time runs right to left. Two environment modes over three time steps are absorbed column by column and compressed into a three-core process tensor with downward system legs. + A three-core process tensor with downward system legs, produced by ACE compression of two environment modes. + A three-core process tensor with downward system legs, produced by ACE compression of two environment modes. +
Construct a process tensor: Combine the influence of independent environmental modes and compress the resulting temporal bonds using ACE.
+
+ +
+ Time runs right to left. A preparation, a custom map, and an open output snap into the slots of a three-step instrument tape; the unspecified slot is filled by an identity. + Time runs right to left. A preparation, a custom map, and an open output snap into the slots of a three-step instrument tape; the unspecified slot is filled by an identity. + A filled three-step instrument tape: preparation, identity, custom map, and open output. + A filled three-step instrument tape: preparation, identity, custom map, and open output. +
Customize your instruments: Define the interventions you wish to perform on the system at chosen times; unspecified slots default to identity.
+
+ +
+ Time runs right to left. Two identical process tensors are contracted with instruments; the left ends as an open reduced state (triangle) and the right as a closed scalar (circle). + Time runs right to left. Two identical process tensors are contracted with instruments; the left ends as an open reduced state (triangle) and the right as a closed scalar (circle). + Contraction results: an open reduced state (triangle) on the left and a closed scalar (circle) on the right. + Contraction results: an open reduced state (triangle) on the left and a closed scalar (circle) on the right. +
Evaluate and reuse: Contract the same process tensor with different instruments to obtain reduced states and scalar values.
+
+``` -# Bath initial state -ψmps = MPS(bath, ["Up"]) -ρmpo = to_dm(ψmps) -ρbath0 = to_liouville(ρmpo; sites=bathL) +## Get started -# Bath Hamiltonian -Hbath = OpSum() -Hbath += 1.0, "Sx", 1 +Follow [Installation](installation.md) to set up the package, then choose your learning route depending on what suits you the best -# System-bath coupling -Hcoupling = OpSum() -Hcoupling += 1.0, "Sz", 1, "Sz", 2 +| Your starting point | Suggested route | +| --- | --- | +| **Ready to use process tensors** | [Installation](installation.md), then [Construct your first process tensor](tutorials/process_tensor_singlemode.md) and [Explore a process with instruments](tutorials/process_tensor_instruments.md). Consult the [API reference](api.md) as needed. | +| **Learning the physical framework** | Start with [Process Tensors](theory/process_tensors.md), then [Construct your first process tensor](tutorials/process_tensor_singlemode.md). | +| **New to ITensor or Liouville representations** | Use [ITensor Basics](tutorials/itensor_basics.md), [MPS and MPO Basics](tutorials/mps_mpo_basics.md), and [Liouville-Space Basics](tutorials/liouville_basics.md) for the supporting conventions. | -mode = spin_mode(bathL, Hbath, ρbath0; coupling = Hcoupling) -environment = spin_bath([mode]) +The foundations explain named ITensor indices, density matrices, and vectorisation. +You can consult them whenever these concepts arise in a process-tensor +calculation. -pt = build_process_tensor(system, system.sites[1]; environment, dt, nsteps) -``` +For theory and notation, see [Process Tensors](theory/process_tensors.md), +[Quantum States and Liouville Space](theory/liouville_space.md), and +[Tensor Networks in Physics](theory/tensor_networks.md). For progress reporting, +threading, and execution settings, see [Advanced Usage](advanced_usage.md). -The process tensor can now be used to evolve a reduced system state. +## What can you explore? -```julia -ρsys0 = to_dm(MPS(sys, ["Up"])) +| What can you explore? | Suggested walkthrough | +| --- | --- | +| Build a process from several environmental modes | [Spin-bath process tensor](examples/spin_bath_process_tensor.md) | +| Compress spin and thermal bosonic environments with ACE | [Central-spin dynamics using ACE](examples/central_spin_ace.md); [Thermal spin-boson dynamics using ACE](examples/thermal_spinboson_ace.md) | +| Design preparations, controls, and measurements | [Explore a process with instruments](tutorials/process_tensor_instruments.md) | +| Probe bath memory through repeated measurements | [Ramsey readouts as a probe of bath memory](examples/ramsey_povm.md) | +| Calculate multi-time correlations | [Multi-time correlations](examples/multitime_correlations.md) | +| Carry an ancillary system between interventions | [Testers and noisy quantum circuit](examples/noisy_quantum_circuit_tester.md) | -trajectory = evolve(pt, ρsys0) -``` +Use `Dense()` for small environments or `ACE()` to incorporate initially +independent bath modes and compress their temporal influence. Gaussian PT-TEMPO +and chain-mapped constructions are planned extensions. -Or it can be contracted with an explicit sequence of instruments. +## Additional tools for open quantum dynamics -```julia -obs = OpSum() -obs += 1.0, "Sz", 1 - -seq = default_schedule(pt) -add!(seq, 0, state_preparation(ρsys0)) -add!(seq, nsteps, observable_measurement(obs)) - -expectation = evaluate_process(pt, seq) -``` +The package also provides Hilbert- and Liouville-space MPS/MPO objects, +density-matrix conversions, Lindblad generators, and TEBD/TDVP evolution. +These tools support model preparation and reference calculations and can also +be used independently for time-local dynamics. ---- - -## Find a doc page by topic - -| Goal | Page | -| ------------------------------------------- | -------------------------------------------------------------------- | -| Open-system / Lindblad evolution | [Dissipative Dynamics](tutorials/dissipative_dynamics.md) | -| Bulk-dissipative spin chain | [Dissipative spin chain](examples/dissipative_spin.md) | -| Boundary-driven transport | [Boundary-driven spin chain](examples/boundary_driven_spin_chain.md) | -| Driven dissipative bosons | [Driven-dissipative Bose–Hubbard](examples/driven_dissipative_bose_hubbard.md) | -| Process tensors, baths, and instruments | [Single-Mode Process Tensor](tutorials/process_tensor_singlemode.md) | -| Package setup | [Installation](installation.md) | -| Physics conventions and notation | [Theory: Hilbert and Liouville Space](theory/liouville_space.md) | -| ITensor index and contraction syntax | [Tutorial: ITensor Basics](tutorials/itensor_basics.md) | -| Hilbert-space MPS/MPO | [Tutorial: MPS and MPO Basics](tutorials/mps_mpo_basics.md) | -| Vectorized density matrices | [Tutorial: Liouville Basics](tutorials/liouville_basics.md) | -| Closed-system TEBD/TDVP | [Tutorial: Unitary Dynamics](tutorials/unitary_dynamics.md) | -| Time-dependent Hamiltonians | [Laser-driven TDVP dynamics](examples/laser_driven_tdvp.md) | -| Multimode baths and multi-time correlations | [Spin-bath process tensor](examples/spin_bath_process_tensor.md) | -| Memory-assisted control of a noisy qubit | [Testers and noisy quantum circuit](examples/noisy_quantum_circuit_tester.md) | -| End-to-end scripts | [Examples](examples/tebd_time_evolution.md) | -| Progress, verbose output, and threading | [Advanced Usage](advanced_usage.md) | -| Function reference | [API Reference](api.md) | - -!!! note "Examples" - Example pages are model-oriented Literate walkthroughs with companion scripts under `scripts/`. - Start from the tutorial that matches the physics, then open the corresponding example for a - larger lattice or a driven variant. - ---- +See [Unitary Dynamics](tutorials/unitary_dynamics.md) for closed-system +evolution and [Dissipative Dynamics](tutorials/dissipative_dynamics.md) for +Markovian open-system evolution. Larger models include a +[dissipative spin chain](examples/dissipative_spin.md) and +[driven-dissipative Bose–Hubbard dynamics](examples/driven_dissipative_bose_hubbard.md). ## Citing and contributing -`ProcessTensors.jl` is under active development. Contributions from expert developers, bug reports from users, new examples, and discussions about future directions are very welcome. +If you use `ProcessTensors.jl` in research, please cite the +[package repository](https://github.com/Gauthameshwar/ProcessTensors.jl) and the +relevant methods and theory references linked in the documentation. -If you use the package in research, please cite the [ProcessTensors.jl repository](https://github.com/Gauthameshwar/ProcessTensors.jl) and any relevant process-tensor or tensor-network literature cited in the theory pages. +Bug reports, questions, new examples, and algorithm contributions are welcome. +Visit the [issue tracker](https://github.com/Gauthameshwar/ProcessTensors.jl/issues) +for questions and feedback, or read the +[contribution guidelines](https://github.com/Gauthameshwar/ProcessTensors.jl/blob/main/CONTRIBUTING.md) +to get involved. diff --git a/docs/src/installation.md b/docs/src/installation.md index 5059768..111d0c9 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -1,21 +1,23 @@ # Installation -`ProcessTensors.jl` requires **Julia 1.10 or later**. The package is under active development and is installed from GitHub for v0.1.0; it is not yet on the General Registry. +`ProcessTensors.jl` requires **Julia 1.10 or later**. The package is under active development and is installed from GitHub; it is not yet on the General Registry. + +The latest tagged release is [v0.2.0](https://github.com/Gauthameshwar/ProcessTensors.jl/releases/tag/v0.2.0). `ACE()` and the other work listed under `Unreleased` in the changelog are not in that tag, so `rev="v0.2.0"` does not reproduce this site. ## Install the package -From the Julia REPL, install the **v0.1.0** release: +From the Julia REPL, install the latest release: ```julia using Pkg -Pkg.add(url="https://github.com/Gauthameshwar/ProcessTensors.jl", rev="v0.1.0") +Pkg.add(url="https://github.com/Gauthameshwar/ProcessTensors.jl", rev="v0.2.0") ``` To track the latest development version on `main`: ```julia using Pkg -Pkg.add(url="https://github.com/Gauthameshwar/ProcessTensors.jl") +Pkg.add(url="https://github.com/Gauthameshwar/ProcessTensors.jl", rev="main") ``` Then load it in a session: @@ -51,9 +53,12 @@ Pkg.test("ProcessTensors") ## Build the documentation locally +From the repository root: + ```julia using Pkg Pkg.activate("docs") +Pkg.develop(path=".") Pkg.instantiate() ``` diff --git a/docs/src/theory/liouville_space.md b/docs/src/theory/liouville_space.md index 1680911..5133340 100644 --- a/docs/src/theory/liouville_space.md +++ b/docs/src/theory/liouville_space.md @@ -1,155 +1,42 @@ # Quantum States and Liouville Space -This page introduces the quantum-mechanical objects that appear throughout `ProcessTensors.jl`: state vectors, observables, density matrices, reduced states, Liouville-space vectorisation, and open-system dynamics. +Process tensors connect quantum operations across time. Liouville space provides a common representation for their ingredients: density operators become vectors, operations act as linear maps, and traces or measurement probabilities become contractions. This page introduces the conventions used throughout `ProcessTensors.jl`. -The goal is not to replace a textbook on quantum mechanics or open quantum systems. Instead, this page gives the minimum theory needed to understand why the package moves between Hilbert-space and Liouville-space tensor-network representations. +Vectorisation changes how an operator is represented, while preserving its information. It does not require a Markovian approximation or a master equation. The same language describes states, interventions, and the open system legs of a process tensor. -For tensor-network vocabulary such as MPS, MPO, contraction, and bond dimension, see [Tensor Networks in Physics](tensor_networks.md). +For the tensor-network vocabulary used below, see [Tensor Networks in Physics](tensor_networks.md). Short package previews connect the definitions to code; the complete worked examples are in [Liouville-Space Basics](@ref). -## Hilbert space and density matrices +## Density operators and reduced states -Before going to the Liouville space, we shall have a quick revision of quantum mechanics in the Hilbert space. In ordinary closed-system quantum mechanics, the state of a pure quantum system is represented by a vector $|\psi\rangle$ in a Hilbert space. For a lattice of $N$ sites with local dimension $d$, a general state can be written as +A quantum state is described by a density operator $\rho$ on a Hilbert space $\mathcal H$. A normalised physical state satisfies ```math -|\psi\rangle = -\sum_{s_1,\ldots,s_N} -c_{s_1\cdots s_N} -|s_1,\ldots,s_N\rangle. +\rho^\dagger=\rho,\qquad \rho\geq0,\qquad \operatorname{Tr}(\rho)=1. ``` -The time evolution of a closed system is generated by a Hamiltonian $H$. In units where $\hbar=1$, the Schrödinger equation is +A pure state has $\rho=|\psi\rangle\langle\psi|$. More generally, ```math -\frac{d}{dt}|\psi(t)\rangle -= --iH|\psi(t)\rangle. +\rho=\sum_a p_a|\psi_a\rangle\langle\psi_a|, +\qquad p_a\geq0,\qquad \sum_a p_a=1, ``` -For a time-independent Hamiltonian, +where the constituent states are normalised. The ensemble decomposition is generally not unique: different preparations can produce the same density operator and therefore the same measurement statistics. For a Hermitian observable $O$, its expectation is $\langle O\rangle=\operatorname{Tr}(O\rho)$. -```math -|\psi(t)\rangle -= -U(t)|\psi(0)\rangle, -\qquad -U(t)=e^{-iHt}. -``` - -In tensor-network simulations, $|\psi\rangle$ is often represented as a matrix product state (MPS), while $H$ and other many-body operators are represented as matrix product operators (MPOs). - -!!! info "In the package" - ```julia - sites = siteinds("S=1/2", N) - ψ = MPS(sites, fill("Up", N)) - H_mpo = MPO(H, sites) - ``` - - See [MPS and MPO Basics](@ref) for `siteinds`, `OpSum`, and Hamiltonian assembly. - -### Observables and expectation values - -A physical observable is represented by an operator $O$. For a pure state, its expectation value is +For a system $S$ coupled to an environment $E$, the joint state acts on $\mathcal H_S\otimes\mathcal H_E$. The **reduced state** ```math -\langle O\rangle -= -\langle\psi|O|\psi\rangle. +\rho_S=\operatorname{Tr}_E(\rho_{SE}) ``` -The variance of $O$ is +contains everything needed to predict measurements on the system at that time: ```math -\operatorname{Var}(O) -= -\langle O^2\rangle -- -\langle O\rangle^2. +\operatorname{Tr}_S(O_S\rho_S) +=\operatorname{Tr}_{SE}\bigl[(O_S\otimes I_E)\rho_{SE}\bigr]. ``` -Two-point correlation functions compare observables at different sites. For example, - -```math -C_{AB}(i,j) -= -\langle A_iB_j\rangle -- -\langle A_i\rangle\langle B_j\rangle. -``` - -These are ordinary Hilbert-space quantities. They are the baseline against which density-matrix and Liouville-space formulas should be compared. - -!!! info "In the package" - ```julia - mz = expect(ψ, "Sz") # local expectations - expect_O = real(inner(ψ', O_mpo, ψ)) # operator expectation - ``` - - See [MPS and MPO Basics](@ref) for `expect`, MPO insertion, and energy calculations. - -!!! note "Package perspective" - In `ProcessTensors.jl`, Hilbert-space MPS and MPO objects provide the familiar starting point. Density-matrix and Liouville-space tools extend this language to mixed states, dissipative dynamics, and process tensors. - -!!! info "In the package" - ```julia - ρ = to_dm(ψ) # pure state → density MPO - ρ_mix = to_dm([ψ1, ψ2]; coeffs=[p, 1-p]) # statistical mixture - ``` - - See [MPS and MPO Basics](@ref) for pure states, density-matrix MPOs, and expectations. - -Pure state vectors are not enough for open quantum systems. A subsystem interacting with an environment is generally not described by a single state vector, even if the combined system-environment state is pure. - -The density matrix formalism gives a more general description of quantum states that contains these classical probabilities associated with each pure wavefunction. - -For a pure state, - -```math -\rho -= -|\psi\rangle\langle\psi|. -``` - -For a statistical mixture of pure states, - -```math -\rho -= -\sum_i p_i -|\psi_i\rangle\langle\psi_i|, -``` - -where - -```math -p_i \ge 0, -\qquad -\sum_i p_i = 1. -``` - -A valid density matrix satisfies three important properties: - -```math -\operatorname{Tr}(\rho)=1, -\qquad -\rho^\dagger=\rho, -\qquad -\rho \ge 0. -``` - -These mean that the state is normalised, Hermitian, and positive semidefinite. - -Expectation values are computed as - -```math -\langle O\rangle -= -\operatorname{Tr}(O\rho). -``` - -For a pure state $\rho=|\psi\rangle\langle\psi|$, this reduces to the familiar expression $\langle\psi|O|\psi\rangle$. - -!!! note "Package convention" - In `ProcessTensors.jl`, a pure MPS can be converted into a density-matrix MPO using `to_dm(ψ)`. Mixtures of MPS states are also supported, provided the probabilities are non-negative and sum to one. +Even when $\rho_{SE}$ is pure, $\rho_S$ can be mixed because the system is entangled with its environment. Mixedness need not arise from uncertainty about which pure state was prepared. Furthermore, the reduced state alone generally does not determine the response to later interventions: correlations with the environment can affect what happens next. Process tensors retain the multi-time response needed for such questions. !!! info "In the package" ```julia @@ -157,614 +44,243 @@ For a pure state $\rho=|\psi\rangle\langle\psi|$, this reduces to the familiar e ρ_mix = to_dm([ψ_a, ψ_b]; coeffs=[0.7, 0.3]) ``` - See [MPS and MPO Basics](@ref) for pure and mixed density-matrix construction and normalization checks. + These construct Hilbert-space density MPOs from compatible MPS states. See [MPS and MPO Basics](@ref) for state preparation and reduced density matrices. -### Reduced density matrices and partial traces +## Operators as Liouville-space vectors -Open-system physics usually begins with a larger closed system made of a system $S$ and a bath or environment $B$. The joint density matrix lives on the composite Hilbert space +The linear operators on a $d$-dimensional Hilbert space form a $d^2$-dimensional vector space. Equipped with the **Hilbert–Schmidt inner product**, this is itself a Hilbert space: ```math -\mathcal{H}_{SB} -= -\mathcal{H}_S -\otimes -\mathcal{H}_B. +\langle\!\langle A|B\rangle\!\rangle +=\operatorname{Tr}(A^\dagger B). ``` -If the full state is $\rho_{SB}$, the reduced state of the system is obtained by tracing out the bath: +We call it Liouville space. Density operators, observables, and non-Hermitian operators all belong to this space. The notation $|A\rangle\!\rangle$ denotes the vector representation of an operator $A$. -```math -\rho_S -= -\operatorname{Tr}_B(\rho_{SB}). -``` +### Column-major vectorisation -Similarly, the reduced bath state is +The package uses column-major ordering, consistent with Julia's array convention. If $A_{jk}=\langle j|A|k\rangle$, then ```math -\rho_B -= -\operatorname{Tr}_S(\rho_{SB}). +|A\rangle\!\rangle=\operatorname{vec}(A) +=\sum_{j,k}A_{jk}|k\rangle\otimes|j\rangle. ``` -The partial trace is the mathematical operation behind the phrase “ignore the bath degrees of freedom.” It keeps all system observables correct: +The row index $j$ changes fastest. For example, ```math -\langle O_S\rangle -= -\operatorname{Tr}_{SB} -\left[ -(O_S\otimes I_B)\rho_{SB} -\right] -= -\operatorname{Tr}_S -\left[ -O_S\rho_S -\right]. +A=\begin{pmatrix}a_{11}&a_{12}\\a_{21}&a_{22}\end{pmatrix} +\quad\longmapsto\quad +|A\rangle\!\rangle= +\begin{pmatrix}a_{11}\\a_{21}\\a_{12}\\a_{22}\end{pmatrix}. ``` -A useful quantity derived from a reduced state is the von Neumann entropy, +The basis here is fixed throughout. The two factors record the original column and row indices; they do not introduce a second physical system. -```math -S(\rho_S) -= --\operatorname{Tr} -\left( -\rho_S\log\rho_S -\right). +```@raw html +
+ Column-major vectorisation bends the bra index beside the ket and fuses them into one Liouville leg. + Column-major vectorisation bends the bra index beside the ket and fuses them into one Liouville leg. +
``` -For a bipartite pure state, this entropy measures entanglement between $S$ and $B$. For a genuinely mixed state, it also contains classical and environmental uncertainty. +The bend rearranges indices. It does not complex-conjugate the operator. The fused leg is labelled $(k,j)$, in the same order as the column-major vector above. -```text -Full state: +### Local vectorisation of a many-body operator - system S bath B - ┌──────────┐ ┌──────────┐ - │ │ │ │ - │ ρ_SB │ ---> │ trace B │ - │ │ │ │ - └──────────┘ └──────────┘ +For a density MPO, vectorisation can be performed one site at a time. At site $j$, the local ket and bra legs combine into a Liouville index of dimension $d_j^2$. The resulting object is an MPS in operator space. This local fusion leaves the existing virtual bonds unchanged; compression, if performed afterwards, is a separate operation. -Reduced state: +There is a small ordering distinction when comparing dense arrays. Vectorising the full many-body matrix groups all column indices and then all row indices. Local fusion groups each site's column–row pair together. The two arrangements are related by a fixed permutation of indices. Dense matrices and vectors must use the same arrangement before their entries can be compared. - ρ_S = Tr_B(ρ_SB) +```@raw html +
+ Local vectorisation turns each ket–bra pair into one Liouville leg and leaves the virtual bonds unchanged. + Local vectorisation turns each ket–bra pair into one Liouville leg and leaves the virtual bonds unchanged. +
``` -!!! note "Why this matters for process tensors" - Process tensors describe reduced system dynamics while keeping track of how the bath remembers past interactions. Reduced density matrices are the first step toward that language. - !!! info "In the package" ```julia - ρ = to_dm(ψ) # full-chain density MPO - # trace out other sites via delta contractions on bra/ket legs (tutorial) - ρ1 = one_site_reduced_density_matrix(ρ, sites, 1) + sites_L = liouv_sites(sites) + ρL = to_liouville(ρ; sites=sites_L) ``` - See [MPS and MPO Basics](@ref) for one-site reduced density matrices and entanglement diagnostics. + `ρ` is a Hilbert-space density MPO; `ρL` represents the same operator as a Liouville MPS. Reuse these exact `sites_L` indices when constructing objects that will contract with it. Matching dimensions and tags alone do not make independently created indices identical. +## Traces, observables, and measurement effects -## Liouville space and vectorisation - -A density matrix is an operator on Hilbert space, +Vectorisation makes physical outputs ordinary inner products. The vectorised identity is ```math -\rho \in \mathcal{B}(\mathcal{H}), +|I\rangle\!\rangle=\sum_j|j\rangle\otimes|j\rangle, +\qquad +\langle\!\langle I|A\rangle\!\rangle=\operatorname{Tr}(A). ``` -where $\mathcal{B}(\mathcal{H})$ denotes the space of linear operators on -$\mathcal{H}$. +Contracting an open Liouville leg with $\langle\!\langle I|$ therefore traces out that subsystem. Applied to the environment legs of a joint state, this gives the partial trace and leaves the system legs open. -The key observation is that $\mathcal{B}(\mathcal{H})$ is itself a Hilbert -space when equipped with the Hilbert--Schmidt inner product, +For a Hermitian observable $O$, ```math -\langle\langle A | B \rangle\rangle -= -\operatorname{Tr}(A^\dagger B). +\langle O\rangle +=\operatorname{Tr}(O\rho) +=\langle\!\langle O|\rho\rangle\!\rangle. ``` -This operator Hilbert space is called **Liouville space**. +For a general operator $A$, the corresponding identity is $\operatorname{Tr}(A\rho)=\langle\!\langle A^\dagger|\rho\rangle\!\rangle$. The dagger matters because a Hilbert–Schmidt inner product conjugates its first argument. -If $\dim(\mathcal{H})=d$, then +A measurement outcome is associated with a positive **effect** $E_x$, with $\sum_xE_x=I$ for a complete measurement. Its probability is ```math -\dim\mathcal{B}(\mathcal{H}) = d^2. +p_x=\operatorname{Tr}(E_x\rho) +=\langle\!\langle E_x|\rho\rangle\!\rangle. ``` -This is why Liouville-space simulations are more expensive than pure-state -Hilbert-space simulations, but also why density matrices can be treated with -state-vector tensor-network tools. +An effect specifies the outcome probability. To describe the state after that outcome, we need an operation as well, introduced in the next section. -!!! note "Package perspective" - In `ProcessTensors.jl`, a Hilbert-space density matrix is represented as an - `MPO{Hilbert}`. After vectorisation, it becomes an `MPS{Liouville}`. +```@raw html +
+ An open Liouville state, its trace, an effect probability, and a partial trace of the environment. + An open Liouville state, its trace, an effect probability, and a partial trace of the environment. +
+``` + +Leaving the leg open retains $|\rho\rangle\!\rangle$. Closing it with the identity gives the trace, and closing it with $E_x$ gives $p_x$. Tracing only the environment leaves the system leg open. !!! info "In the package" ```julia - sites_L = liouv_sites(sites) # create once, reuse everywhere - ρL = to_liouville(ρ; sites=sites_L) - ρ_back = to_hilbert(ρL; sites=sites) # round-trip check + Id_L = to_liouville(Id_mpo; sites=sites_L) + O_L = to_liouville(O_mpo; sites=sites_L) + trace_ρ = inner(Id_L, ρL) + mean_O = inner(O_L, ρL) ``` - See [Liouville-Space Basics](@ref) for vectorisation, column-major ordering, and shared `sites_L`. - -### First-level vectorisation - -Choose an orthonormal basis $\{|j\rangle\}$ of $\mathcal{H}$. The basic -vectorisation rule is + Here `Id_mpo` is the identity and `O_mpo` is a Hermitian observable on the same Hilbert sites as `ρ`. See [Liouville-Space Basics](@ref) for their construction. -```math -|j\rangle\langle k| -\longmapsto -|j\rangle\otimes|k\rangle. -``` +### Trace normalisation and purity -Equivalently, for an operator +A density operator has unit trace, but its Liouville vector generally does not have unit Euclidean norm: ```math -A = -\sum_{j,k} -A_{jk} -|j\rangle\langle k|, -``` - -we define - -```math -|A\rangle\rangle -= -\sum_{j,k} -A_{jk} -|j\rangle\otimes|k\rangle. -``` - -This is the simple linear-algebra move behind Liouville space: an operator -becomes a vector in a larger space. - -A more invariant way to say this is - -```math -\mathcal{B}(\mathcal{H}) -\simeq -\mathcal{H}\otimes\mathcal{H}^*. +\langle\!\langle I|\rho\rangle\!\rangle=1, +\qquad +\|\,|\rho\rangle\!\rangle\,\|_2^2 +=\operatorname{Tr}(\rho^2). ``` -The second factor is the dual space. After a basis is chosen, it is often -identified computationally with another copy of $\mathcal{H}$. - -!!! warning "Basis dependence" - The bare map - $|j\rangle\langle k|\mapsto |j\rangle\otimes|k\rangle$ - depends on the chosen basis. This is harmless for numerical work if the - convention is fixed, but it should not be mistaken for a basis-independent - physical statement. - -!!! tip "Why this language is useful" - In Hilbert-space notation, a channel looks like a function acting on a - matrix: - - ```math - \rho \mapsto \Phi(\rho). - ``` - - In Liouville space, the same channel is represented as an operator acting - on a vector: - - ```math - |\rho\rangle\rangle - \mapsto - \Phi |\rho\rangle\rangle. - ``` - -### Column-major vectorisation convention - -Different communities use different vectorisation conventions. The convention -matters because it determines the Kronecker-product formulas for left and right -multiplication. +The second quantity is the **purity**. It equals one for a pure state and $1/d$ for the maximally mixed state $I/d$. Consequently, normalising a mixed-state Liouville MPS to unit Euclidean norm changes its physical trace. State normalisation must use the trace instead. -`ProcessTensors.jl` uses **column-major vectorisation**, matching Julia's native -array ordering. +## Operations as Liouville-space maps -For a one-site density matrix +A linear operation $\Phi$ acting on operators has a matrix representation $S_\Phi$ in Liouville space: ```math -\rho -= -\begin{pmatrix} -\rho_{11} & \rho_{12} \\ -\rho_{21} & \rho_{22} -\end{pmatrix}, -``` - -the vectorised state is - -```math -|\rho\rangle\rangle -= -\operatorname{vec}(\rho) -= -\begin{pmatrix} -\rho_{11} \\ -\rho_{21} \\ -\rho_{12} \\ -\rho_{22} -\end{pmatrix}. -``` - -Equivalently, the first matrix index changes fastest. - -```text -Matrix: - - column 1 column 2 - - ρ₁₁ ρ₁₂ - ρ₂₁ ρ₂₂ - -Column-major vector: - - [ρ₁₁, ρ₂₁, ρ₁₂, ρ₂₂]ᵀ +|\Phi(A)\rangle\!\rangle=S_\Phi|A\rangle\!\rangle. ``` -!!! warning "Do not mix conventions" - Row-major and column-major vectorisation lead to different formulas. All - left/right action rules in this documentation assume the column-major - convention above. - -### Operators as superoperators +An input Hilbert dimension $d_{\mathrm{in}}$ and output dimension $d_{\mathrm{out}}$ give Liouville dimensions $d_{\mathrm{in}}^2$ and $d_{\mathrm{out}}^2$. Such maps need not be square. -A superoperator is a linear map acting on operators. After vectorisation, it -becomes an ordinary operator acting on Liouville-space vectors. +### Left and right multiplication -The central identity is +The column-major convention gives the identity ```math \operatorname{vec}(A\rho B) -= -(B^{\mathsf{T}}\otimes A) -\operatorname{vec}(\rho). +=(B^{\mathsf T}\otimes A)\operatorname{vec}(\rho). ``` -Two special cases are especially important: - -```math -\operatorname{vec}(A\rho) -= -(I\otimes A) -|\rho\rangle\rangle, -``` - -and - -```math -\operatorname{vec}(\rho B) -= -(B^{\mathsf{T}}\otimes I) -|\rho\rangle\rangle. -``` +Indeed, $(A\rho B)_{jk}=\sum_{m,n}A_{jm}\rho_{mn}B_{nk}$, which is exactly the component action of $B^{\mathsf T}\otimes A$ in the chosen ordering. The transpose records this rearrangement; it is not a physical transpose operation performed on the state. -So left multiplication and right multiplication are represented by different -Liouville-space operators. +| Hilbert-space action | Liouville matrix | Package suffix | +|:--|:--|:--| +| $A\rho$ | $I\otimes A$ | `_L` | +| $\rho A$ | $A^{\mathsf T}\otimes I$ | `_R` | +| $A\rho A^\dagger$ | $A^*\otimes A$ | `_Jump` | +| $A^\dagger A\rho$ | $I\otimes A^\dagger A$ | `_LdagL_L` | +| $\rho A^\dagger A$ | $(A^\dagger A)^{\mathsf T}\otimes I$ | `_LdagL_R` | -In package notation: - -| Hilbert-space action | Liouville-space action | Package suffix | -| -------------------- | ---------------------- | -------------- | -| $A\rho$ | $(I \otimes A)\vert\rho\rangle\rangle$ | `A_L` | -| $\rho A$ | $(A^{\mathsf{T}} \otimes I)\vert\rho\rangle\rangle$ | `A_R` | -| $A\rho A^\dagger$ | $(A^* \otimes A)\vert\rho\rangle\rangle$ | `A_Jump` | -| $A^\dagger A\rho$ | $(I \otimes A^\dagger A)\vert\rho\rangle\rangle$ | `A_LdagL_L` | -| $\rho A^\dagger A$ | $((A^\dagger A)^{\mathsf{T}} \otimes I)\vert\rho\rangle\rangle$ | `A_LdagL_R` | - -!!! note "Left and right are physical statements" - The suffixes `_L` and `_R` do not mean “left tensor leg” and “right tensor - leg”. They mean multiplication of the density matrix from the left or from - the right before vectorisation. +Here $*$ denotes entrywise complex conjugation. The suffixes refer to multiplication of the original operator, not to the visual position of a tensor leg. For a many-body network, these rules are applied with the local index ordering described above. !!! info "In the package" ```julia - # OpSum terms use _L / _R suffixes before vectorisation os = OpSum() - os += 1.0im, "Sx_L", 1 # Sx ρ - os += -1.0im, "Sx_R", 1 # ρ Sx + os += -1.0im, "Sx_L", 1 + os += 1.0im, "Sx_R", 1 ``` - See [Liouville superoperators and OpSums](@ref liouville-superoperators-and-opsums) in [Liouville-Space Basics](@ref). - -### Channels as Liouville-space operators - -A deterministic physical evolution of density matrices is described by a -completely positive trace-preserving map, - -```math -\Phi: -\mathcal{B}(\mathcal{H}_{\mathrm{in}}) -\rightarrow -\mathcal{B}(\mathcal{H}_{\mathrm{out}}). -``` + These terms encode $-i[S_x,\rho]$. See [Liouville-Space Basics](@ref) for assembling superoperator `OpSum`s into Liouville MPOs. -Such maps are quantum channels. +### Channels and outcome-conditioned operations -In Hilbert-space notation, a channel is written as a map, +A quantum channel is a completely positive, trace-preserving linear map. Complete positivity means that positivity is preserved even when the map acts on one part of a larger system. In finite dimensions, a channel admits a Kraus representation ```math -\rho_{\mathrm{in}} -\longmapsto -\Phi(\rho_{\mathrm{in}}). +\Phi(\rho)=\sum_a K_a\rho K_a^\dagger, +\qquad \sum_aK_a^\dagger K_a=I, +\qquad S_\Phi=\sum_a K_a^*\otimes K_a. ``` -After vectorisation, it becomes a matrix-like object, +Trace preservation becomes ```math -|\rho_{\mathrm{out}}\rangle\rangle -= -\Phi -|\rho_{\mathrm{in}}\rangle\rangle. +\langle\!\langle I_{\mathrm{out}}|S_\Phi +=\langle\!\langle I_{\mathrm{in}}|. ``` -This is the computational reason for using Liouville space in this package: -density matrices become MPS-like objects, while channels and generators become -MPO-like objects. - -!!! info "In the package" - ```julia - ρL = to_liouville(ρ; sites=sites_L) # density matrix as Liouville MPS - L_mpo = liouvillian_mpo(H, sites_L) # channel generator as Liouville MPO - ``` - - See [Liouville-Space Basics](@ref) for the full Hilbert ↔ Liouville workflow. - -!!! info "Trace preservation" - A channel is trace preserving when - - ```math - \operatorname{Tr}[\Phi(\rho)] = \operatorname{Tr}(\rho) - ``` - - for all density matrices $\rho$. In Liouville notation this becomes a - statement about the vectorised identity: - - ```math - \langle\langle I|\Phi - = - \langle\langle I|. - ``` - -## Dynamics in Liouville space - -### Hamiltonian dynamics - -Closed-system density matrices obey the von Neumann equation +For a particular measurement outcome $x$, an operation $\mathcal A_x$ is completely positive and trace-nonincreasing: ```math -\frac{d\rho}{dt} -= --i[H,\rho]. +\mathcal A_x(\rho)=\sum_aK_{x,a}\rho K_{x,a}^\dagger, +\qquad E_x=\sum_aK_{x,a}^\dagger K_{x,a}\leq I. ``` -Expanding the commutator, +Its output is the unnormalised conditional state $\widetilde\rho_x=\mathcal A_x(\rho)$. Its trace gives $p_x$, and the normalised conditional state is $\rho_x=\widetilde\rho_x/p_x$ when $p_x>0$. A complete instrument is a collection of such outcome maps whose sum is trace preserving. Different instruments can have the same effects while producing different post-measurement states. -```math -\frac{d\rho}{dt} -= --iH\rho -+ -i\rho H. -``` +The unnormalised maps are linear. Dividing by an outcome probability is generally nonlinear in the input state, so normalisation is performed after the linear contraction when a conditional state is wanted. -After vectorisation, this becomes +### Markovian generators -```math -\frac{d}{dt} -|\rho\rangle\rangle -= -\mathcal{L}_H -|\rho\rangle\rangle, -``` - -where +Liouville space also represents time-local density-matrix dynamics. In units with $\hbar=1$, the Hamiltonian generator is ```math -\mathcal{L}_H -= --i(I\otimes H) -+ -i(H^{\mathsf{T}}\otimes I). +\mathcal L_H=-i(I\otimes H-H^{\mathsf T}\otimes I), +\qquad \frac{d}{dt}|\rho\rangle\!\rangle=\mathcal L_H|\rho\rangle\!\rangle. ``` -Using the package suffix language, this is the same structure as +For a time-independent GKLS generator with rates $\gamma_\mu\geq0$, ```math -\mathcal{L}_H -= --iH_L -+ -iH_R. +\mathcal L=\mathcal L_H+\sum_\mu\gamma_\mu +\left[ +L_\mu^*\otimes L_\mu +-\frac12 I\otimes L_\mu^\dagger L_\mu +-\frac12(L_\mu^\dagger L_\mu)^{\mathsf T}\otimes I +\right], +\qquad S_{\Phi_t}=e^{t\mathcal L}. ``` -Here $H_L$ means “left multiplication by $H$” and $H_R$ means “right multiplication by $H$” in the Liouville-space convention used by the package. - -!!! tip "Why this is useful" - Once the density matrix is vectorised, Hamiltonian density-matrix evolution looks like ordinary linear evolution generated by a Liouville-space operator. +A generator obeys $\langle\!\langle I|\mathcal L=0$, whereas its trace-preserving propagator obeys $\langle\!\langle I|S_{\Phi_t}=\langle\!\langle I|$. The generator is not itself a channel. The GKLS form also does not, by itself, specify a local or global master-equation derivation; that distinction concerns how the Hamiltonian, jump operators, and rates were obtained. !!! info "In the package" ```julia L_mpo = liouvillian_mpo(H, sites_L) - ρL = tdvp(ρL, L_mpo, -1im * dt; alg=TDVP(), maxdim=32) ``` - See [Liouville-Space Basics](@ref) for the commutator generator and [Hilbert versus Liouville evolution](@ref hilbert-liouville-tdvp) in [Unitary Dynamics](@ref). - -### Open Markovian dynamics - -Many Markovian open quantum systems are described by a Master equation in the GKLS form. The most commonly used version is the Local Master Equations (LME), given by - -```math -\frac{d\rho}{dt} -= --i[H,\rho] -+ -\sum_\mu -\gamma_\mu -\left( -L_\mu\rho L_\mu^\dagger -- -\frac{1}{2} -\left\{ -L_\mu^\dagger L_\mu, -\rho -\right\} -\right). -``` - -Here $H$ generates coherent Hamiltonian dynamics, while the jump operators $L_\mu$ describe dissipative processes such as decay, dephasing, or particle loss. - -The anticommutator is - -```math -\left\{ -L_\mu^\dagger L_\mu, -\rho -\right\} -= -L_\mu^\dagger L_\mu\rho -+ -\rho L_\mu^\dagger L_\mu. -``` - -After vectorisation, the whole equation becomes - -```math -\frac{d}{dt} -|\rho\rangle\rangle -= -\mathcal{L} -|\rho\rangle\rangle, -``` - -where $\mathcal{L}$ is the Liouvillian superoperator. - -For one jump operator $L$ with rate $\gamma$, the dissipative part has the structure - -```math -\gamma -\left( -L^*\otimes L -- -\frac{1}{2} -I\otimes L^\dagger L -- -\frac{1}{2} -(L^\dagger L)^{\mathsf{T}}\otimes I -\right). -``` - -In package language, these correspond to the `_Jump`, `_LdagL_L`, and `_LdagL_R` terms. - -!!! note "Package bridge" - `liouvillian_opsum` builds a symbolic Liouvillian operator sum, while `liouvillian_mpo` builds a Liouville-space MPO. These are the objects used to represent Hamiltonian and dissipative density-matrix evolution in Liouville space. - -!!! info "In the package" - ```julia - using ITensors.Ops: Trotter - jumps = [(γ, "Sm", 1)] # jump rate, operator, site - L_mpo = liouvillian_mpo(H, sites_L; jump_ops=jumps) - ρL = tebd(ρL, L_mpo, dt, T; alg=Trotter{2}(), maxdim=32) - ``` - - See [Dissipative Dynamics](@ref) for a full Lindblad walkthrough and [dissipative Lindblad model](@ref dissipative-lindblad-mpo) for the `liouvillian_mpo` construction. - - For larger runnable models after that tutorial, see: - - - [Dissipative spin chain](../examples/dissipative_spin.md) — bulk amplitude damping with Liouville TEBD - - [Boundary-driven spin chain](../examples/boundary_driven_spin_chain.md) — opposing edge reservoirs and spin current with Liouville TDVP - - [Driven-dissipative Bose–Hubbard](../examples/driven_dissipative_bose_hubbard.md) — time-dependent pump plus local loss - -### Hilbert-to-Liouville dictionary - -The following table summarises the main translations used throughout this documentation. - -| Hilbert-space expression | Liouville-space expression | Meaning | -| ------------------------ | -------------------------- | ------- | -| $\rho$ | $\vert\rho\rangle\rangle$ | vectorised density matrix | -| $A\rho$ | $A_{\mathrm{L}}\vert\rho\rangle\rangle$ | left multiplication | -| $\rho A$ | $A_{\mathrm{R}}\vert\rho\rangle\rangle$ | right multiplication | -| $A\rho B$ | $(B^{\mathsf{T}} \otimes A)\vert\rho\rangle\rangle$ | left-right multiplication | -| $A\rho A^\dagger$ | $(A^* \otimes A)\vert\rho\rangle\rangle$ | jump term | -| $\operatorname{Tr}(\rho)$ | $\langle\langle I \vert \rho \rangle\rangle$ | trace as identity overlap | -| $\langle O\rangle=\operatorname{Tr}(O\rho)$ | $\langle\langle O \vert \rho \rangle\rangle$ | expectation as Liouville overlap | -| $-i[H,\rho]$ | $-iH_{\mathrm{L}}\vert\rho\rangle\rangle + iH_{\mathrm{R}}\vert\rho\rangle\rangle$ | Hamiltonian Liouvillian | -| $L\rho L^\dagger$ | `_Jump` | jump term | -| $L^\dagger L\rho$ | `_LdagL_L` | left anticommutator term | -| $\rho L^\dagger L$ | `_LdagL_R` | right anticommutator term | - -!!! warning "Trace and expectation values become overlaps" - In Liouville space, quantities such as $\operatorname{Tr}(\rho)$ and $\operatorname{Tr}(O\rho)$ are computed as overlaps with vectorised operators. This is why identity operators and observable insertions appear naturally as contractions in tensor-network diagrams. - -!!! info "In the package" - ```julia - Id_L = to_liouville(Id_mpo; sites=sites_L) - Sz_L = to_liouville(Sz_mpo; sites=sites_L) - Tr_ρ = inner(Id_L, ρL) - expect_Sz = inner(Sz_L, ρL) # same sites_L on both factors - ``` - - See [Trace and expectation values](@ref liouville-trace-expectations) in [Liouville-Space Basics](@ref). - -!!! related "Related tutorials and examples" - | Topic | Page | - | ----- | ---- | - | Vectorisation and overlaps | [Liouville-Space Basics](@ref) | - | Closed TEBD / TDVP | [Unitary Dynamics](@ref) | - | Lindblad generators and open TEBD / TDVP | [Dissipative Dynamics](@ref) | - | Bulk dissipative TFIM | [Dissipative spin chain](../examples/dissipative_spin.md) | - | Boundary-driven XXZ transport | [Boundary-driven spin chain](../examples/boundary_driven_spin_chain.md) | - | Driven bosons with loss | [Driven-dissipative Bose–Hubbard](../examples/driven_dissipative_bose_hubbard.md) | - | Non-Markovian reduced dynamics | [Process Tensors](process_tensors.md), [Single-Mode Process Tensor](@ref) | - -## Further reading - -This page is meant to be a compact bridge into the rest of the package. For more background, the following resources are useful. - -### Quantum states and density matrices - -1. [John Preskill, *Lecture Notes for Quantum Computation*, Chapter 2 and Chapter 3](http://theory.caltech.edu/~preskill/ph229/) - - A clear introduction to density matrices, measurements, quantum operations, and channels from a quantum-information perspective. - -2. [Michael A. Nielsen and Isaac L. Chuang, *Quantum Computation and Quantum Information*](https://www.cambridge.org/highereducation/books/quantum-computation-and-quantum-information/01E10196D0A682A6AEFFEA52D53BE9AE) - - A standard reference for density matrices, partial traces, quantum channels, and open-system language in finite-dimensional quantum mechanics. - -3. [Mark M. Wilde, *Quantum Information Theory*](https://arxiv.org/abs/1106.1445) - - A freely available advanced reference with careful treatments of density operators, partial traces, purifications, and quantum channels. - -### Liouville space and vectorisation - -1. [J. A. Gyamfi, “Fundamentals of Quantum Mechanics in Liouville Space.”](https://arxiv.org/abs/2003.11472) - - A pedagogical introduction to finite-dimensional Liouville space, vectorisation, Kronecker products, and open-system master equations. - -2. [QuTiP documentation](https://qutip.readthedocs.io/en/stable/guide/guide.html) - - Useful for practical examples of density matrices, Lindblad master equations, superoperators, and numerical open-system simulation. - -3. [QuTiP: An open-source Python framework for the dynamics of open quantum systems.](https://arxiv.org/abs/1110.0573) - - A software-oriented reference showing how open quantum dynamics, master equations, and Monte Carlo methods are organised in a numerical package. - -### Open quantum systems - -1. [Heinz-Peter Breuer and Francesco Petruccione, *The Theory of Open Quantum Systems*](https://www.researchgate.net/publication/235426843_The_Theory_of_Open_Quantum_Systems) - - A standard textbook reference for reduced density matrices, master equations, Markovian limits, and non-Markovian open-system dynamics. + For a Hamiltonian `OpSum` `H`, this builds the commutator generator, including its factor of $-i$. See [Unitary Dynamics](@ref) and [Dissipative Dynamics](@ref) for propagation and jump-operator examples. -2. [Ángel Rivas and Susana F. Huelga, *Open Quantum Systems: An Introduction*](https://link.springer.com/book/10.1007/978-3-642-23354-8) +## Related material and further reading - A compact and readable introduction to the mathematical structure of open quantum dynamics. +!!! related "Continue learning" + | Goal | Page | + |:--|:--| + | Practise vectorisation, overlaps, and maps | [Liouville-Space Basics](@ref) | + | Understand the multi-time description | [Process Tensors](process_tensors.md) | + | Construct a reusable process | [Construct a process tensor](@ref) | + | Apply interventions and measurements | [Process tensor instruments](@ref) | + | Use time-local evolution tools | [Unitary Dynamics](@ref) and [Dissipative Dynamics](@ref) | -3. [G. Lindblad, “On the Generators of Quantum Dynamical Semigroups.”](https://projecteuclid.org/journals/communications-in-mathematical-physics/volume-48/issue-2/On-the-generators-of-quantum-dynamical-semigroups/cmp/1103899849.full) +For more background: - The original mathematical reference for the Lindblad generator of Markovian quantum dynamics. +- John Preskill, [Lecture Notes for Quantum Computation](https://theory.caltech.edu/~preskill/ph229/), Chapters 2 and 3: density operators, measurements, and quantum operations. +- J. A. Gyamfi, [Fundamentals of Quantum Mechanics in Liouville Space](https://arxiv.org/abs/2003.11472): operator-space methods and vectorisation conventions. +- Mark M. Wilde, [Quantum Information Theory](https://arxiv.org/abs/1106.1445): channels, instruments, and the Choi representation. diff --git a/docs/src/theory/process_tensors.md b/docs/src/theory/process_tensors.md index 006712d..3580e61 100644 --- a/docs/src/theory/process_tensors.md +++ b/docs/src/theory/process_tensors.md @@ -1,10 +1,9 @@ # Process Tensors -A process tensor is the central object used to describe a quantum system that is probed, controlled, measured, or otherwise intervened on at multiple times while it remains coupled to an environment. +A process tensor describes how a quantum system responds to operations applied at several times while it interacts with an environment. It retains the multi-time information needed to compare different preparations, controls, and measurements within one underlying process. -The goal of this page is to explain what a process tensor is, why it naturally describes non-Markovian open quantum dynamics, and how the same object appears in `ProcessTensors.jl` as a tensor network with time steps, input/output legs, memory links, and instruments. - -For the Hilbert-space, density-matrix, and Liouville-space conventions used in this package, see [Quantum States and Liouville Space](liouville_space.md). +The goal of this page is to explain what a process tensor is, how it naturally describes non-Markovian open quantum dynamics, and how the same object appears in `ProcessTensors.jl` as a tensor network with time steps, input/output legs, memory links, and instruments. +This page uses the vectorisation conventions introduced in [Quantum States and Liouville Space](liouville_space.md) and the network vocabulary from [Tensor Networks in Physics](tensor_networks.md). ## From channels to multi-time processes @@ -16,537 +15,250 @@ A quantum state is represented by a density matrix $\rho$. A quantum channel is \Phi(\rho). ``` -For deterministic dynamics, $\Phi$ is usually required to be Completely Positive and Trace Preserving (CPTP). The Local Master Equation, and the closed unitary dynamics of $\rho$ given by Von-Neuman equations are examples of channels. +For deterministic dynamics, $\Phi$ is completely positive and trace preserving (CPTP). Unitary propagation and propagation generated by a time-independent GKLS master equation are familiar examples. + +```@raw html +
+ A channel propagates an input state at time s to an output state at time t. Time runs right to left. + A channel propagates an input state at time s to an output state at time t. Time runs right to left. +
+``` In this sense, an ordinary channel answers a one-time question: Given the state now, what is the state later? We can extend this and ask a more operational question: Given the operations performed on the system at several earlier times, what state, or outcome do we obtain later? -To express this idea cleanly, it is useful to introduce the language of **superchannels**. A channel maps states to states. A superchannel is a higher-order transformation that takes quantum channels as inputs and returns a state output. In this sense, a process tensor is a multi-time superchannel: it takes the operations inserted at different times as its inputs. -Schematically, +A **process tensor** $\mathcal T_{n:0}$ makes the intervention history an explicit input. For times $t_0<\cdots ρ₃ -``` +where $\mathcal A_k$ is the system operation applied at $t_k$, before evolution to $t_{k+1}$. The process is linear in each operation separately. It therefore describes probabilistic mixtures of interventions as well as individual choices. -!!! note "Operations, channels, and instruments" - - A **quantum operation** is a completely positive map that may decrease the trace of the density matrix. It can represent a probabilistic event, such as one outcome of a measurement. - - - A **quantum channel** is a deterministic operation. It is completely positive and trace preserving. - - - An **instrument** is a collection of operations labelled by outcomes, $\mathcal{J}=\{\mathcal{A}^{(x)}\}_x$. Each $\mathcal{A}^{(x)}$ is a completely positive map associated with outcome $x$, and the sum over all outcomes is trace preserving. - - In process-tensor language, an instrument does not mean only a measurement. It can represent preparation, evolution, measurement, trace-out, or a custom operation placed into a time slot of the process tensor. - -A process tensor over times $t_0,\ldots,t_n$ can therefore be read as a higher-order object that accepts a sequence of operations: +For deterministic channels at every slot and a normalised initial joint state, the final state has unit trace. If an operation selects a measurement outcome, the output is generally an **unnormalised conditional state**. For an outcome sequence $\mathbf x=(x_0,\ldots,x_{n-1})$, ```math -\mathcal{T}_{n:0} -: -( -\mathcal{A}_{n-1}, -\ldots, -\mathcal{A}_0 -) -\mapsto -\rho_n. +\widetilde\rho_n(\mathbf x) +=\mathcal T_{n:0} +[\mathcal A_{n-1}^{(x_{n-1})},\ldots,\mathcal A_0^{(x_0)}], +\qquad +p(\mathbf x)=\operatorname{Tr}\widetilde\rho_n(\mathbf x). ``` -Equivalently, +If a final measurement has effect $E_y$, its joint probability with the earlier outcomes is $\operatorname{Tr}[E_y\widetilde\rho_n(\mathbf x)]$. When $p(\mathbf x)>0$, dividing by it gives the final state conditioned on the earlier outcomes. Keeping the output unnormalised during contraction preserves linearity and retains the probability information. -```math -\rho_n -= -\mathcal{T}_{n:0} -[ -\mathcal{A}_{n-1}, -\ldots, -\mathcal{A}_0 -]. -``` +## From system–environment dynamics to a reusable process -If the operations are selected from instruments with outcomes $x_0,\ldots,x_{n-1}$, and the final output is closed, the process tensor gives a joint probability: +Let $\rho_{SE}(t_0)$ be the initial joint state. Between interventions, the joint system evolves through maps $\mathcal U_{k+1:k}$. For unitary microscopic dynamics, ```math -p(x_{n-1},\ldots,x_0) -= -\mathcal{T}_{n:0} -[ -\mathcal{A}_{n-1}^{(x_{n-1})}, -\ldots, -\mathcal{A}_0^{(x_0)} -]. +\mathcal U_{k+1:k}(X)=U_{k+1:k}XU_{k+1:k}^\dagger. ``` -In a Choi-state representation, the process tensor is often denoted by $\Upsilon_{n:0}$. The corresponding generalised Born rule has the schematic form - -```math -p(\mathbf{x}) -= -\operatorname{Tr} -\left[ -\Upsilon_{n:0} -\left( -\mathsf{A}_{n-1}^{(x_{n-1})} -\otimes -\cdots -\otimes -\mathsf{A}_0^{(x_0)} -\right) -\right], -``` - -up to transpose and index-ordering conventions fixed by the chosen Choi or Liouville representation. Here $\mathsf{A}_k^{(x_k)}$ denotes the Choi or Liouville representation of the operation inserted at time $t_k$. - -!!! warning "Conventions matter" - Process tensors can be written using Choi states, Liouville vectors, quantum combs, or diagrammatic tensor notation. These representations describe the same operational object, but transpose and index-ordering conventions differ. In this package, the Liouville-space convention is described in [Quantum States and Liouville Space](liouville_space.md). - -## Non-Markovian dynamics and process tensors - -If an open quantum system is effectively memoryless, one often describes its reduced dynamics by a chain of state-to-state maps: +An intervention acts on the accessible system, while the environment is untouched directly. The final reduced state is ```math -\rho_{k+1} -= -\Phi_{k+1:k} -\left[ -\rho_k +\widetilde\rho_n +=\operatorname{Tr}_E\!\left[ +\mathcal U_{n:n-1}\circ(\mathcal A_{n-1}\otimes\mathrm{id}_E) +\circ\cdots\circ +\mathcal U_{1:0}\circ(\mathcal A_0\otimes\mathrm{id}_E) +\bigl(\rho_{SE}(t_0)\bigr) \right]. ``` -This is the Markovian intuition: the future depends only on the present reduced state. - -But for a system coupled to a bath, the system and bath can become correlated. If the bath retains information about previous system states or previous interventions, then the transformation from $t_k$ to $t_{k+1}$ is not determined only by $\rho_k$. +Composition acts from right to left, and the diagrams on this page run in that same direction: later times are on the left. The process tensor packages the fixed initial joint state, intermediate propagators, and environmental trace into an object with open slots for the operations. The same construction also applies when the joint propagators are CPTP maps rather than unitary maps. -In such a case, the later state may depend on the full history of operations: - -```math -\rho_n -= -\mathcal{T}_{n:0} -[ -\mathcal{A}_{n-1}, -\ldots, -\mathcal{A}_0 -]. +```@raw html +
+ Joint system–environment evolution with interventions, and the same network after the interventions are removed. + Joint system–environment evolution with interventions, and the same network after the interventions are removed. +
``` -This is the operational meaning of memory: later statistics can depend on what was done to the system at earlier times. +The propagators, the initial joint state, and the environmental trace stay in the process. The open slots are where an experiment attaches its operations. Those slots are not identity operations already inserted. -```text -Markovian picture: +### Initial conditions and the package workflow -ρ₀ ── Φ₁ ──> ρ₁ ── Φ₂ ──> ρ₂ ── Φ₃ ──> ρ₃ +The general definition above allows a correlated initial state $\rho_{SE}(t_0)$. The construction workflow described here instead supplies an environmental state during construction and a system preparation during evaluation. It corresponds to an initially factorised preparation $\rho_0\otimes\rho_E$; arbitrary initial system–environment correlations are not supplied merely by changing $\rho_0$. +The resulting network exposes the system input of the first interval. Attaching a preparation there specifies the experiment's initial system state. This is a boundary convention different from fixing a joint initial state and treating $\mathcal A_0$ as an operation on that state; the underlying multi-time idea is the same. -Non-Markovian process picture: +### What stays fixed when the process is reused? -A₀ A₁ A₂ -│ │ │ -▼ ▼ ▼ -[slot 0]─m₁─[slot 1]─m₂─[slot 2]─m₃─> output +The environment, its initial state, the system–environment couplings, and the chosen time grid determine the constructed process. Operations at the exposed slots can subsequently change without repeating that construction. -memory links carry history -``` - -!!! note "Memory as operational dependence" - Non-Markovianity is not only the statement that “the bath has memory.” In the process-tensor framework, memory means that future states or probabilities can depend on the sequence of previous interventions. +The package separates free-system propagation from environmental propagation internally during construction, then embeds the free-system maps into the temporal cores. That separation does not remove system dynamics from the completed `ProcessTensor`. An extra control operation inserted at a slot acts in addition to the propagation already encoded there. Changing a Hamiltonian or coupling embedded in the cores generally requires rebuilding the process. -The most common physical origin of a process tensor is a system $S$ interacting with a bath $B$. Suppose the joint system-bath state at the initial time is $\rho_{SB}(t_0)$. Between interventions, the joint state evolves under system-bath propagators. In superoperator notation, +!!! info "In the package" + ```julia + pt = build_process_tensor( + system; + environment=environment, + dt=dt, + nsteps=nsteps, + method=Dense(), + ) + ``` -```math -\mathcal{U}_{k+1:k}(\cdot) -= -U_{k+1:k} -(\cdot) -U_{k+1:k}^{\dagger}. -``` + See [Construct a process tensor](@ref) for the system, bath, and coupling definitions. Once constructed, the same `pt` can be evaluated with different preparations and intervention sequences. -At each time $t_k$, the user may apply an operation $\mathcal{A}_k$ to the system. The bath is not directly controlled, so the operation acts as $\mathcal{A}_k\otimes\mathcal{I}_B$ on the joint state. +## Anatomy of the temporal tensor network -The final reduced system state is +The package represents the process as a matrix-product network along time. For $n$ intervals, a core $Q^{[k]}$ represents the interval from $t_k$ to $t_{k+1}$, with $k=0,\ldots,n-1$. It carries a system input $i_k$, a system output $o_k$, and the neighbouring temporal bonds: ```math -\rho_S(t_n) -= -\operatorname{Tr}_B -\left[ -\mathcal{U}_{n:n-1} -\left( -\mathcal{A}_{n-1}\otimes\mathcal{I}_B -\right) -\cdots -\mathcal{U}_{1:0} -\left( -\mathcal{A}_{0}\otimes\mathcal{I}_B -\right) -\rho_{SB}(t_0) -\right]. +Q^{[k]}_{\chi_k\chi_{k+1}}{}^{o_k}{}_{i_k}. ``` -This equation is the key construction. The process tensor is what remains when all the system-bath evolution and bath trace are packaged into a reusable object with open slots for the system operations $\mathcal{A}_k$. - -```text -System-bath origin of a process tensor: - -ρ_SB(t₀) - │ - ▼ -[A₀ on S] ── U₁:₀ ── [A₁ on S] ── U₂:₁ ── [A₂ on S] ── U₃:₂ - │ - ▼ - Tr_B at end - -The bath is not directly controlled, but it carries memory between time steps. -``` +The system legs are Liouville indices. For a system with Hilbert dimension $d_S$, each has dimension $d_S^2$. The environmental initial state and final trace are absorbed into the boundary cores, leaving trivial external bond dimensions. -A Markovian process is a special case of the process-tensor framework. In this case, the multi-time process factorises into independent step-to-step channels: +A preparation occupies $i_0$. For $k\geq1$, an intervention connects the output of the preceding interval to the input of the next: ```math -\mathcal{T}_{n:0}^{\mathrm{Markov}} -\sim -\Phi_{n:n-1} -\otimes -\Phi_{n-1:n-2} -\otimes -\cdots -\otimes -\Phi_{1:0}. -``` - -There is no non-trivial memory link carrying information between interventions. In a non-Markovian process, this factorisation fails. The memory links are non-trivial, and the effect of an operation at one time can influence later reduced dynamics. - -```text -Markovian temporal structure: - -[Φ₁] [Φ₂] [Φ₃] - -no memory links between interventions - - -Non-Markovian temporal structure: - -[PT₀]──m₁──[PT₁]──m₂──[PT₂]──m₃──[PT₃] - -memory links carry temporal correlations -``` - -!!! note "Memory and bond dimension" - In a tensor-network representation, memory is reflected in temporal correlations and memory bond dimensions. A compact memory bond does not mean there is no memory; it means the memory is efficiently compressible. - -## Anatomy of a process tensor - -In tensor-network form, a process tensor has an input and output leg at each time slot. - -The input leg represents the system state entering that time slot. The output leg represents the system state leaving that time slot. The operation inserted by the user connects the input-output structure associated with that time. - -A memory link connects neighbouring process-tensor cores. It stores compressed information about the bath influence and the history of previous interactions. - -```text -One process-tensor core: - - output leg - │ -left memory ── [ PTₖ ] ── right memory - │ - input leg +\mathcal A_k:\ o_{k-1}\longrightarrow i_k. ``` -A multi-time process tensor becomes a chain in time: - -```text -Process tensor as a temporal tensor network: +A final effect closes $o_{n-1}$, the system output at $t_n$. Thus the label of a core identifies an interval, whereas the intervention lies at its boundary. Core $Q^{[0]}$ is the first core even though Julia's positional indexing starts at one. -out₀ out₁ out₂ out₃ - │ │ │ │ -[PT₀]--m₁--[PT₁]--m₂--[PT₂]--m₃--[PT₃] - │ │ │ │ -in₀ in₁ in₂ in₃ +```@raw html +
+ Three process cores and the intervention that joins one output to the next input. Time runs right to left. + Three process cores and the intervention that joins one output to the next input. Time runs right to left. +
``` -The memory bond dimensions determine how much temporal correlation is stored. A small memory bond corresponds to a compact process. A large memory bond means the bath influence or intervention history is harder to compress. - -!!! note "Temporal tensor network" - An MPS stores spatial correlations along a chain. A process tensor stores temporal correlations along a sequence of time steps. This is why process tensors can often be represented as matrix-product objects in time. +Core $Q^{[k]}$ owns the interval $[t_k,t_{k+1}]$. The intervention $\mathcal A_k$ joins $o_{k-1}$ to $i_k$. The input leg has prime level 1 and the output leg has prime level 0. Those primes label the Liouville ports. They are not the ket and bra indices already fused inside each leg. Julia stores $Q^{[k]}$ at position $k+1$. Use `input_sites` and `output_sites` when attaching custom tensors. !!! info "In the package" ```julia - pt = build_process_tensor(system; environment, dt, nsteps) + input_sites(pt) + output_sites(pt) + linkdims(pt) ``` - See [Building the process tensor](@ref building-the-process-tensor) in [Single-Mode Process Tensor](@ref), - and the [Spin-bath process tensor](../examples/spin_bath_process_tensor.md) example for single- - and multimode constructions with companion plotting scripts. - -### Instruments in process-tensor slots - -An instrument is inserted into the open legs of a process tensor. It represents the operation performed on the system at a given time. - -Common examples include: - -| Role | Meaning | -| ---------------------- | ----------------------------------------------------------- | -| State preparation | Prepare the system in a chosen initial state | -| Identity operation | Let the system pass through the slot without intervention | -| System propagation | Insert a chosen system map or unitary | -| Observable measurement | Contract with an observable to compute an expectation value | -| Trace-out | Close a leg with the identity effect | -| Open output | Leave a leg uncontracted to obtain a reduced object | -| Left/right insertion | Insert operator actions such as $A\rho$ or $\rho A$ | -| Custom operation | Insert a user-defined two-leg map | - -```text -Instrument contraction: - - A₀ A₁ A₂ - │ │ │ -out₀ │ out₁ │ out₂ │ - │ │ │ │ │ │ -[PT₀]--m₁--[PT₁]--m₂--[PT₂]--m₃--[PT₃] - │ │ │ -in₀ in₁ in₂ -``` + These expose the temporal system legs and bond dimensions. See [Construct a process tensor](@ref) for an inspection of the constructed network and its time labels. -The process tensor itself stores the environment-mediated multi-time structure. The instruments specify what the user does to the system. +### Dense and ACE construction -!!! warning "Instrument is broader than measurement" - A measurement is one kind of instrument. But in a process tensor calculation, an instrument can also mean preparation, propagation, trace-out, observable insertion, or leaving a leg open. +The temporal bonds arise from environmental degrees of freedom connecting successive intervals. In the uncompressed dense construction, an environment of Hilbert dimension $d_E$ carries an internal Liouville space of dimension $d_E^2$. -!!! info "In the package" - ```julia - seq = InstrumentSeq(default=identity_operation(), nsteps=pt.nsteps) - add!(seq, state_preparation(ρ0), 0) - add!(seq, identity_operation(), 1) - ``` - - See [Instrument schedules](@ref instrument-schedules) in [Single-Mode Process Tensor](@ref). +| Method | Construction idea | Role of temporal bonds | +|:--|:--|:--| +| `Dense()` | Build the small joint environmental influence directly | Retain the full environmental Liouville space on internal links | +| `ACE()` | Incorporate independent bath modes and compress their combined influence | Retain a smaller effective space according to the chosen compression settings | -### Evaluating a process tensor +The ACE setting assumes independent environmental modes with a product initial bath state; their interaction through the accessible system still produces a combined influence on it. Compression allows larger environments to be represented when the resulting temporal structure is sufficiently compressible. -Evaluating a process tensor means contracting it with an instrument sequence. +Dense construction avoids ACE truncation, but does not eliminate other numerical approximations. Finite bath truncations, propagator approximations, and the splitting of free-system and environmental dynamics must still be assessed. In particular, the system and environmental generators need not commute. The construction tutorial explains the propagation and compression settings; the SVD principles are introduced in [Tensor Networks in Physics](tensor_networks.md). -If all relevant legs are closed, the result is a scalar. This scalar may be a probability, expectation value, or correlation value depending on the inserted instruments. +## Interventions and process evaluation -```text -All legs closed: - -[A₀] [A₁] [A₂] [A₃] - │ │ │ │ -[PT₀]--m--[PT₁]--m--[PT₂]--m--[PT₃] - │ │ │ │ -closed closed closed closed - -Result: scalar -``` +A physical **instrument** is an outcome-labelled family of completely positive, trace-nonincreasing maps whose sum is trace preserving. A channel is the deterministic single-outcome case. -If one output is left open, the result is a reduced system object at that time. +The package's instrument interface also provides more general contraction objects: observable insertions, one-sided operator actions, and markers that leave legs open. These support response calculations, but they are not all physical measurement operations. The interpretation of the result depends on what was attached. -```text -One output left open: +| Attachment | Network role | Interpretation | +|:--|:--|:--| +| State preparation | Supplies an input leg | Initial or replacement state | +| Channel or selected outcome map | Joins an earlier output to the next input | Control or conditional state transformation | +| Identity effect | Closes an output leg | Trace over that output | +| Positive measurement effect | Closes an output leg | Outcome probability for a physical state | +| Observable insertion | Closes an output or enters a response contraction | Expectation or correlation, according to the schedule | +| Left/right operator action | Joins adjacent interval legs | Linear insertion such as $\rho\mapsto A\rho B$ | +| Open-leg marker | Leaves an index unresolved | State-like output or a more general tensor | -[A₀] [A₁] open - │ │ │ -[PT₀]--m--[PT₁]--m--[PT₂] - │ │ │ -closed closed input/trace structure - -Result: reduced state or Liouville-space object -``` - -Reduced time evolution is a special process-tensor contraction pattern. If the first instrument prepares an initial state, and later slots are filled with identity, trace-out, or propagation instruments, the process tensor returns the reduced system trajectory. - -```text -Reduced evolution picture: - -ρ₀ ── [PT₀] ──> ρ₁ ── [PT₁] ──> ρ₂ ── [PT₂] ──> ρ₃ -``` - -In package language, this is the difference between the general and convenience workflows: +An identity channel passes the system between intervals; it is not a trace-out operation. Likewise, leaving an output open retains its information, while tracing it closes that leg. !!! info "In the package" ```julia - # General process contraction - evaluate_process(pt, seq) - - # Common reduced-evolution workflow - evolve(pt, ρ0) + seq = InstrumentSeq(default=identity_operation(), nsteps=pt.nsteps) + add!(seq, state_preparation(ρ0), 0) ``` - See [Instrument schedules](@ref instrument-schedules) and [Evolving reduced states](@ref evolving-reduced-states) in [Single-Mode Process Tensor](@ref). - -The second is not a different physical theory. It is a frequently used contraction of the same process tensor. + This starts a schedule with a preparation and identity connections. Complete its terminal closure or open-output choice as shown in [Process tensor instruments](@ref). -### Multi-time observables and correlations +### What does a contraction return? -Process tensors are useful for multi-time quantities because they keep intervention slots open. For example, a two-time quantity may be computed by inserting one operation at $t_1$ and another at $t_2$. +A physical sequence with one final system output left open returns a state-like object. Its trace is one for a deterministic experiment, or the outcome-sequence probability for a selected branch. Closing that output with the identity gives the trace; closing it with an effect gives the corresponding outcome probability. -```text -Two-time operator insertion: +A fully contracted network is a scalar, but it is not automatically a probability. Observable and operator-insertion contractions can yield expectations or complex correlation values. Multiple unresolved legs instead return a higher-order object whose meaning depends on the remaining inputs and outputs. - B insertion A insertion / trace - │ │ -[PT₀]──m──[PT₁]──m──[PT₂]──m──[PT₃]──m──[PT₄] +```@raw html +
+ The same process tensor with an open output, a measurement effect, and a Hermitian observable. + The same process tensor with an open output, a measurement effect, and a Hermitian observable. +
``` -The precise instrument depends on what correlation is desired. A projective measurement, an observable insertion, and a left/right operator action are not the same operation. +The cores and the earlier operations stay fixed. An open final leg returns an unnormalised output state. Closing that leg with an effect returns an outcome probability, and closing it with a Hermitian observable returns the corresponding contraction. A conditional expectation divides that contraction by the probability of the selected outcomes. !!! info "In the package" ```julia - seq = two_time_correlation_seq(pt, (O, t2), (O, t1); rho0=ρ0) + result = evaluate_process(pt, seq) # A completed intervention schedule + trajectory = evolve(pt, ρ0) # Reduced-trajectory convenience workflow ``` - See the [Multi-time correlations](../examples/multitime_correlations.md) example and [Two-time correlation preview](@ref two-time-correlation-preview) in [Single-Mode Process Tensor](@ref). + Both interrogate the same process. See [Process tensor instruments](@ref) for complete schedules and the returned data. -!!! warning "Sequential measurements versus operator correlations" - A sequential measurement correlation is built from actual measured joint probabilities and includes measurement backaction. An operator correlation such as $\langle A(t_2)B(t_1)\rangle$ is an operator-insertion object. These two quantities agree only under specific assumptions. +Obtaining a trajectory does not turn the cores into independent reduced-state propagators. The contraction retains the temporal bonds carrying environmental influence. - This distinction matters because process tensors are operational. They respond to the actual instruments inserted into the time slots. +### Correlations and extended experiments -### Physical properties +A sequential measurement correlation is computed from joint outcome probabilities and includes measurement backaction. An operator correlation such as $\langle A(t_2)B(t_1)\rangle$ uses the appropriate operator insertions, which need not be completely positive maps. The two are generally different experiments or response quantities. -A physical process tensor is not an arbitrary tensor. It must satisfy structural conditions inherited from quantum mechanics and causality. +Testers extend the intervention setting by allowing an ancillary system to persist between operations. They probe the process through a larger controlled experiment. The same distinction remains: the process describes the fixed underlying evolution, while the attached network specifies how it is interrogated. -* **Complete positivity.** If completely positive operations are inserted into the process tensor, the resulting state or probability must be physical. In Choi-like representations, this is reflected by positivity of the process tensor object. +## Memory and physical consistency -* **Normalisation.** If a complete deterministic set of operations is inserted at every time, probabilities must normalise correctly. Equivalently, inserting trace-preserving operations should not create or destroy total probability. +### Operational memory -* **Causality.** Future interventions cannot change earlier observed statistics. Operationally, if all future slots are closed with trace-preserving operations, then the probabilities for earlier outcomes must remain unchanged. +Earlier interventions can affect later statistics even in a Markovian process, simply by changing the present state. Memory concerns dependence that cannot be accounted for by that state alone. -```text -Causality intuition: - -past instruments current statistics future slots closed - A₀, A₁ ? Tr, Tr, Tr - │ │ │ │ │ - [PT₀]──m──[PT₁]──m──[PT₂]──m──[PT₃]──m──[PT₄]──m──[PT₅] - -Future trace-preserving closures cannot signal backwards in time. -``` +A useful diagnostic is a **causal break**: discard the system and prepare the same state $\sigma$ after different earlier histories. The replacement channel acts as -!!! note "Causality is built into physical processes" - A valid process tensor respects the arrow of time. Later choices may affect later results, but they cannot alter statistics that have already been fixed at earlier times. - -### How this appears in `ProcessTensors.jl` - -`ProcessTensors.jl` constructs process tensors from microscopic ingredients such as system sites, bath modes, system-bath couplings, time steps, and propagation rules. - -The package workflow is conceptually: - -```text -define system + bath + coupling - │ - ▼ -build_process_tensor(...) - │ - ▼ -ProcessTensor - │ - ┌──────┴─────────┐ - ▼ ▼ -evolve(...) evaluate_process(...) +```math +\mathcal R_\sigma(X)=\operatorname{Tr}(X)\sigma, +\qquad +(\mathcal R_\sigma\otimes\mathrm{id}_E)(\rho_{SE}) +=\sigma\otimes\rho_E. ``` -The important package objects are: - -| Object or function | Conceptual role | -| ---------------------- | ------------------------------------------------------------------ | -| `ProcessTensor` | Tensor-network representation of a multi-time open quantum process | -| `build_process_tensor` | Constructs the process tensor from system-bath dynamics | -| `InstrumentSeq` | Stores the sequence of instruments inserted at time steps | -| `evaluate_process` | Contracts the process tensor with the instrument sequence | -| `evolve` | Convenience workflow for reduced system evolution | -| `OpenOutput` | Leaves an output leg open to return a reduced object | -| `TraceOut` | Closes a leg by tracing out the corresponding degree of freedom | - -The theory is the same regardless of the implementation details: the process tensor stores the reusable environment influence, and instruments specify what is done to the system. - -!!! note "Package perspective" - In `ProcessTensors.jl`, the process tensor is represented as a tensor network in time. Its open legs are the places where instruments are inserted, and its memory links store compressed temporal correlations. - -!!! warning "Common process-tensor mistakes" - A process tensor is not just a time-evolution operator. It is a multi-time object that accepts operations at several times. - - A process tensor is not only a final state. Depending on which legs are contracted or left open, it can return probabilities, expectation values, reduced states, or Liouville-space objects. - - An instrument is not necessarily a measurement. It can represent preparation, propagation, trace-out, identity, observable insertion, or a custom operation. - - Leaving a leg open is different from tracing it out. An open leg returns an object; a traced-out leg closes part of the tensor network. - - Sequential measurement correlations are not automatically the same as theoretical operator correlations. Measurement backaction matters. - - A small memory bond does not always mean the process is Markovian. It may mean the non-Markovian memory is efficiently compressible. - -!!! related "Related tutorials and examples" - | Topic | Page | - | ----- | ---- | - | Liouville conventions | [Quantum States and Liouville Space](liouville_space.md) | - | Constructing and contracting a process tensor | [Single-Mode Process Tensor](@ref) | - | Spin bath, single- and multimode PT | [Spin-bath process tensor](../examples/spin_bath_process_tensor.md) | - | Sequential two-time correlators | [Multi-time correlations](../examples/multitime_correlations.md) | - | Markovian open dynamics (contrast) | [Dissipative Dynamics](@ref), [Dissipative spin chain](../examples/dissipative_spin.md) | - -## Further reading - -This page is a compact guide to the process-tensor language used in the package. For deeper study, the following references are useful. - -### Process tensors and quantum stochastic processes - -1. [F. A. Pollock, C. Rodríguez-Rosario, T. Frauenheim, M. Paternostro, and K. Modi, “Non-Markovian quantum processes: complete framework and efficient characterisation.”](https://arxiv.org/abs/1512.00589) - - Foundational process-tensor reference. Useful for understanding how multi-time quantum processes with memory can be characterised operationally and represented as many-body quantum states. - -2. [F. A. Pollock, C. Rodríguez-Rosario, T. Frauenheim, M. Paternostro, and K. Modi, “Operational Markov condition for quantum processes.”](https://arxiv.org/abs/1801.09811) - - Important reference for the operational definition of quantum Markovianity and the causal structure of process tensors. - -3. [S. Milz and K. Modi, “Quantum stochastic processes and quantum non-Markovian phenomena.”](https://arxiv.org/abs/2012.01894) - - A pedagogical tutorial connecting classical stochastic processes, quantum combs, process tensors, and non-Markovian quantum phenomena. - -### Higher-order quantum operations and quantum combs - -1. [P. Taranto, S. Milz, M. Murao, M. T. Quintino, and K. Modi, “Higher-Order Quantum Operations.”](https://arxiv.org/abs/2503.09693) - - A broad review article on higher-order quantum operations, including superchannels, combs, process tensors, and their physical applications. - -2. [G. Chiribella, G. M. D'Ariano, and P. Perinotti, “Theoretical framework for quantum networks.”](https://arxiv.org/abs/0904.4483) +The replacement removes system–environment correlations at the break, but the environmental marginal can still depend on the earlier history. If identical subsequent controls produce different future statistics, the environment has retained information that the freshly prepared system state does not contain. When histories are outcome-conditioned, compare their normalised conditional future statistics. The instrument gallery will show this comparison as a diagram. - A foundational reference for quantum combs, link products, and higher-order transformations of quantum operations. +For a Markovian process on the chosen time grid, the response to arbitrary interventions is described by fixed step-to-step channels and an initial state. A particular reduced trajectory, or a particular pair of histories with matching futures, is not enough to establish this property for all interventions. -3. [G. Chiribella, G. M. D'Ariano, and P. Perinotti, “Quantum Circuit Architecture.”](https://arxiv.org/abs/0712.1325) +Temporal bond dimensions describe the storage and contraction cost of the chosen representation. A compact bond can retain nontrivial memory, while an unnecessarily large bond can contain redundant directions. They are not, by themselves, representation-independent memory measures. - Useful background for understanding how networks of quantum operations can be treated as higher-order quantum objects. +### Physical structure and numerical approximations -### Tensor-network process-tensor methods +A physical process obeys four connected requirements: -1. [A. Strathearn, P. Kirton, D. Kilda, J. Keeling, and B. W. Lovett, “Efficient non-Markovian quantum dynamics using time-evolving matrix product operators.”](https://arxiv.org/abs/1711.09641) +- **Multilinearity:** mixing an operation at one slot mixes the corresponding outputs while all other slots remain fixed. +- **Complete positivity:** physical interventions produce positive unnormalised output states, including when the experiment is extended by ancillary systems. +- **Normalisation:** deterministic sequences preserve total probability; summing over all outcomes of complete instruments gives a normalised experiment. +- **Causality:** later choices cannot change earlier marginal outcome statistics when later outcomes are summed over. Postselecting a later outcome can change conditional statistics without violating this requirement. - Introduces the TEMPO approach, which represents environment influence using matrix product operators in time. +These properties follow from the microscopic physical construction. Generic numerical compression does not automatically preserve every constraint exactly. Assess convergence of the quantities used in the experiment as the time step, bath truncation, and compression settings are refined. Arbitrary custom insertion tensors also require their own physical interpretation; a valid process does not turn a nonphysical insertion into a physical operation. -2. [M. Cygorek, M. Cosacchi, A. Vagov, V. M. Axt, B. W. Lovett, J. Keeling, and E. M. Gauger, “Numerically exact open quantum systems simulations for arbitrary environments using automated compression of environments.”](https://arxiv.org/abs/2101.01653) +## Related material and further reading - Introduces automated compression of environments, a process-tensor-based method for constructing and compressing environmental influence. +!!! related "Continue learning" + | Goal | Page | + |:--|:--| + | Construct and inspect a process | [Construct a process tensor](@ref) | + | Reuse it with different interventions | [Process tensor instruments](@ref) | + | Revisit vectorisation and output contractions | [Quantum States and Liouville Space](liouville_space.md) | + | Understand temporal compression | [Tensor Networks in Physics](tensor_networks.md) | + | Explore operator correlations | [Multi-time correlations](../examples/multitime_correlations.md) | -3. [G. E. Fux, D. Kilda, B. W. Lovett, and J. Keeling, “Tensor network simulation of chains of non-Markovian open quantum systems.”](https://arxiv.org/abs/2201.05529) +For deeper background: - Useful follow-up for readers interested in combining process tensors with tensor-network simulations of interacting chains. +- F. A. Pollock et al., [Non-Markovian quantum processes: Complete framework and efficient characterization](https://arxiv.org/abs/1512.00589): the process-tensor framework and its operational interpretation. +- F. A. Pollock et al., [Operational Markov Condition for Quantum Processes](https://arxiv.org/abs/1801.09811): memory and causal-break interventions. +- S. Milz and K. Modi, [Quantum stochastic processes and quantum non-Markovian phenomena](https://arxiv.org/abs/2012.01894): a broad introduction to multi-time quantum processes. +- M. Cygorek et al., [Numerically exact open quantum systems simulations for arbitrary environments using automated compression of environments](https://arxiv.org/abs/2101.01653): the ACE construction and compression of environmental influence. diff --git a/docs/src/theory/tensor_networks.md b/docs/src/theory/tensor_networks.md index 981f77a..49e4681 100644 --- a/docs/src/theory/tensor_networks.md +++ b/docs/src/theory/tensor_networks.md @@ -1,84 +1,112 @@ # Tensor Networks in Physics -Tensor networks are a way of representing very large quantum states and operators by breaking them into smaller tensors connected by shared indices. Instead of storing one exponentially large array, we store a structured network of local tensors. This is especially useful in one-dimensional many-body physics, where physically relevant states often contain much less information than the full Hilbert space allows. +A tensor network represents a large array as a collection of smaller tensors connected by shared indices. Its usefulness comes from the structure of the object being represented: when that structure admits modest internal dimensions, we can store and manipulate the network without constructing the full array. -This page gives only the minimum tensor-network background needed to understand the rest of the `ProcessTensors.jl` documentation. It is not meant to replace a full course or review article on tensor networks. For deeper study, use the references and online resources at the end of this page. +In `ProcessTensors.jl`, tensor networks provide the language for representing quantum states, operators, and processes. For a many-body state, the network often runs along a chain of physical sites. For a process tensor, it runs through time: the network retains the information needed to predict how a system responds to different interventions. -## Why tensor networks appear in many-body physics +This page introduces the notation needed to read these networks, explains what their bond dimensions control, and connects familiar state and operator representations to process tensors. For hands-on examples, see [ITensor Basics](@ref) and [MPS and MPO Basics](@ref). -A chain of $N$ spin-$1/2$ particles has a Hilbert space of dimension $2^N$. A general state is +## Tensors, indices, and contractions + +A tensor is a multidimensional array whose components are labelled by indices (or legs). A vector has one index, a matrix has two, and a general tensor can have any number. Here, the **order** of a tensor means its number of indices; it should not be confused with the rank of a matrix. + +In a tensor diagram, a node represents a tensor and each leg represents an index. The dimension of an index is the number of values it can take. Connecting two legs means summing over their shared index, an operation called a **contraction**. For example, ```math -|\psi\rangle = -\sum_{s_1,\ldots,s_N} -c_{s_1\cdots s_N} -|s_1,\ldots,s_N\rangle, +C_{ik} = \sum_j A_{ij} B_{jk} +``` + +is both a tensor contraction and ordinary matrix multiplication. The uncontracted indices, here $i$ and $k$, are the **open legs** of the resulting network. A network with no open legs evaluates to a scalar. + +Two other useful operations are a tensor product, which introduces no shared index, + +```math +(A \otimes B)_{ijkl} = A_{ij} B_{kl}, +``` + +and a trace, which sums over a pair of compatible indices, + +```math +\operatorname{Tr}(A) = \sum_i A_{ii}. +``` + +```@raw html +
+ Two open legs of A, the contraction of A with B, and the trace of A. + Two open legs of A, the contraction of A with B, and the trace of A. +
``` -where each $s_j \in \{\uparrow,\downarrow\}$. The coefficient tensor $c_{s_1\cdots s_N}$ has $2^N$ entries. For a local dimension $d$, this becomes $d^N$ entries. +An open leg is a free index. Joining two legs contracts that index, and joining both legs of one tensor takes its trace. -Tensor networks ask a practical question: +The order in which contractions are performed can strongly affect their computational cost, even though the exact result is unchanged. -> Can this large coefficient tensor be written as a contraction of smaller tensors? +`ITensors.jl` makes these connections explicit through `Index` objects. Multiplication contracts matching indices rather than relying on their position in an array: -For many physically relevant one-dimensional states, especially low-entanglement states, the answer is yes. The large tensor $c_{s_1\cdots s_N}$ is not stored directly. Instead, it is decomposed into local tensors connected by internal indices. +```julia +using ITensors + +i = Index(2, "i") +j = Index(3, "j") +k = Index(2, "k") + +A = random_itensor(i, j) +B = random_itensor(j, k) +C = A * B # Contracts j; C has open indices i and k. +``` -!!! note "The main idea" - Tensor networks do not remove the exponential size of the full Hilbert space. They give an efficient representation for special but physically important parts of it. +Two independently created indices do not match merely because they have the same dimension and tags. A prime level also distinguishes an index from its unprimed counterpart. Priming changes index labels; it does not, by itself, transpose or complex-conjugate a tensor. When interpreting a quantum tensor, identify which legs represent inputs, outputs, kets, or bras from the stated convention. ## Matrix product states -The most common tensor network in this package is the **matrix product state**, or MPS. An MPS writes the coefficient tensor of a many-body state as +Consider a chain of $N$ sites with local basis states $|s_j\rangle$ and local dimension $d$. A general pure state is ```math -c_{s_1\cdots s_N} -= -\sum_{\alpha_1,\ldots,\alpha_{N-1}} -A^{s_1}_{\alpha_1} -A^{s_2}_{\alpha_1\alpha_2} -A^{s_3}_{\alpha_2\alpha_3} -\cdots -A^{s_N}_{\alpha_{N-1}}. +|\psi\rangle = \sum_{s_1,\ldots,s_N} + c_{s_1\cdots s_N}|s_1\cdots s_N\rangle. ``` -Equivalently, +Storing its coefficients directly requires $d^N$ complex numbers. A **matrix product state** (MPS) factors these coefficients into a chain of local tensors: ```math -|\psi\rangle = -\sum_{s_1,\ldots,s_N} +c_{s_1\cdots s_N} += \sum_{\alpha_1,\ldots,\alpha_{N-1}} -A^{s_1}_{\alpha_1} -A^{s_2}_{\alpha_1\alpha_2} -\cdots -A^{s_N}_{\alpha_{N-1}} -|s_1,\ldots,s_N\rangle. + A^{[1]s_1}_{\alpha_0\alpha_1} + A^{[2]s_2}_{\alpha_1\alpha_2} + \cdots + A^{[N]s_N}_{\alpha_{N-1}\alpha_N}. ``` -Each site has a physical index $s_j$, and neighbouring sites are connected by internal indices $\alpha_j$. These internal indices are often called **bond indices**, **link indices**, or **virtual indices**. - -```text -physical legs: s₁ s₂ s₃ sₙ - | | | | -MPS: [A] -- [A] -- [A] -- ... -- [A] - α₁ α₂ αₙ₋₁ +```@raw html +
+ A physical index, the virtual bond between neighbouring site tensors, a three-site MPS, and a three-site MPO. + A physical index, the virtual bond between neighbouring site tensors, a three-site MPS, and a three-site MPO. +
``` -The maximum size of the internal indices is called the **bond dimension**, often denoted by $\chi$. +A physical index labels a local basis state. A virtual bond is the internal index that chains the site tensors. Each square is one solid colour, stepping along the chain; an MPO uses a different palette from an MPS. + +For open boundaries, $\alpha_0$ and $\alpha_N$ each take a single value. Every local tensor has a physical index $s_j$ and up to two nontrivial internal indices. These internal indices are called **bond indices**, with dimensions $\chi_j$. -A product state has bond dimension $\chi=1$. More entangled states require larger $\chi$. In an exact MPS representation, $\chi$ may still grow exponentially with system size. The useful regime is when the state can be accurately represented with moderate $\chi$. +If all bond dimensions are bounded by $\chi$, the representation stores at most $O(Nd\chi^2)$ entries. This is useful when $\chi$ remains manageable; an arbitrary state can still require bond dimensions that grow exponentially with system size. In a simulation, that bond dimension is one of the main quantities to monitor. If it grows quickly across a cut, storage and contraction become expensive, and a truncated bond is an approximation to the state. -Across a bipartition of the chain, a pure state can be written in Schmidt form as +The connection to entanglement follows from a Schmidt decomposition across a cut between sites $j$ and $j+1$: ```math -|\psi\rangle -= -\sum_{\alpha=1}^{r} -\lambda_\alpha -|\alpha_L\rangle -|\alpha_R\rangle. +|\psi\rangle = \sum_{a=1}^{r_j} + \lambda_a |L_a\rangle |R_a\rangle, +\qquad \sum_a \lambda_a^2 = 1. ``` -The Schmidt rank $r$ tells us how many independent left-right components are needed across that cut. The MPS bond dimension across that cut must be large enough to store these components. This is why bond dimension is closely tied to entanglement. +The smallest exact MPS bond dimension at that cut is the Schmidt rank $r_j$. A stored representation may use a larger bond. For a normalized pure state, the bipartite entanglement entropy satisfies + +```math +S_j = -\sum_a \lambda_a^2 \log(\lambda_a^2) +\leq \log r_j \leq \log\chi_j. +``` + +This explains why states with limited entanglement are natural candidates for efficient MPS representations. !!! info "In the package" ```julia @@ -88,262 +116,146 @@ The Schmidt rank $r$ tells us how many independent left-right components are nee See [ITensor Basics](@ref) for index conventions and [MPS and MPO Basics](@ref) for `siteinds` and MPS construction. -!!! tip "Practical takeaway" - In MPS simulations, the bond dimension is one of the main quantities to monitor. If the required bond dimension grows too quickly, the simulation becomes expensive or inaccurate. +An overlap $\langle\phi|\psi\rangle$ is obtained by contracting the physical legs of the two states and all internal bonds. The virtual bonds connect tensors within each state's own chain; they need not have matching dimensions or index identities between the two states. In an ITensor calculation, index labels must distinguish those separate virtual chains so that only the intended contractions occur. ## Matrix product operators -A **matrix product operator**, or MPO, is the operator analogue of an MPS. Instead of representing a many-body state, it represents a many-body operator such as a Hamiltonian, a time-evolution operator, a density matrix, or a Liouvillian superoperator. - -A many-body operator has matrix elements +An operator has both an input and an output index at each site: ```math -O_{s_1'\cdots s_N', s_1\cdots s_N}. +\hat O = \sum_{\boldsymbol r,\boldsymbol s} + O_{\boldsymbol r,\boldsymbol s} + |r_1\cdots r_N\rangle\langle s_1\cdots s_N|. ``` -For a chain with local dimension $d$, the full operator contains $d^{2N}$ matrix elements. This is already much larger than the $d^N$ coefficients needed for a pure state vector. - -An MPO decomposes these operator coefficients into local tensors connected by bond indices: +A **matrix product operator** (MPO) factors its components as ```math -O +O_{\boldsymbol r,\boldsymbol s} = -\sum_{s_1,\ldots,s_N,s_1',\ldots,s_N'} \sum_{\beta_1,\ldots,\beta_{N-1}} -W^{s_1's_1}_{\beta_1} -W^{s_2's_2}_{\beta_1\beta_2} -\cdots -W^{s_N's_N}_{\beta_{N-1}} -|s_1'\cdots s_N'\rangle -\langle s_1\cdots s_N|. -``` - -```text -output legs: s₁' s₂' s₃' sₙ' - | | | | -MPO: [W] -- [W] -- [W] -- ... -- [W] - | | | | -input legs: s₁ s₂ s₃ sₙ + W^{[1]r_1s_1}_{\beta_0\beta_1} + W^{[2]r_2s_2}_{\beta_1\beta_2} + \cdots + W^{[N]r_Ns_N}_{\beta_{N-1}\beta_N}, ``` -In tensor-network language, an MPO has two physical legs per site: one input leg and one output leg. +again with boundary bond dimensions equal to one. Each local tensor has an output leg $r_j$, an input leg $s_j$, and its bond legs. For equal input and output dimensions $d$ and bond dimensions bounded by $\chi$, storage scales as $O(Nd^2\chi^2)$. -In `ProcessTensors.jl`, MPOs appear in several places: - -* Hamiltonians are represented as MPOs. -* Density matrices can be represented as operator-like tensor networks in Hilbert space. -* Liouvillian superoperators are represented as MPOs in Liouville space. -* Process tensors are stored as tensor networks with physical input/output legs at each timestep, and memory links. +Hamiltonians, density operators, and other observables can all be represented as MPOs. Their interpretation differs, but the network operations follow the same index rules. For example, evaluating $\langle\psi|\hat O|\psi\rangle$ contracts the operator's input legs with the ket and its output legs with the bra. !!! note "MPS versus MPO" - An MPS represents a vector-like object. An MPO represents a map-like object. Density matrices sit between these viewpoints: in Hilbert space they are operators, while in Liouville space they can be treated as vectorised states. + An MPS represents a vector-like object. An MPO represents a map-like object. A density operator sits between these viewpoints: in Hilbert space it is an operator, while in Liouville space the same object can be treated as a vectorised state. !!! info "In the package" ```julia H_mpo = MPO(H, sites) + expect_O = real(inner(ψ', O_mpo, ψ)) ``` - See [MPS and MPO Basics](@ref) for `OpSum` Hamiltonians and MPO assembly. - -## Contractions + `inner(ψ', O_mpo, ψ)` is the bra–operator–ket contraction. See [MPS and MPO Basics](@ref) for `OpSum` Hamiltonians, MPO assembly, and expectation values. -A tensor network becomes a number, state, operator, or reduced object by **contracting** shared indices. Contracting an index means summing over all values of that index. - -For two tensors $A$ and $B$ sharing an index $\alpha$, - -```math -C_{ij} -= -\sum_{\alpha} -A_{i\alpha}B_{\alpha j}. -``` - -This is just matrix multiplication written as an index contraction. Tensor networks generalise this idea to many indices and many tensors. - -The inner product $\langle\phi|\psi\rangle$ is obtained by contracting every physical and bond index between the bra MPS and ket MPS. - -```text -bra: [B†] -- [B†] -- [B†] -- ... -- [B†] - | | | | -ket: [A] -- [A] -- [A] -- ... -- [A] -``` - -In equations, - -```math -\langle\phi|\psi\rangle -= -\sum_{s_1,\ldots,s_N} -\overline{\phi}_{s_1\cdots s_N} -\psi_{s_1\cdots s_N}. -``` - -Expectation values are contractions too: - -```math -\langle O\rangle -= -\langle \psi|O|\psi\rangle. -``` - -In diagrammatic language, this means placing the MPO between the bra and ket MPS and contracting all matching legs. - -```text -bra: [A†] -- [A†] -- [A†] - | | | -operator: [W] -- [W] -- [W] - | | | -ket: [A] -- [A] -- [A] -``` - -This contraction viewpoint is important because `ProcessTensors.jl` uses the same idea for process tensors: a process tensor is evaluated by contracting it with a sequence of instruments. +A density-operator MPO can also be viewed as an MPS in **Liouville space** by grouping each local ket–bra pair into one index of dimension $d^2$. This local reshaping leaves the existing bond dimensions unchanged. Any subsequent compression is a separate operation. A superoperator acting on such a representation has a Liouville-space input and output, each of local dimension $d^2$. !!! info "In the package" ```julia - expect_O = real(inner(ψ', O_mpo, ψ)) + ρ = to_dm(ψ) # Hilbert density MPO + sites_L = liouv_sites(sites) + ρL = to_liouville(ρ; sites=sites_L) # Liouville MPS ``` - See [MPS and MPO Basics](@ref) for expectation values and energy calculations. + See [MPS and MPO Basics](@ref) for density-matrix MPOs and [Liouville-Space Basics](@ref) for the vectorisation. The index convention is set out on the [Liouville Space](liouville_space.md) theory page. -## Truncation and approximation +The singular values across a cut of a vectorized density operator describe its operator-space structure. They should not be interpreted as the pure-state entanglement spectrum of the physical mixed state. The ordering and meaning of the fused indices are covered in [Liouville Space](liouville_space.md). -Tensor-network simulations are powerful because they can compress information. This compression usually happens through singular-value decompositions. +## Bond dimensions and compression -Suppose a tensor is reshaped into a matrix $M$ across some chosen bipartition. Its singular-value decomposition is +Factorization alone does not guarantee a smaller representation. Compression becomes possible when some directions across a bond contribute little to the tensor being represented. -```math -M = U S V^\dagger, -``` - -where $S$ contains non-negative singular values. If many singular values are very small, one can approximate $M$ by keeping only the largest ones: +The basic tool is the singular value decomposition (SVD). After grouping a tensor's indices into a left set and a right set, we reshape it into a matrix and write ```math -M -\approx -U_{\mathrm{kept}} -S_{\mathrm{kept}} -V_{\mathrm{kept}}^\dagger. +M = U\Sigma V^\dagger, +\qquad \sigma_1 \geq \sigma_2 \geq \cdots \geq 0. ``` -For an MPS, this operation is closely related to truncating the Schmidt decomposition +Keeping the largest $r$ singular values gives a best rank-$r$ approximation in the Frobenius norm, ```math -|\psi\rangle = -\sum_{\alpha} -\lambda_\alpha -|\alpha_L\rangle |\alpha_R\rangle. +M_r = U_{[:,1:r]}\Sigma_{1:r,1:r}V^\dagger_{[1:r,:]}, +\qquad +\|M-M_r\|_F^2 = \sum_{a>r}\sigma_a^2. ``` -Keeping only the largest $\lambda_\alpha$ gives an approximate state with smaller bond dimension. +In a tensor network, the retained singular-value index becomes a bond. Its dimension sets how much information passes across that partition. The [ITensors.jl documentation](https://docs.itensor.org/ITensors/stable/) shows this decomposition on named indices: how a tensor is split, where the singular values appear, and how the factors contract back to the original object. -This is the basic compression step behind many tensor-network algorithms. In practice, simulations usually control truncation using parameters such as a maximum bond dimension and a singular-value cutoff. +An MPS has gauge freedom: an invertible matrix can be inserted on one side of a bond and its inverse on the other without changing the represented state. **Canonical forms** use this freedom to make the tensors on either side of a chosen bond orthonormal. In that setting, the singular values at the bond give the Schmidt coefficients of the full state, rather than just the singular values of an arbitrarily chosen local tensor. -!!! info "In the package" - ```julia - using ITensors.Ops: Trotter - ψ = tebd(ψ, H, dt, T; alg=Trotter{2}(), maxdim=32, cutoff=1e-10) - ``` - - See [Unitary Dynamics](@ref) for TEBD evolution and how `maxdim` / `cutoff` control truncation error. +Two common compression controls are a singular-value cutoff and a maximum bond dimension. Their precise meaning depends on the algorithm. In particular, a cutoff based on discarded weight is different from a threshold relative to the largest singular value. The ACE construction described in the accompanying paper uses the relative criterion -!!! tip "Learn the SVD machinery of ITensors" - The official [ITensors](https://docs.itensor.org/ITensors/stable/) documentation has examples of performing SVDs on matrices and higher-order tensors using named indices. This is a good place to learn how tensors are split, how singular values appear, and how contractions rebuild the original object. +```math +\sigma_a > \epsilon\sigma_1. +``` -## Tensor networks in `ProcessTensors.jl` +A maximum bond dimension can impose an additional restriction. Check the relevant constructor or contraction routine before interpreting its tolerance numerically. -This package builds on the `ITensors.jl` and `ITensorMPS.jl` ecosystem. If you already know how to use `siteinds`, `MPS`, `MPO`, `OpSum`, `apply`, `expect`, or `tdvp`, then much of the syntax will feel familiar. +The discarded singular values quantify the error of an individual SVD truncation in the norm above. They do not, by themselves, bound the final error of every observable after many truncations and contractions. For a simulation, assess convergence by tightening the compression settings and comparing the quantities you intend to use. Generic SVD compression also does not automatically preserve positivity or all physical constraints of a density operator or process tensor. !!! info "In the package" ```julia - ρ = to_dm(ψ) # Hilbert density MPO - sites_L = liouv_sites(sites) - ρL = to_liouville(ρ; sites=sites_L) # Liouville MPS + ψ = tebd(ψ, H, dt, T; alg=Trotter{2}(), maxdim=32, cutoff=1e-10) ``` - See [MPS and MPO Basics](@ref) for density-matrix MPOs and [Liouville-Space Basics](@ref) for Liouville vectorisation. - -The package adds a layer of open-system structure on top of that familiar tensor-network language. - -In particular, later pages will explain how `ProcessTensors.jl` uses tensor networks for: - -* Hilbert-space dynamics, -* density matrices, -* Liouville-space vectorisation, -* Liouvillian MPOs, -* process tensor construction, -* instruments and interventions, -* reduced dynamics, -* multi-time observables. - -!!! note "Why borrow tensor networks for open quantum simulation?" - Open quantum systems are usually described by density matrices rather than pure wavefunctions. For a chain with local dimension $d$, a pure state has $d^N$ amplitudes, while a density matrix has $d^{2N}$ coefficients. This squared scaling makes exact density-matrix simulation much harder than closed-system wavefunction simulation. + For this evolution routine, `maxdim` caps the bond and `cutoff` is a discarded-weight tolerance. That is a different control from the relative singular-value threshold $\sigma_a > \epsilon\sigma_1$ used by ACE. See [Unitary Dynamics](@ref) for how these arguments enter a TEBD step. - Tensor networks provide a compression strategy. Instead of storing the full density matrix or Liouvillian, one can represent them as matrix product density operators, Liouville-space MPS/MPOs, or related tensor-network objects. This does not make every open-system problem easy, but it gives a controlled language for approximating mixed states, dissipative evolution, and memory effects using bond dimensions and truncation cutoffs. +## From spatial networks to temporal processes - In `ProcessTensors.jl`, this idea appears in two ways: density matrices can be lifted into Liouville space and evolved with Liouvillian MPOs, and process tensors can store system-bath memory in tensor-network bonds. +For a many-body MPS or MPO, the chain usually follows physical sites. For a process tensor, the chain follows successive time intervals. This change of interpretation is central to `ProcessTensors.jl`. - For more background, see the [Mixed states, MPDOs, and open-system tensor networks](#mixed-states-mpdos-and-open-system-tensor-networks), as well as the [Liouville-space theory page](liouville_space.md) in this documentation. +A process tensor describes a system's response to a sequence of interventions for a specified underlying process. Its temporal network exposes system legs at which those interventions can be attached, while internal bonds carry the information needed to connect different times. -The goal is not to replace `ITensorMPS.jl`, but to extend its style of computation toward open quantum systems and non-Markovian processes. +| Network element | Meaning in a temporal process network | +|:--|:--| +| Local core | A tensor associated with a time interval | +| Open system legs | Interfaces for the system input and output in Liouville space | +| Internal bond | Information retained between successive temporal cores | +| Intervention tensor | A system operation connected at an available intervention time | -!!! related "Related tutorials and examples" - | Topic | Page | - | ----- | ---- | - | Named indices and contractions | [ITensor Basics](@ref) | - | Hilbert-space MPS / MPO | [MPS and MPO Basics](@ref) | - | Density matrices in Liouville space | [Liouville-Space Basics](@ref), [Quantum States and Liouville Space](liouville_space.md) | - | Closed TEBD / TDVP | [Unitary Dynamics](@ref), [TEBD time evolution](../examples/tebd_time_evolution.md), [TDVP time evolution](../examples/tdvp_time_evolution.md) | +An intervention connects the relevant system output to the next system input. Contracting the process with a chosen sequence of operations, together with the appropriate boundary tensors, gives the corresponding output state or measurement statistics. A measurement outcome can produce an unnormalized conditional state; its trace gives the probability of that outcome sequence. -## Further reading +The main practical benefit is **reuse**. Once a process tensor has been constructed, we can change operations at its exposed intervention slots without rebuilding the environmental evolution that it already represents. Reuse assumes that the underlying process, time grid, and boundary assumptions encoded in the tensor remain applicable. Changing the environment or an interaction already included in the construction generally requires a new process tensor. -This page only provides the vocabulary needed for the rest of the documentation. For a more detailed read, check out the resources below. +Temporal bond dimensions determine the cost of storing and contracting this representation. They reflect how much information the chosen factorization retains across temporal cuts, but a stored bond dimension alone is not a representation-independent measure or certificate of physical memory. -### Package documentation and visual guides +The [Process Tensors](process_tensors.md) theory page develops the operational definition and its connection to these temporal networks. -1. [ITensors.jl documentation](https://docs.itensor.org/ITensors/stable/) - - Best for named tensor indices, contractions, tensor SVDs, and the basic `ITensor` object. - -2. [ITensorMPS.jl documentation](https://docs.itensor.org/ITensorMPS/stable/) - - Best for practical Julia usage of `MPS`, `MPO`, `OpSum`, DMRG, and MPS time evolution. - -3. [TensorNetwork.org](https://tensornetwork.org/) - - A broad community resource with introductory and review-style material on tensor networks, algorithms, and software. - -4. [Tensors.net](https://www.tensors.net/) - - Useful for visual tensor-network tutorials, especially if you want to understand diagrams, contractions, decompositions, and algorithmic building blocks. - -### Introductory papers and reviews - -1. [Roman Orús, “A Practical Introduction to Tensor Networks: Matrix Product States and Projected Entangled Pair States”](https://arxiv.org/abs/1306.2164) - - A beginner-friendly conceptual introduction to tensor networks, MPS, and PEPS. - -2. [Jacob C. Bridgeman and Christopher T. Chubb, “Hand-waving and Interpretive Dance: An Introductory Course on Tensor Networks”](https://arxiv.org/abs/1603.03039) - - A readable introduction emphasizing graphical tensor-network reasoning. - -3. [Jacob Biamonte and Ville Bergholm, “Tensor Networks in a Nutshell”](https://arxiv.org/abs/1708.00006) - - A compact overview of tensor-network ideas and notation. +!!! info "In the package" + ```julia + pt = build_process_tensor( + system; + environment=environment, + dt=dt, + nsteps=nsteps, + method=Dense(), + ) + ``` -4. [Ulrich Schollwöck, “The density-matrix renormalization group in the age of matrix product states”](https://arxiv.org/abs/1008.3477) - - A deeper review of MPS, canonical forms, DMRG, and one-dimensional quantum systems. +The returned object is a temporal network for one fixed process. Later experiments change the instruments attached to it. See [Construct a process tensor](@ref) for the spin–boson construction, including the ACE alternative, and [Process tensor instruments](@ref) for the contractions. -### Mixed states, MPDOs, and open-system tensor networks +## Related material and further reading -1. [F. Verstraete, J. J. García-Ripoll, and J. I. Cirac, “Matrix Product Density Operators: Simulation of finite-T and dissipative systems.”](https://arxiv.org/abs/cond-mat/0406426) - - A foundational reference introducing matrix product density operators as tensor-network representations of mixed states. +!!! related "Continue learning" + | Goal | Page | + |:--|:--| + | Work with named indices and contractions | [ITensor Basics](@ref) | + | Build and manipulate spatial networks | [MPS and MPO Basics](@ref) | + | Understand vectorization conventions | [Liouville-Space Basics](@ref) | + | Understand the operational process description | [Process Tensors](process_tensors.md) | + | Construct and reuse a temporal network | [Construct a process tensor](@ref) and [Process tensor instruments](@ref) | -2. [D. Jaschke, S. Montangero, and L. D. Carr, “One-dimensional many-body entangled open quantum systems with tensor network methods.”](https://arxiv.org/abs/1804.09796) - - A broad and accessible entry point for open-system tensor-network simulations. +For broader introductions and implementation details: -3. [J. G. Jarkovsky, A. Molnar, N. Schuch, and J. I. Cirac, “Efficient description of many-body systems with Matrix Product Density Operators.”](https://arxiv.org/abs/2003.12418) - - A more theoretical reference about MPDO representation and when mixed quantum states admit efficient MPDO descriptions. \ No newline at end of file +- R. Orús, [A Practical Introduction to Tensor Networks: Matrix Product States and Projected Entangled Pair States](https://arxiv.org/abs/1306.2164). +- J. C. Bridgeman and C. T. Chubb, [Hand-waving and Interpretive Dance: An Introductory Course on Tensor Networks](https://arxiv.org/abs/1603.03039). +- U. Schollwöck, [The Density-Matrix Renormalization Group in the Age of Matrix Product States](https://arxiv.org/abs/1008.3477). +- [ITensors.jl documentation](https://docs.itensor.org/ITensors/stable/) and [ITensorMPS.jl documentation](https://docs.itensor.org/ITensorMPS/stable/). diff --git a/docs/tikz/build_svgs.sh b/docs/tikz/build_svgs.sh new file mode 100755 index 0000000..1ea95c1 --- /dev/null +++ b/docs/tikz/build_svgs.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Compile each standalone TikZ figure to an SVG under docs/src/assets/theory. +# Inkscape reads the PDF. dvisvgm --pdf cannot open these files here. +set -eu +cd "$(dirname "$0")" +out="../src/assets/theory" +mkdir -p "$out" +for name in \ + pt_anatomy \ + quantum_channel \ + channel_to_multitime_process \ + column_major_vectorisation \ + mpo_to_liouville_mps \ + closing_a_leg \ + process_contractions \ + tensor_contractions \ + mps_vocabulary +do + pdflatex -interaction=nonstopmode "${name}.tex" >"${name}.build.log" + inkscape "${name}.pdf" --export-type=svg --export-filename="${out}/${name}-light.svg" \ + || test -s "${out}/${name}-light.svg" +done + +python3 generate_dark_svgs.py diff --git a/docs/tikz/channel_to_multitime_process.tex b/docs/tikz/channel_to_multitime_process.tex new file mode 100644 index 0000000..ec1731c --- /dev/null +++ b/docs/tikz/channel_to_multitime_process.tex @@ -0,0 +1,6 @@ +\documentclass[tikz]{standalone} +\usepackage{amsmath,amssymb} +\input{pt_tikz_styles.tex} +\begin{document} +\input{channel_to_multitime_process.tikz} +\end{document} diff --git a/docs/tikz/channel_to_multitime_process.tikz b/docs/tikz/channel_to_multitime_process.tikz new file mode 100644 index 0000000..1c281c3 --- /dev/null +++ b/docs/tikz/channel_to_multitime_process.tikz @@ -0,0 +1,294 @@ +% Joint evolution and the open process. Time runs right to left. +% (a) system--environment evolution with interventions, +% (b) the same network after the interventions are removed. +% The open slots in (b) are the multilinear process, not identity operations. +% The one-time channel is quantum_channel.tikz. +% Requires pt_tikz_styles.tex in the preamble. +\begin{tikzpicture}[PTPicture] + \def\envY{0.96}% + \def\sysY{0.00}% + \def\midY{0.48}% + \def\Uwid{8.6mm}% + \def\Uhgt{16.4mm}% + \def\mapSide{11.0mm}% + % Equilateral: altitude = (sqrt(3)/2)*side + \def\ketAlt{9.526mm}% + \def\bLeg{4.8mm}% + \tikzset{ + g5lab/.style={ + font=\small\bfseries, + text=PTLabel, + inner sep=1.0pt, + anchor=north west, + }, + g5eq/.style={ + font=\footnotesize, + text=PTLabel, + align=center, + inner sep=0.4pt, + anchor=north, + }, + g5sub/.style={ + font=\scriptsize, + text=PTLabel, + inner sep=0.4pt, + }, + g5ph/.style={ + dash pattern=on 1.6pt off 1.3pt, + draw=black!45, + line width=0.7pt, + rounded corners=2.2pt, + fill=PTProcess!35, + }, + } + + % Equilateral Liouville ket with the same side length as the (a) channel square. + \newcommand{\GFiveMapKet}[3]{% + \node[ + name=#1, + inner sep=0pt, + outer sep=0pt, + minimum width=\ketAlt, + minimum height=\mapSide, + ] at #2 {}; + \begin{scope}[on PTforeground layer] + \PTSetTriangleRound{\ketAlt}{\mapSide}% + \filldraw[ + draw=black, + fill=PTLiouville!16, + line width=\PTCoreStroke, + line join=round, + rounded corners=\PTRelRound, + ] + (#1.west |- #1.south) -- (#1.east) -- (#1.west |- #1.north) -- cycle; + \node[ + font=\scriptsize, + text=PTLabel, + inner sep=0pt, + outer sep=0pt, + ] + at ($ (#1.west)!0.40!(#1.east) $) {#3}; + \end{scope} + } + % Right-pointing; wires attach on the vertical west base. + \newcommand{\GFiveSEKet}[2]{% + \node[ + name=#1, + inner sep=0pt, + outer sep=0pt, + minimum width=\Uwid, + minimum height=\Uhgt, + ] at #2 {}; + \begin{scope}[on PTforeground layer] + \PTSetTriangleRound{\Uwid}{\Uhgt}% + \filldraw[ + draw=black, + fill=PTLiouville!16, + line width=\PTCoreStroke, + line join=round, + rounded corners=\PTRelRound, + ] + (#1.west |- #1.south) -- (#1.east) -- (#1.west |- #1.north) -- cycle; + \node[font=\small, text=PTLabel, inner sep=0.3pt, align=center] + at ($ (#1.west)!0.36!(#1.east) $) {\(\rho_{SE}\)}; + \end{scope} + } + + % ===================================================================== + % (a) and (b) on one row + % ===================================================================== + \begin{scope}[shift={(0.45, 4.72)}] + % ----------------------------------------------------------------- + % (a) open_S, Tr_E, U32 (A2 otimes I) U21 (A1 otimes I) U10 (A0 otimes I) rho_SE + % Environment on top, system below; both Liouville. + % The propagator centres match panel (b). + % ----------------------------------------------------------------- + \begin{scope}[shift={(0, 0)}] + \coordinate (b-ey) at (0, \envY); + \coordinate (b-sy) at (0, \sysY); + + \PTInstrument[minimum width=7.2mm,minimum height=7.2mm] + {b-A2}{(3.05, \sysY)}{\(\mathcal A_2\)} + \PTInstrument[minimum width=7.2mm,minimum height=7.2mm] + {b-A1}{(5.15, \sysY)}{\(\mathcal A_1\)} + \PTInstrument[minimum width=7.2mm,minimum height=7.2mm] + {b-A0}{(7.25, \sysY)}{\(\mathcal A_0\)} + \PTTensor[fill=PTTensor,minimum width=\Uwid,minimum height=\Uhgt] + {b-U32}{(2.00, \midY)}{\(\mathcal U_{3:2}\)} + \PTTensor[fill=PTTensor,minimum width=\Uwid,minimum height=\Uhgt] + {b-U21}{(4.10, \midY)}{\(\mathcal U_{2:1}\)} + \PTTensor[fill=PTTensor,minimum width=\Uwid,minimum height=\Uhgt] + {b-U10}{(6.20, \midY)}{\(\mathcal U_{1:0}\)} + \GFiveSEKet{b-rho}{(8.42, \midY)} + + % System rail (bottom): open on the left + \draw[PTLiouvilleLeg] + (b-rho.west |- b-sy) -- (b-A0.east); + \draw[PTLiouvilleLeg] + (b-A0.west) -- (b-U10.east |- b-sy); + \draw[PTLiouvilleLeg] + (b-U10.west |- b-sy) -- (b-A1.east); + \draw[PTLiouvilleLeg] + (b-A1.west) -- (b-U21.east |- b-sy); + \draw[PTLiouvilleLeg] + (b-U21.west |- b-sy) -- (b-A2.east); + \draw[PTLiouvilleLeg] + (b-A2.west) -- (b-U32.east |- b-sy); + \draw[PTLiouvilleLeg] + (b-U32.west |- b-sy) -- ++(-\bLeg,0) coordinate (b-open); + \node[g5sub, anchor=east] at ([xshift=-1.6pt]b-open) {\(S\)}; + + % Environment rail (top): unbroken, traced at the left + \draw[PTLiouvilleLeg] + (b-rho.west |- b-ey) -- (b-U10.east |- b-ey); + \draw[PTLiouvilleLeg] + (b-U10.west |- b-ey) -- (b-U21.east |- b-ey); + \draw[PTLiouvilleLeg] + (b-U21.west |- b-ey) -- (b-U32.east |- b-ey); + \coordinate (b-tr) at ($(b-U32.west |- b-ey)+(-\bLeg,0)$); + \draw[PTLiouvilleLeg] + (b-U32.west |- b-ey) -- (b-tr); + \PTTraceOutTick{b-tr} + \node[g5sub, anchor=east] at ([xshift=-3.2pt]b-tr) {\(E\)}; + \node[g5lab, anchor=south east] + at ([xshift=-1.5pt, yshift=2pt]$(b-U32.west |- b-U32.north)+(-8.2mm,0)$) {(a)}; + + % Small RTL time mark, bottom-right (clear of rho_SE) + \coordinate (b-tR) at (8.72,-0.92); + \coordinate (b-tL) at (7.72,-0.92); + \draw[ + -{Stealth[length=2.0mm, width=1.4mm]}, + line width=1.0pt, + draw=PTLabel, + line cap=round, + ] (b-tR) -- (b-tL); + \node[g5sub, anchor=south] at ($(b-tR)!0.5!(b-tL)+(0,1.2pt)$) {time}; + + \node[g5eq] at (4.55,-1.42) + {\(\rho_S(t_3) + =\operatorname{Tr}_E[ + \mathcal U_{3:2}(\mathcal A_2\otimes\mathcal I_E)\, + \mathcal U_{2:1}(\mathcal A_1\otimes\mathcal I_E)\, + \mathcal U_{1:0}(\mathcal A_0\otimes\mathcal I_E) + [\rho_{SE}(t_0)] + ]\)}; + \end{scope} + \end{scope} + + % ===================================================================== + % (b) process tensor: (a) without instruments, enclosed + % ===================================================================== + \begin{scope}[shift={(0.45, 0.55)}] + \coordinate (c-ey) at (0, \envY); + \coordinate (c-sy) at (0, \sysY); + \coordinate (c-ny) at (0, 0.54); + \def\cPad{2.3mm}% + \def\cSlot{4.4mm}% + \def\cTrDist{2.5mm}% + % Roof sits \cPad above U.north; bulge radius matches that gap from the env rail. + \def\cBulgeR{5.7mm}% + \def\cRoofR{5.2pt}% + + \PTTensor[fill=PTTensor,minimum width=\Uwid,minimum height=\Uhgt] + {c-U32}{(2.00, \midY)}{\(\mathcal U_{3:2}\)} + \PTTensor[fill=PTTensor,minimum width=\Uwid,minimum height=\Uhgt] + {c-U21}{(4.10, \midY)}{\(\mathcal U_{2:1}\)} + \PTTensor[fill=PTTensor,minimum width=\Uwid,minimum height=\Uhgt] + {c-U10}{(6.20, \midY)}{\(\mathcal U_{1:0}\)} + \GFiveSEKet{c-rho}{(8.42, \midY)} + \coordinate (c-tr) at ($(c-U32.west |- c-ey)+(-\cTrDist,0)$); + + % Roof is a flat line at the semicircle's north y, out to the rho_SE tip x. + \coordinate (c-trS) at ($(c-tr)+(0,-\cBulgeR)$); + \coordinate (c-trN) at ($(c-tr)+(0,\cBulgeR)$); + \coordinate (c-TR) at ([xshift=\cPad]c-rho.east |- c-trN); + \coordinate (c-TRin) at ([xshift=-\cRoofR]c-TR); + \coordinate (c-RSbot) at ($(c-rho.south east)+(\cPad,-\cPad)$); + \coordinate (c-RSbotL) at ($(c-rho.south west)+(-\cPad,-\cPad)$); + \coordinate (c-rhoWny) at ([xshift=-\cPad]c-rho.west |- c-ny); + \coordinate (c-U10Eny) at ([xshift=\cPad]c-U10.east |- c-ny); + \coordinate (c-U10SE) at ($(c-U10.south east)+(\cPad,-\cPad)$); + \coordinate (c-U10SW) at ($(c-U10.south west)+(-\cPad,-\cPad)$); + \coordinate (c-U10Wny) at ([xshift=-\cPad]c-U10.west |- c-ny); + \coordinate (c-U21Eny) at ([xshift=\cPad]c-U21.east |- c-ny); + \coordinate (c-U21SE) at ($(c-U21.south east)+(\cPad,-\cPad)$); + \coordinate (c-U21SW) at ($(c-U21.south west)+(-\cPad,-\cPad)$); + \coordinate (c-U21Wny) at ([xshift=-\cPad]c-U21.west |- c-ny); + \coordinate (c-U32Eny) at ([xshift=\cPad]c-U32.east |- c-ny); + \coordinate (c-U32SE) at ($(c-U32.south east)+(\cPad,-\cPad)$); + \coordinate (c-U32SW) at ($(c-U32.south west)+(-\cPad,-\cPad)$); + \coordinate (c-U32WtrS) at ([xshift=-\cPad]c-U32.west |- c-trS); + \begin{scope}[on background layer] + \filldraw[ + draw=green!28!black, + fill=green!38!black!18!white, + fill opacity=1, + draw opacity=1, + dash pattern=on 1.7pt off 1.25pt, + line width=0.85pt, + ] + (c-trN) -- (c-TRin) + arc[start angle=90, delta angle=-90, radius=\cRoofR] + [rounded corners=5.2pt] + -- (c-RSbot) -- (c-RSbotL) + -- (c-rhoWny) -- (c-U10Eny) + -- (c-U10SE) -- (c-U10SW) + -- (c-U10Wny) -- (c-U21Eny) + -- (c-U21SE) -- (c-U21SW) + -- (c-U21Wny) -- (c-U32Eny) + -- (c-U32SE) -- (c-U32SW) + [sharp corners] + -- (c-U32WtrS) -- (c-trS) + arc[start angle=-90, delta angle=-180, radius=\cBulgeR] + -- cycle; + \end{scope} + + % Environment rail (inside T), traced on the left + \draw[PTLiouvilleLeg] + (c-rho.west |- c-ey) -- (c-U10.east |- c-ey); + \draw[PTLiouvilleLeg] + (c-U10.west |- c-ey) -- (c-U21.east |- c-ey); + \draw[PTLiouvilleLeg] + (c-U21.west |- c-ey) -- (c-U32.east |- c-ey); + \draw[PTLiouvilleLeg] + (c-U32.west |- c-ey) -- (c-tr); + \PTTraceOutTick{c-tr} + \node[g5lab, anchor=south east] + at ([xshift=-1.5pt, yshift=1.5pt]$(c-tr)+(-\cBulgeR,\cBulgeR)$) {(b)}; + + % Open system slots: output on the right of each pair, input on the left + \draw[PTLiouvilleLeg] + (c-rho.west |- c-sy) -- ++(-\cSlot,0); + \draw[PTLiouvilleLeg primed] + (c-U10.east |- c-sy) -- ++(\cSlot,0); + \draw[PTLiouvilleLeg] + (c-U10.west |- c-sy) -- ++(-\cSlot,0); + \draw[PTLiouvilleLeg primed] + (c-U21.east |- c-sy) -- ++(\cSlot,0); + \draw[PTLiouvilleLeg] + (c-U21.west |- c-sy) -- ++(-\cSlot,0); + \draw[PTLiouvilleLeg primed] + (c-U32.east |- c-sy) -- ++(\cSlot,0); + \draw[PTLiouvilleLeg] + (c-U32.west |- c-sy) -- ++(-\cSlot,0) coordinate (c-open); + + \coordinate (c-tR) at (8.72,-0.92); + \coordinate (c-tL) at (7.72,-0.92); + \draw[ + -{Stealth[length=2.0mm, width=1.4mm]}, + line width=1.0pt, + draw=PTLabel, + line cap=round, + ] (c-tR) -- (c-tL); + \node[g5sub, anchor=south] at ($(c-tR)!0.5!(c-tL)+(0,1.2pt)$) {time}; + + \node[g5eq] at (4.55,-1.42) + {\(\mathcal T_{3:0} + =\operatorname{Tr}_E[ + \mathcal U_{3:2}\, + \mathcal U_{2:1}\, + \mathcal U_{1:0} + [\rho_{SE}(t_0)] + ]\)}; + \end{scope} +\end{tikzpicture} diff --git a/docs/tikz/closing_a_leg.tex b/docs/tikz/closing_a_leg.tex new file mode 100644 index 0000000..43c9a6b --- /dev/null +++ b/docs/tikz/closing_a_leg.tex @@ -0,0 +1,6 @@ +\documentclass[tikz]{standalone} +\usepackage{amsmath,amssymb} +\input{pt_tikz_styles.tex} +\begin{document} +\input{closing_a_leg.tikz} +\end{document} diff --git a/docs/tikz/closing_a_leg.tikz b/docs/tikz/closing_a_leg.tikz new file mode 100644 index 0000000..71989de --- /dev/null +++ b/docs/tikz/closing_a_leg.tikz @@ -0,0 +1,95 @@ +% Closing a Liouville leg: keep the state, trace it out, read an +% effect probability, or trace out only the environment. +% Requires pt_tikz_styles.tex in the preamble. +\newcommand{\PTEffectBra}[3]{% + \node[ + name=#1, + inner sep=0pt, + outer sep=0pt, + minimum width=\PTKetWidth, + minimum height=\PTKetHeight, + ] at #2 {}; + \begin{scope}[on PTforeground layer] + \PTSetTriangleRound{\PTKetWidth}{\PTKetHeight} + \filldraw[ + draw=black, + fill=PTHilbertBra!42, + line width=\PTCoreStroke, + line join=round, + rounded corners=\PTRelRound, + ] (#1.east |- #1.south) -- (#1.west) -- (#1.east |- #1.north) -- cycle; + \node[font=\small, text=PTLabel, inner sep=0.4pt] + at ($ (#1.east)!0.36!(#1.west) $) {#3}; + \end{scope} +} +\begin{tikzpicture}[PTPicture] + \setlength{\PTKetWidth}{9.6mm} + \setlength{\PTKetHeight}{11.1mm} + \setlength{\PTCoreStroke}{1.85pt} + \setlength{\PTLiouvilleLW}{0.95pt} + \setlength{\PTLiouvilleGap}{1.95pt} + \def\cellW{4.05}% + \tikzset{ + gclab/.style={ + font=\small\bfseries, + text=PTLabel, + inner sep=1.0pt, + anchor=north west, + }, + gceq/.style={ + font=\small, + text=PTLabel, + align=center, + inner sep=0.4pt, + anchor=north, + }, + gcleg/.style={ + font=\small, + text=PTLabel, + inner sep=0.5pt, + }, + } + + % (a) open state + \begin{scope}[shift={(0,0)}] + \node[gclab] at (0, 1.95) {(a)}; + \PTLiouvilleKet{a-rho}{(2.05,0.78)}{\(\rho\)} + \draw[PTLiouvilleLeg] (a-rho.west) -- ++(-0.85,0) coordinate (a-open); + \node[gceq] at (1.55,-0.18) {\(\lvert\rho\rangle\!\rangle\)}; + \end{scope} + + % (b) trace, capped by the trace-out tick rather than an identity bra + \begin{scope}[shift={(\cellW,0)}] + \node[gclab] at (0, 1.95) {(b)}; + \PTLiouvilleKet{b-rho}{(2.35,0.78)}{\(\rho\)} + \PTTraceOut{b-tr}{($(b-rho.west)+(-0.95,0)$)} + \draw[PTLiouvilleLeg] (b-rho.west) -- (b-tr-east); + \node[gceq] at (1.70,-0.18) {\(\operatorname{Tr}(\rho)\)}; + \end{scope} + + % (c) effect + \begin{scope}[shift={(2*\cellW,0)}] + \node[gclab] at (0, 1.95) {(c)}; + \PTEffectBra{c-E}{(1.05,0.78)}{\(E_x\)} + \PTLiouvilleKet{c-rho}{(2.85,0.78)}{\(\rho\)} + \draw[PTLiouvilleLeg] (c-E.east) -- (c-rho.west); + \node[gceq] at (1.95,-0.18) {\(p_x=\operatorname{Tr}(E_x\rho)\)}; + \end{scope} + + % (d) partial trace of the environment + \begin{scope}[shift={(3*\cellW,0)}] + \node[gclab] at (0, 1.95) {(d)}; + \PTLiouvilleKet{d-rho}{(2.55,0.78)}{\(\rho_{SE}\)} + \coordinate (d-Esrc) at ($(d-rho.west)+(0,0.28)$); + \coordinate (d-Ssrc) at ($(d-rho.west)+(0,-0.28)$); + \coordinate (d-tip) at ($(d-rho.west)+(-1.45,0)$); + \coordinate (d-S) at (d-tip |- d-Ssrc); + \coordinate (d-E) at (d-tip |- d-Esrc); + \draw[PTLiouvilleLeg] (d-Ssrc) -- (d-S); + \PTTraceOut{d-tr}{($(d-E)+(2.8mm,0)$)} + \draw[PTLiouvilleLeg] (d-Esrc) -- (d-tr-east); + \node[gcleg, anchor=east] at ([xshift=-3.5pt]d-E) {\(E\)}; + \node[gcleg, anchor=east] at ([xshift=-3.5pt]d-S) {\(S\)}; + \node[gceq] at (1.85,-0.18) {\(\operatorname{Tr}_E(\rho_{SE})\)}; + \end{scope} +\end{tikzpicture} diff --git a/docs/tikz/column_major_vectorisation.tex b/docs/tikz/column_major_vectorisation.tex new file mode 100644 index 0000000..76007c4 --- /dev/null +++ b/docs/tikz/column_major_vectorisation.tex @@ -0,0 +1,6 @@ +\documentclass[tikz]{standalone} +\usepackage{amsmath,amssymb} +\input{pt_tikz_styles.tex} +\begin{document} +\input{column_major_vectorisation.tikz} +\end{document} diff --git a/docs/tikz/column_major_vectorisation.tikz b/docs/tikz/column_major_vectorisation.tikz new file mode 100644 index 0000000..b97a8bb --- /dev/null +++ b/docs/tikz/column_major_vectorisation.tikz @@ -0,0 +1,64 @@ +% Column-major vectorisation. Bending the bra beside the ket is an +% index rearrangement, not complex conjugation. +% Requires pt_tikz_styles.tex in the preamble. +\begin{tikzpicture}[PTPicture] + \setlength{\PTCoreStroke}{2.05pt} + \setlength{\PTHilbertLW}{1.9pt} + \setlength{\PTLiouvilleLW}{1.00pt} + \setlength{\PTLiouvilleGap}{2.05pt} + \setlength{\PTKetWidth}{10.2mm} + \setlength{\PTKetHeight}{11.8mm} + \setlength{\PTVecBendR}{3.5mm} + \setlength{\PTVecOverhang}{0.28mm} + \setlength{\PTFuseGap}{2.05mm} + \setlength{\PTVecRun}{2.25mm} + \setlength{\PTVecDipH}{5.8mm} + \tikzset{ + g2eq/.style={ + font=\small, + text=PTLabel, + align=center, + inner sep=0.4pt, + anchor=north, + }, + g2arr/.style={ + -{Stealth[length=2.4mm, width=1.7mm]}, + line width=1.35pt, + draw=PTLabel, + }, + } + + \begin{scope}[shift={(3.70, 1.35)}] + \PTOperator[minimum width=10.4mm,minimum height=10.4mm]{vA}{(-3.15,0)}{\(A\)} + \draw[PTHilbertKetLeg] (vA.west) -- ++(-0.28,0) coordinate (vA-j); + \draw[PTHilbertBraLeg] (vA.east) -- ++(0.28,0) coordinate (vA-k); + \PTIndexLabel[anchor=east, math={j}, xshift=-1.2pt]{vA-j} + \PTIndexLabel[anchor=west, math={k}, xshift=1.2pt]{vA-k} + + \PTOperator[minimum width=10.4mm,minimum height=10.4mm]{vAb}{(0.15,0)}{\(A\)} + \PTVectoriseToKet{vAb} + \coordinate (vAb-tl) at ($ (vAb.west) + (-\PTVecOverhang, 2*\PTVecBendR) $); + \coordinate (vAb-elbow) at ($ (vAb.east) + (\PTVecBendR, \PTVecBendR) $); + \coordinate (g2a-s) at ($ (vA.north east) + (2.2mm, 2.2mm) $); + \path let + \p1=(g2a-s), + \p2=(vAb-tl), + \n{d2}={\y1-\y2} + in coordinate (g2a-e) at ($ (vAb-tl) + (-\n{d2}, \n{d2}) $); + \draw[g2arr] (g2a-s) to[bend left=34] (g2a-e); + \PTIndexLabel[anchor=south east, math={k}, xshift=-1.0pt, yshift=0.4pt]{vAb-bra} + \PTIndexLabel[anchor=north east, math={j}, xshift=-1.0pt, yshift=-0.4pt]{vAb-ket} + + \PTLiouvilleKet{vAL}{(3.55,0)}{\(A\)} + \draw[PTLiouvilleLeg] (vAL.west) -- ++(-0.62,0) coordinate (vAL-ell); + \path let \p1=(vAb-elbow), \p2=(vAL-ell) in + node[font=\small, text=PTLabel] at ({0.5*(\x1+\x2)}, 0) {\(\equiv\)}; + \PTIndexLabel[anchor=south, math={(k,j)}, yshift=1.8pt] + {$(vAL-ell)!0.18!(vAL.west)$} + + \node[g2eq] at (-2.20,-1.15) + {\(\hat A=a_{jk}\lvert j\rangle\langle k\rvert\)}; + \node[g2eq] at ($ (vAb)!0.5!(vAL) +(0,-1.15) $) + {\(\lvert A\rangle\!\rangle=a_{jk}\lvert k\rangle\otimes\lvert j\rangle\)}; + \end{scope} +\end{tikzpicture} diff --git a/docs/tikz/generate_dark_svgs.py b/docs/tikz/generate_dark_svgs.py new file mode 100644 index 0000000..628c713 --- /dev/null +++ b/docs/tikz/generate_dark_svgs.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors +# SPDX-License-Identifier: MIT +# +# File: docs/tikz/generate_dark_svgs.py +# Contributor: Gauthameshwar S. +# +# Generates dark-theme SVG variants from light-theme TikZ SVGs for Documenter docs. +# Uses a curated, soft and vibrant palette for tensor cores, instruments, and operators +# on dark canvas (#1f2424) with crisp off-white labels and outlines (#e6e6e6). + +import os +import re +import colorsys +DOC_DARK_BG = "#1f2424" + +# Curated dark-theme-friendly palette for ProcessTensors theory figures. +# Preserves semantic color identities with soft, luminous, and vibrant tones. +DARK_PALETTE = { + # 1. Page knockouts, double-wire inner rails, crossing halos, prime-tick knockouts + "#ffffff": DOC_DARK_BG, + "#fff": DOC_DARK_BG, + "#d1d1d1": DOC_DARK_BG, # Inkscape desk background + + # 2. Main strokes, wires, labels, text + "#000000": "#e6e6e6", + "#000": "#e6e6e6", + "#333333": "#e6e6e6", + + # 3. Bonds and dividers + "#6a6a6a": "#9e9e9e", + "#adadad": "#6e6e6e", + + # 4. Process-tensor cores (W and Q) + "#dceaf7": "#2b6ea6", # PTProcess: soft vibrant sapphire / sky core (W) + "#ace8ae": "#2d854c", # QAMPOcore: soft vibrant emerald / mint-sage core (Q) + + # 5. Instruments and interventions + "#f6e8a6": "#997116", # PTInstrument: soft warm golden amber (E, A) + "#f7e58b": "#a8801a", # PTInstIn: warm golden sunflower (instrument input) + "#fcb06d": "#b55e26", # PTInstOut: soft vibrant coral / terracotta (instrument output) + "#ad9986": "#7a6652", # PTInstMap: refined warm bronze / taupe (instrument map) + + # 6. Generic operators and Liouville superoperators + "#e8e8e8": "#3e4c52", # PTTensor: soft elevated slate card (generic operator) + "#eadeee": "#7b488d", # PTLiouville: soft vibrant amethyst / purple + "#e5d6ea": "#7b488d", # PTLiouville variant + + # 7. Ket and Bra boundary states + "#b8d8e9": "#2b6ea6", # PTHilbertKet!18: soft sapphire ket triangle + "#edbb94": "#a85c2c", # PTHilbertBra!18: soft terracotta bra triangle + "#f1c8a8": "#a85c2c", # PTHilbertBra!18 variant + + # 8. Environment memory loop and bath contours + "#d1e3d1": "#285236", # Green environment wash (fill) + "#004700": "#3eb066", # Green memory stroke (vibrant mint line) + + # 9. MPS site gradient in mps_vocabulary (3-step blue gradient) + "#f4f9fc": "#23496d", # MPS Site A (lightest) + "#d6e8f4": "#285b88", # MPS Site B (mid) + "#b7d4e8": "#2f71a6", # MPS Site C (deepest) + + # 10. MPO site gradient in mps_vocabulary (3-step warm amber gradient) + "#fdf8f3": "#5e3d24", # MPO Site A (lightest) + "#f6e6d6": "#754b2b", # MPO Site B (mid) + "#ebd4be": "#8f5c34", # MPO Site C (deepest) +} + +def hex_to_rgb(h): + h = h.lstrip("#") + if len(h) == 3: + h = "".join(2 * c for c in h) + return tuple(int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)) + +def rgb_to_hex(r, g, b): + return "#{:02x}{:02x}{:02x}".format( + int(round(max(0.0, min(1.0, r)) * 255)), + int(round(max(0.0, min(1.0, g)) * 255)), + int(round(max(0.0, min(1.0, b)) * 255)), + ) + +def transform_color_for_dark(hex_str, dark_bg=DOC_DARK_BG): + h_lower = hex_str.lower() + if h_lower in DARK_PALETTE: + val = DARK_PALETTE[h_lower] + return dark_bg if val == DOC_DARK_BG else val + + # Graceful fallback for any unlisted color + r, g, b = hex_to_rgb(h_lower) + h, l, s = colorsys.rgb_to_hls(r, g, b) + + if l > 0.65: + # Soft vibrant luminous fill + new_l = 0.35 + (l - 0.65) * 0.15 + new_s = min(1.0, s * 0.85) + elif l < 0.35: + new_l = 0.60 + new_s = min(1.0, s * 0.85) + else: + new_l = 1.0 - l + new_s = s + + nr, ng, nb = colorsys.hls_to_rgb(h, new_l, new_s) + return rgb_to_hex(nr, ng, nb) + +def convert_svg_to_dark(src_path, dst_path, dark_bg=DOC_DARK_BG): + with open(src_path, "r", encoding="utf-8") as f: + content = f.read() + + def replace_hex(match): + original_hex = match.group(0) + return transform_color_for_dark(original_hex, dark_bg) + + # Match #xxxxxx or #xxx hex colors + new_content = re.sub(r"#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b", replace_hex, content) + + with open(dst_path, "w", encoding="utf-8") as f: + f.write(new_content) + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + assets_dir = os.path.normpath(os.path.join(script_dir, "..", "src", "assets", "theory")) + + figures = [ + "pt_anatomy", + "quantum_channel", + "channel_to_multitime_process", + "column_major_vectorisation", + "mpo_to_liouville_mps", + "closing_a_leg", + "process_contractions", + "tensor_contractions", + "mps_vocabulary", + ] + + for name in figures: + src = os.path.join(assets_dir, f"{name}-light.svg") + if not os.path.exists(src): + print(f"Skipping {src} (not found)") + continue + + dark_dst = os.path.join(assets_dir, f"{name}-dark.svg") + convert_svg_to_dark(src, dark_dst) + print(f"Generated {name}-dark.svg") + +if __name__ == "__main__": + main() diff --git a/docs/tikz/mpo_to_liouville_mps.tex b/docs/tikz/mpo_to_liouville_mps.tex new file mode 100644 index 0000000..fe7aabd --- /dev/null +++ b/docs/tikz/mpo_to_liouville_mps.tex @@ -0,0 +1,6 @@ +\documentclass[tikz]{standalone} +\usepackage{amsmath,amssymb} +\input{pt_tikz_styles.tex} +\begin{document} +\input{mpo_to_liouville_mps.tikz} +\end{document} diff --git a/docs/tikz/mpo_to_liouville_mps.tikz b/docs/tikz/mpo_to_liouville_mps.tikz new file mode 100644 index 0000000..46588c5 --- /dev/null +++ b/docs/tikz/mpo_to_liouville_mps.tikz @@ -0,0 +1,89 @@ +% Fig. 4: three-site density MPO -> bent pairs -> Liouville MPS. +% Requires pt_tikz_styles.tex in the preamble. +\begin{tikzpicture}[PTPicture] + \def\siteDy{1.24}% + \tikzset{ + g4eq/.style={ + font=\footnotesize, + text=PTLabel, + align=center, + inner sep=0.4pt, + anchor=north, + }, + g4bond/.style={ + PTBondLeg, + draw=black!32, + line width=\PTLinkLW, + }, + } + + % Shared vertical stack: site 1 at top, site 3 at bottom. + % #1 = name prefix, #2 = x, #3 = core fill. + % Bonds are drawn on the background layer so bent primed wires sit on top. + \newcommand{\GFourSites}[3]{% + \foreach \j/\yy in {1/\siteDy, 2/0, 3/-\siteDy} { + \PTOperator[fill=#3,minimum width=7.2mm,minimum height=7.2mm] + {#1-\j}{(#2,\yy)}{\(\rho^{[\j]}\)} + } + \draw[g4bond] (#1-1.south) -- (#1-2.north); + \draw[g4bond] (#1-2.south) -- (#1-3.north); + } + + % Hilbert-space density MPO + \begin{scope}[shift={(0.10, 1.95)}] + \GFourSites{a}{1.15}{PTProcess} + \foreach \j in {1,2,3} { + \draw[PTHilbertKetLeg] + (a-\j.west) -- ++(-0.42,0) coordinate (a-\j-s); + \draw[PTHilbertBraLeg primed] + (a-\j.east) -- ++(0.42,0) coordinate (a-\j-sp); + } + \PTIndexLabel[anchor=east, math={s_1}, xshift=-1.2pt]{a-1-s} + \PTIndexLabel[anchor=west, math={s'_1}, xshift=1.2pt]{a-1-sp} + \PTIndexLabel[anchor=east, math={s_2}, xshift=-1.2pt]{a-2-s} + \PTIndexLabel[anchor=west, math={s'_2}, xshift=1.2pt]{a-2-sp} + \PTIndexLabel[anchor=east, math={s_3}, xshift=-1.2pt]{a-3-s} + \PTIndexLabel[anchor=west, math={s'_3}, xshift=1.2pt]{a-3-sp} + + \node[g4eq] at (1.15,-1.92) + {\(\hat\rho=\rho_{s_1 s_2 s_3}^{s'_1 s'_2 s'_3} + \lvert s_1 s_2 s_3\rangle\langle s'_1 s'_2 s'_3\rvert\)}; + \end{scope} + + \PTMapsTo{(3.55,1.95)} + + % Bend each bra over the ket: column-major pair (s_j', s_j) + \begin{scope}[shift={(4.05, 1.95)}] + \GFourSites{b}{1.55}{PTProcess} + \foreach \j in {1,2,3} { + \PTVectoriseToKet{b-\j} + \PTIndexLabel[anchor=south east, math={s'_{\j}}, xshift=-0.6pt, yshift=0.3pt] + {b-\j-bra} + \PTIndexLabel[anchor=north east, math={s_{\j}}, xshift=-0.6pt, yshift=-0.3pt] + {b-\j-ket} + } + \end{scope} + + % Geometric middle of the gap: east of the bent cores (~5.96) and west + % of the Liouville labels (~8.85). + \node[font=\small, text=PTLabel] at (7.28,1.95) {\(\equiv\)}; + + % Liouville MPS: double wire α_j = (s_j', s_j) + \begin{scope}[shift={(8.70, 1.95)}] + \GFourSites{c}{1.35}{PTLiouville!20} + \foreach \j in {1,2,3} { + \draw[PTLiouvilleLeg] + (c-\j.west) -- ++(-0.62,0) coordinate (c-\j-a); + } + \PTIndexLabel[anchor=east, math={\alpha_1}, xshift=-1.4pt]{c-1-a} + \PTIndexLabel[anchor=east, math={\alpha_2}, xshift=-1.4pt]{c-2-a} + \PTIndexLabel[anchor=east, math={\alpha_3}, xshift=-1.4pt]{c-3-a} + + \node[g4eq] at (1.45,-1.92) + {\(\lvert\rho\rangle\!\rangle + =\rho_{\alpha_1\alpha_2\alpha_3}\, + \lvert\alpha_1\rangle\otimes + \lvert\alpha_2\rangle\otimes + \lvert\alpha_3\rangle\)}; + \end{scope} +\end{tikzpicture} diff --git a/docs/tikz/mps_vocabulary.tex b/docs/tikz/mps_vocabulary.tex new file mode 100644 index 0000000..7159dad --- /dev/null +++ b/docs/tikz/mps_vocabulary.tex @@ -0,0 +1,6 @@ +\documentclass[tikz]{standalone} +\usepackage{amsmath,amssymb} +\input{pt_tikz_styles.tex} +\begin{document} +\input{mps_vocabulary.tikz} +\end{document} diff --git a/docs/tikz/mps_vocabulary.tikz b/docs/tikz/mps_vocabulary.tikz new file mode 100644 index 0000000..4d98cc7 --- /dev/null +++ b/docs/tikz/mps_vocabulary.tikz @@ -0,0 +1,89 @@ +% Physical index, virtual bond, and short MPS and MPO chains. +% Sites stack vertically. Ket legs leave to the left; primed bra legs +% leave to the right. +% Requires pt_tikz_styles.tex in the preamble. +\definecolor{MPSSiteA}{HTML}{F4F9FC} +\definecolor{MPSSiteB}{HTML}{D6E8F4} +\definecolor{MPSSiteC}{HTML}{B7D4E8} +\definecolor{MPOSiteA}{HTML}{FDF8F3} +\definecolor{MPOSiteB}{HTML}{F6E6D6} +\definecolor{MPOSiteC}{HTML}{EBD4BE} +\begin{tikzpicture}[PTPicture] + \setlength{\PTCoreStroke}{1.95pt} + \setlength{\PTHilbertLW}{1.8pt} + \setlength{\PTBondLW}{1.85pt} + \def\siteDy{1.58}% + \def\legL{0.62}% + \tikzset{ + tvlab/.style={ + font=\small\bfseries, + text=PTLabel, + inner sep=1.0pt, + anchor=north west, + }, + tveq/.style={ + font=\small, + text=PTLabel, + align=center, + inner sep=0.3pt, + anchor=north, + }, + } + + % (a) physical index: ket leg on the left + \begin{scope}[shift={(0,0)}] + \node[tvlab] at (0, 4.15) {(a)}; + \PTTensor[minimum width=10.5mm, minimum height=10.5mm, fill=MPSSiteB]{aA}{(1.85,\siteDy)}{\(A^{[j]}\)} + \draw[PTHilbertKetLeg] (aA.west) -- ++(-\legL,0) coordinate (a-s); + \PTIndexLabel[anchor=east, math={s_j}, xshift=-1.6pt]{a-s} + \node[tveq] at (1.55,-1.05) {physical index}; + \end{scope} + + % (b) virtual bond + \begin{scope}[shift={(3.45,0)}] + \node[tvlab] at (0, 4.15) {(b)}; + \PTTensor[minimum width=10.5mm, minimum height=10.5mm, fill=MPSSiteA]{bT}{(1.85,1.5*\siteDy)}{\(A^{[j]}\)} + \PTTensor[minimum width=10.5mm, minimum height=10.5mm, fill=MPSSiteC]{bB}{(1.85,0.5*\siteDy)}{\(A^{[j+1]}\)} + \draw[PTBondLeg] (bT.south) -- (bB.north) coordinate[midway] (b-chi); + \draw[PTHilbertKetLeg] (bT.west) -- ++(-\legL,0) coordinate (b-sj); + \draw[PTHilbertKetLeg] (bB.west) -- ++(-\legL,0) coordinate (b-sjp); + \PTIndexLabel[anchor=east, math={s_j}, xshift=-1.6pt]{b-sj} + \PTIndexLabel[anchor=east, math={s_{j+1}}, xshift=-1.6pt]{b-sjp} + \PTIndexLabel[anchor=west, math={\chi_j}, xshift=2.2pt]{b-chi} + \node[tveq] at (1.55,-1.05) {virtual bond \(\chi_j\)}; + \end{scope} + + % (c) short MPS + \begin{scope}[shift={(7.15,0)}] + \node[tvlab] at (0, 4.15) {(c)}; + \PTTensor[minimum width=10.5mm, minimum height=10.5mm, fill=MPSSiteA]{m-1}{(1.85,2*\siteDy)}{\(A^{[1]}\)} + \PTTensor[minimum width=10.5mm, minimum height=10.5mm, fill=MPSSiteB]{m-2}{(1.85,\siteDy)}{\(A^{[2]}\)} + \PTTensor[minimum width=10.5mm, minimum height=10.5mm, fill=MPSSiteC]{m-3}{(1.85,0)}{\(A^{[3]}\)} + \draw[PTHilbertKetLeg] (m-1.west) -- ++(-\legL,0) coordinate (m-s1); + \draw[PTHilbertKetLeg] (m-2.west) -- ++(-\legL,0) coordinate (m-s2); + \draw[PTHilbertKetLeg] (m-3.west) -- ++(-\legL,0) coordinate (m-s3); + \PTIndexLabel[anchor=east, math={s_1}, xshift=-1.6pt]{m-s1} + \PTIndexLabel[anchor=east, math={s_2}, xshift=-1.6pt]{m-s2} + \PTIndexLabel[anchor=east, math={s_3}, xshift=-1.6pt]{m-s3} + \draw[PTBondLeg] (m-1.south) -- (m-2.north); + \draw[PTBondLeg] (m-2.south) -- (m-3.north); + \node[tveq] at (1.55,-1.05) {MPS}; + \end{scope} + + % (d) short MPO: ket on the left, primed bra on the right + \begin{scope}[shift={(10.55,0)}] + \node[tvlab] at (0, 4.15) {(d)}; + \PTOperator[minimum width=10.5mm, minimum height=10.5mm, fill=MPOSiteA]{w-1}{(1.75,2*\siteDy)}{\(W^{[1]}\)} + \PTOperator[minimum width=10.5mm, minimum height=10.5mm, fill=MPOSiteB]{w-2}{(1.75,\siteDy)}{\(W^{[2]}\)} + \PTOperator[minimum width=10.5mm, minimum height=10.5mm, fill=MPOSiteC]{w-3}{(1.75,0)}{\(W^{[3]}\)} + \foreach \j in {1,2,3} { + \draw[PTHilbertKetLeg] (w-\j.west) -- ++(-\legL,0) coordinate (w-s\j); + \draw[PTHilbertBraLeg primed] (w-\j.east) -- ++(0.70,0) coordinate (w-sp\j); + \PTIndexLabel[anchor=east, math={s_{\j}}, xshift=-1.6pt]{w-s\j} + \PTIndexLabel[anchor=west, math={s'_{\j}}, xshift=1.8pt]{w-sp\j} + } + \draw[PTBondLeg] (w-1.south) -- (w-2.north); + \draw[PTBondLeg] (w-2.south) -- (w-3.north); + \node[tveq] at (1.75,-1.05) {MPO}; + \end{scope} +\end{tikzpicture} diff --git a/docs/tikz/process_contractions.tex b/docs/tikz/process_contractions.tex new file mode 100644 index 0000000..453a142 --- /dev/null +++ b/docs/tikz/process_contractions.tex @@ -0,0 +1,6 @@ +\documentclass[tikz]{standalone} +\usepackage{amsmath,amssymb} +\input{pt_tikz_styles.tex} +\begin{document} +\input{process_contractions.tikz} +\end{document} diff --git a/docs/tikz/process_contractions.tikz b/docs/tikz/process_contractions.tikz new file mode 100644 index 0000000..f41816b --- /dev/null +++ b/docs/tikz/process_contractions.tikz @@ -0,0 +1,143 @@ +% One process tensor, three contractions. Time runs right to left. +% The cores do not change. Only the final closure changes. +% Requires pt_tikz_styles.tex in the preamble. +\begin{tikzpicture}[PTPicture] + \def\QACoreW{10.2mm}% + \def\QACoreH{16.8mm}% + \def\QAIORaise{3.5mm}% + \def\QABondRaise{3.6mm}% + \def\QAdx{3.10}% + \def\QAoff{0.92}% + \def\cPad{2.3mm}% + \tikzset{ + g9lab/.style={ + font=\small\bfseries, + text=PTLabel, + inner sep=1.0pt, + }, + g9leg/.style={ + font=\footnotesize, + text=PTLabel, + inner sep=0.5pt, + }, + g9eq/.style={ + font=\footnotesize, + text=PTLabel, + align=center, + inner sep=0.4pt, + }, + g9mem/.style={ + draw=PTBond, + line width=\PTLinkLW, + line cap=butt, + line join=round, + double=none, + on background layer, + }, + } + + \newcommand{\QACore}[3]{% + \PTTensor[ + fill=QAMPOcore, + minimum width=\QACoreW, + minimum height=\QACoreH, + font=\normalsize, + ]{#1}{#2}{#3}% + \coordinate (#1-o) at ([yshift=\QAIORaise]#1.south west); + \coordinate (#1-i) at ([yshift=\QAIORaise]#1.south east); + \coordinate (#1-L) at ([yshift=\QABondRaise]#1.west); + \coordinate (#1-R) at ([yshift=\QABondRaise]#1.east); + } + + \newcommand{\GNinePT}[1]{% + \QACore{#1-Q2}{(0.00,0)}{\(Q^{[2]}\)} + \QACore{#1-Q1}{(\QAdx,0)}{\(Q^{[1]}\)} + \QACore{#1-Q0}{(2*\QAdx,0)}{\(Q^{[0]}\)} + \draw[g9mem] (#1-Q2-R) -- (#1-Q1-L) coordinate[midway] (#1-chi2); + \draw[g9mem] (#1-Q1-R) -- (#1-Q0-L) coordinate[midway] (#1-chi1); + \node[g9leg, above=2.8pt] at (#1-chi2) {\(\chi_2\)}; + \node[g9leg, above=2.8pt] at (#1-chi1) {\(\chi_1\)}; + \coordinate (#1-roof) at ([yshift=\cPad]#1-Q0.north); + \coordinate (#1-TL) at ([xshift=-\cPad]#1-Q2.west |- #1-roof); + \coordinate (#1-TR) at ([xshift=\cPad]#1-Q0.east |- #1-roof); + \coordinate (#1-Q0SE) at ($ (#1-Q0.south east)+(\cPad,-\cPad) $); + \coordinate (#1-Q0SW) at ($ (#1-Q0.south west)+(-\cPad,-\cPad) $); + \coordinate (#1-Q1SE) at ($ (#1-Q1.south east)+(\cPad,-\cPad) $); + \coordinate (#1-Q1SW) at ($ (#1-Q1.south west)+(-\cPad,-\cPad) $); + \coordinate (#1-Q2SE) at ($ (#1-Q2.south east)+(\cPad,-\cPad) $); + \coordinate (#1-Q2SW) at ($ (#1-Q2.south west)+(-\cPad,-\cPad) $); + \coordinate (#1-slot) at ([yshift=1.6mm]#1-Q0.center); + \coordinate (#1-g0R) at ([xshift=-\cPad]#1-Q0.west |- #1-slot); + \coordinate (#1-g0L) at ([xshift=\cPad]#1-Q1.east |- #1-slot); + \coordinate (#1-g1R) at ([xshift=-\cPad]#1-Q1.west |- #1-slot); + \coordinate (#1-g1L) at ([xshift=\cPad]#1-Q2.east |- #1-slot); + \begin{scope}[on background layer] + \filldraw[ + draw=green!28!black, + fill=green!38!black!18!white, + dash pattern=on 1.7pt off 1.25pt, + line width=0.85pt, + rounded corners=5.2pt, + ] + (#1-TL) -- (#1-TR) + -- (#1-Q0SE) -- (#1-Q0SW) + -- (#1-g0R) -- (#1-g0L) + -- (#1-Q1SE) -- (#1-Q1SW) + -- (#1-g1R) -- (#1-g1L) + -- (#1-Q2SE) -- (#1-Q2SW) + -- cycle; + \end{scope} + } + + \newcommand{\GNineSchedule}[1]{% + \PTInstrumentNode[kind=ket,fill=PTInstIn] + {#1-rho}{($ (#1-Q0-i)+(\QAoff,0) $)}{\(\rho_0\)} + \draw[PTLiouvilleLeg primed] (#1-Q0-i) -- (#1-rho.west); + \draw[PTLiouvilleLeg] (#1-Q0-o) -- (#1-Q1-i); + \PTTensor[fill=PTInstMap,minimum width=7.2mm,minimum height=7.2mm] + {#1-U}{($ (#1-Q1-o)!0.5!(#1-Q2-i) $)}{\(U\)} + \draw[PTLiouvilleLeg] (#1-Q1-o) -- (#1-U.east); + \draw[PTLiouvilleLeg primed] (#1-U.west) -- (#1-Q2-i); + } + + % (a) open final output: unnormalised state + \begin{scope}[shift={(0.15, 6.90)}] + \begin{scope}[shift={(0.55,0.15)}] + \GNinePT{a} + \GNineSchedule{a} + \draw[PTLiouvilleLeg] (a-Q2-o) -- ++(-0.72,0) coordinate (a-open); + \node[g9leg, anchor=east] at ([xshift=-1.2pt]a-open) {\(o_2\)}; + \node[g9lab, anchor=south east] at ([xshift=-1.5pt, yshift=1.2pt]a-TL) {(a)}; + \end{scope} + \node[g9eq, anchor=north] at (3.85,-1.42) + {unnormalised output state \(\lvert\widetilde\rho\rangle\!\rangle\)}; + \end{scope} + + % (b) measurement effect: outcome probability + \begin{scope}[shift={(0.15, 3.45)}] + \begin{scope}[shift={(0.55,0.15)}] + \GNinePT{b} + \GNineSchedule{b} + \PTInstrumentNode[kind=bra,fill=PTInstOut] + {b-E}{($ (b-Q2-o)+(-\QAoff,0) $)}{\(E_x\)} + \draw[PTLiouvilleLeg] (b-Q2-o) -- (b-E.east); + \node[g9lab, anchor=south east] at ([xshift=-1.5pt, yshift=1.2pt]b-TL) {(b)}; + \end{scope} + \node[g9eq, anchor=north] at (3.85,-1.42) + {outcome probability \(p_x=\operatorname{Tr}(E_x\widetilde\rho)\)}; + \end{scope} + + % (c) Hermitian observable contraction + \begin{scope}[shift={(0.15, 0.05)}] + \begin{scope}[shift={(0.55,0.15)}] + \GNinePT{c} + \GNineSchedule{c} + \PTInstrumentNode[kind=bra,fill=PTInstOut] + {c-O}{($ (c-Q2-o)+(-\QAoff,0) $)}{\(O\)} + \draw[PTLiouvilleLeg] (c-Q2-o) -- (c-O.east); + \node[g9lab, anchor=south east] at ([xshift=-1.5pt, yshift=1.2pt]c-TL) {(c)}; + \end{scope} + \node[g9eq, anchor=north] at (3.85,-1.42) + {observable contraction \(\langle\!\langle O\vert\widetilde\rho\rangle\!\rangle\)}; + \end{scope} +\end{tikzpicture} diff --git a/docs/tikz/pt_anatomy.tex b/docs/tikz/pt_anatomy.tex new file mode 100644 index 0000000..a14fb19 --- /dev/null +++ b/docs/tikz/pt_anatomy.tex @@ -0,0 +1,6 @@ +\documentclass[tikz]{standalone} +\usepackage{amsmath,amssymb} +\input{pt_tikz_styles.tex} +\begin{document} +\input{pt_anatomy.tikz} +\end{document} diff --git a/docs/tikz/pt_anatomy.tikz b/docs/tikz/pt_anatomy.tikz new file mode 100644 index 0000000..ae5de53 --- /dev/null +++ b/docs/tikz/pt_anatomy.tikz @@ -0,0 +1,125 @@ +% Process-tensor core anatomy. Time runs right to left. +% A core owns one interval. An intervention joins the output of one +% interval to the input of the next. Requires pt_tikz_styles.tex. +\begin{tikzpicture}[PTPicture] + \def\QACoreW{10.2mm}% + \def\QACoreH{16.8mm}% + \def\QAIOLen{3.2mm}% + \def\QAIORaise{3.5mm}% + \def\QABondRaise{3.6mm}% + \def\QAdx{2.95}% + \tikzset{ + qalab/.style={ + font=\normalsize\bfseries, + text=PTLabel, + inner sep=1.0pt, + }, + qaleg/.style={ + font=\footnotesize, + text=PTLabel, + inner sep=0.6pt, + }, + qamem/.style={ + draw=PTBond, + line width=\PTLinkLW, + line cap=butt, + line join=round, + double=none, + on background layer, + }, + qaeq/.style={ + font=\footnotesize, + text=PTLabel, + align=center, + inner sep=1.0pt, + }, + } + + % Tall process core. Output port is on the left (later time); + % input port is on the right (earlier time). + \newcommand{\QACore}[3]{% + \PTTensor[ + fill=QAMPOcore, + minimum width=\QACoreW, + minimum height=\QACoreH, + font=\normalsize, + ]{#1}{#2}{#3}% + \coordinate (#1-o) at ([yshift=\QAIORaise]#1.south west); + \coordinate (#1-i) at ([yshift=\QAIORaise]#1.south east); + \coordinate (#1-L) at ([yshift=\QABondRaise]#1.west); + \coordinate (#1-R) at ([yshift=\QABondRaise]#1.east); + } + + % ------------------------------------------------------------------ (a) + \begin{scope}[shift={(0.15,0.55)}] + \QACore{a-Q2}{(0.00,0)}{\(Q^{[2]}\)} + \QACore{a-Q1}{(\QAdx,0)}{\(Q^{[1]}\)} + \QACore{a-Q0}{(2*\QAdx,0)}{\(Q^{[0]}\)} + + \draw[qamem] (a-Q2-R) -- (a-Q1-L) + coordinate[midway] (a-a2); + \draw[qamem] (a-Q1-R) -- (a-Q0-L) + coordinate[midway] (a-a1); + \node[qaleg, above=3.2pt] at (a-a2) {\(\chi_2\)}; + \node[qaleg, above=3.2pt] at (a-a1) {\(\chi_1\)}; + + % A_1 joins o_0 (unprimed output) to i_1 (primed input). + \coordinate (a-A) at ($(a-Q0-o)!0.5!(a-Q1-i)$); + \PTInstrument[minimum width=7.4mm,minimum height=7.4mm] + {a-A1}{(a-A)}{\(\mathcal A_1\)} + \draw[PTLiouvilleLeg] (a-Q0-o) -- (a-A1.east); + \draw[PTLiouvilleLeg primed] (a-A1.west) -- (a-Q1-i); + \node[qaleg, below=1.6pt] at (a-A1.south) {\(i_1\leftarrow o_0\)}; + + \draw[PTLiouvilleLeg] (a-Q2-o) -- ++(-\QAIOLen,0) coordinate (a-o2); + \draw[PTLiouvilleLeg primed] (a-Q2-i) -- ++(\QAIOLen,0) coordinate (a-i2); + \draw[PTLiouvilleLeg] (a-Q1-o) -- ++(-\QAIOLen,0) coordinate (a-o1); + \draw[PTLiouvilleLeg primed] (a-Q0-i) -- ++(\QAIOLen,0) coordinate (a-i0); + + \node[qaleg, below=2.4pt] at (a-o2) {\(o_2\)}; + \node[qaleg, below=2.4pt] at (a-i2) {\(i_2\)}; + \node[qaleg, below=2.4pt] at (a-o1) {\(o_1\)}; + \node[qaleg, below=2.4pt] at (a-i0) {\(i_0\)}; + + \node[qaleg, above=1.5pt] at (a-Q0.north) {\([t_0,t_1]\)}; + \node[qaleg, above=1.5pt] at (a-Q1.north) {\([t_1,t_2]\)}; + \node[qaleg, above=1.5pt] at (a-Q2.north) {\([t_2,t_3]\)}; + + \node[qalab, anchor=south east, inner sep=1.4pt] + at ([yshift=4.2mm]a-Q2.north west) {(a)}; + + \coordinate (a-tR) at ($(a-Q0.south)+(0.15,-1.15)$); + \coordinate (a-tL) at ($(a-tR)+(-1.15,0)$); + \draw[ + -{Stealth[length=2.0mm, width=1.4mm]}, + line width=1.0pt, + draw=PTLabel, + line cap=round, + ] (a-tR) -- (a-tL); + \node[qaleg, anchor=south] at ($(a-tR)!0.5!(a-tL)+(0,1.2pt)$) {time}; + \end{scope} + + % ------------------------------------------------------------------ (b) + \begin{scope}[shift={(8.55,0.55)}] + \QACore{b-Qk}{(0,0)}{\(Q^{[k]}\)} + \node[qaleg, above=1.5pt] at (b-Qk.north) {\([t_k,t_{k+1}]\)}; + + \draw[qamem] (b-Qk-L) -- ++(-\QAIOLen,0) coordinate (b-akp); + \draw[qamem] (b-Qk-R) -- ++(\QAIOLen,0) coordinate (b-ak); + \node[qaleg, anchor=east, xshift=-1.4pt] at (b-akp) {\(\chi_{k+1}\)}; + \node[qaleg, anchor=west, xshift=1.4pt] at (b-ak) {\(\chi_k\)}; + + \draw[PTLiouvilleLeg] (b-Qk-o) -- ++(-\QAIOLen,0) coordinate (b-ok); + \draw[PTLiouvilleLeg primed] (b-Qk-i) -- ++(\QAIOLen,0) coordinate (b-ik); + \node[qaleg, anchor=east, xshift=-1.4pt] at (b-ok) {\(o_k\)}; + \node[qaleg, anchor=west, xshift=1.4pt] at (b-ik) {\(i_k\)}; + + \node[qalab, anchor=south east, inner sep=1.4pt] + at ([yshift=4.2mm]b-Qk.north west) {(b)}; + + \node[qaeq, anchor=north west] at (-1.55,-1.35) {% + \(i_k\): prime level \(1\)\\ + \(o_k\): prime level \(0\)% + }; + \end{scope} +\end{tikzpicture} diff --git a/docs/tikz/pt_tikz_styles.tex b/docs/tikz/pt_tikz_styles.tex new file mode 100644 index 0000000..ab08a3b --- /dev/null +++ b/docs/tikz/pt_tikz_styles.tex @@ -0,0 +1,1196 @@ +% ============================================================================= +% pt_tikz_styles.tex --- Process-tensor graphical calculus (SciPost / docs) +% ============================================================================= +% +% Visual conventions +% ------------------ +% Time in every process-tensor diagram runs RIGHT TO LEFT: +% +% end <---------------- start +% time +% t_n t_0 +% +% Earlier operations sit to the right of later ones. The reusable time arrow +% always points left. Never reverse this convention in a panel. +% +% Spaces are distinguished by LINE STRUCTURE, not colour (wires are black): +% * Hilbert ket leg --- solid wire. +% * Hilbert bra leg --- solid wire plus an optional prime marker. +% * Liouville leg --- double wire (fused s \otimes s^*, dim d^2). +% * PT-MPO link index --- solid wire at twice the site-leg thickness. +% * Ordinary TN bond --- solid wire at the site-leg thickness. +% * Classical outcome --- dashed wire. +% +% Boundary states are equilateral triangles, not arrowheads: +% * Ket ----|> right-pointing triangle; wire enters from the left. +% * Bra <|---- left-pointing triangle; wire exits to the right. +% Outlines are black. Fills may differ (ket / bra / Liouville / instrument). +% A Liouville vector has a plain triangle; only its leg is double-wired. +% +% Operators and two-legged tensors are squares with a thick stroke and slight +% corner rounding. Process cores use a light-blue fill; instruments use a +% light-gold fill; ordinary operators use a light-grey fill. +% Filled shapes are drawn on a foreground layer so wires never show through. +% Trace symbols are cups (Hilbert) or compact Tr nodes (Liouville), never +% ordinary measurement boxes. +% +% Input / output ports on a process core (local time also right-to-left): +% * Output of the process (to the instrument) sits on the RIGHT of the pair. +% * Input of the process (from the instrument) sits on the LEFT of the pair. +% +% Column-major vectorisation (Julia / ProcessTensors.jl): +% |A>> = sum_{j,k} A_{jk} |k> \otimes |j> +% vec(|j> \otimes |j> +% vec(A \rho B) = (B^T \otimes A) |\rho>> +% +% Public commands (all prefixed PT) +% --------------------------------- +% \PTKet / \PTBra / \PTLiouvilleKet / \PTLiouvilleBra +% \PTTensor / \PTOperator / \PTSuperoperator / \PTProcessCore / \PTInstrument +% \PTInstrumentNode generic instrument constructor +% \PTIdentity / \PTControl / \PTUnitary / \PTPrepare / \PTMeasure +% \PTProjective / \PTCausalBreak / \PTTraceHilbert / \PTTraceLiouville +% \PTCombiner / \PTCombinerInv +% \PTIndexLabel math + ITensor tag + optional prime +% \PTCup / \PTCap / \PTTimeArrow +% \PTDrawProcessMPO[n=4, at={0,0}, name=pt] % at = x,y without extra parens +% \PTDrawJointProcess wrapper with joint=true +% +% Introducing a new instrument +% ---------------------------- +% Derive it from \PTInstrumentNode (or from a ket/bra/trace kind). Example: +% +% \newcommand{\PTReset}[4][]{% +% \PTInstrumentNode[fill=PTInstrument, classical=none, #1]{#2}{#3}{#4}% +% } +% % usage: \PTReset{R}{(0,0)}{\(\mathcal{R}_k\)} +% +% Future instruments (testers, feedback, multi-site, coarse-grained +% measurements, discard-and-reprepare, ...) should likewise be thin wrappers +% around \PTInstrumentNode so existing figures keep compiling. +% +% Overriding colours +% ------------------ +% All styles refer to named colours. Redefine them AFTER inputting this +% file and BEFORE any tikzpicture; do not edit the drawing commands: +% +% \definecolor{PTLink}{HTML}{111111} +% \definecolor{PTProcess}{HTML}{F0F0F0} +% +% Lengths (\PTCoreWidth, \PTTimeSpacing, ...) may be \setlength similarly. +% +% Loading +% ------- +% Preamble only: +% \input{figures/tikz/pt_tikz_styles.tex} +% Figure fragments then: +% \input{figures/tikz/.tikz} +% +% ============================================================================= + +\ifdefined\PTTikZLoaded + \expandafter\endinput +\fi +\def\PTTikZLoaded{1} + +\RequirePackage{tikz} +\RequirePackage{xcolor} +\RequirePackage{keyval} +\usetikzlibrary{ + arrows.meta, + backgrounds, + calc, + decorations.markings, + fit, + matrix, + positioning, + shapes.geometric +} + +% ----------------------------------------------------------------------------- +% Colours (Okabe--Ito / colour-blind friendly; intelligible in grayscale) +% ----------------------------------------------------------------------------- +\definecolor{PTHilbertKet}{HTML}{0072B2} +\definecolor{PTHilbertBra}{HTML}{D55E00} +\definecolor{PTLiouville}{HTML}{7B3294} +\definecolor{PTLink}{HTML}{009E73} +\definecolor{PTTensor}{HTML}{E8E8E8} +\definecolor{PTProcess}{HTML}{DCEAF7} +\definecolor{QAMPOcore}{HTML}{ACE8AE} +\definecolor{PTInstrument}{HTML}{F6E8A6} +\definecolor{PTInstIn}{HTML}{F7E58B} +\definecolor{PTInstOut}{HTML}{FCB06D} +\definecolor{PTInstMap}{HTML}{AD9986} +\definecolor{PTTrace}{HTML}{CC79A7} +\definecolor{PTMeasurement}{HTML}{E69F00} +\definecolor{PTLabel}{HTML}{333333} +\definecolor{PTBond}{HTML}{6A6A6A} + +% ----------------------------------------------------------------------------- +% Lengths (the single place to retune the visual language) +% ----------------------------------------------------------------------------- +\newlength{\PTCoreWidth} \setlength{\PTCoreWidth}{9.6mm} +\newlength{\PTCoreHeight} \setlength{\PTCoreHeight}{9.6mm} +\newlength{\PTTimeSpacing} \setlength{\PTTimeSpacing}{20.0mm} +\newlength{\PTLegLength} \setlength{\PTLegLength}{7.0mm} +\newlength{\PTIOSep} \setlength{\PTIOSep}{2.4mm} +% Equilateral triangle: vertical base = side, horizontal altitude = (sqrt(3)/2)*side. +\newlength{\PTKetHeight} \setlength{\PTKetHeight}{7.8mm} +\newlength{\PTKetWidth} \setlength{\PTKetWidth}{6.75mm} +\newlength{\PTBendDepth} \setlength{\PTBendDepth}{5.5mm} +\newlength{\PTVecBendR} \setlength{\PTVecBendR}{2.5mm} +\newlength{\PTFuseGap} \setlength{\PTFuseGap}{1.5mm} +\newlength{\PTVecOverhang} \setlength{\PTVecOverhang}{0.20mm} +\newlength{\PTVecRun} \setlength{\PTVecRun}{1.6mm} +\newlength{\PTVecDipH} \setlength{\PTVecDipH}{4.2mm} +\newlength{\PTCombinerSize} \setlength{\PTCombinerSize}{7.2mm} +\newlength{\PTHilbertLW} \setlength{\PTHilbertLW}{1.35pt} +\newlength{\PTLiouvilleLW} \setlength{\PTLiouvilleLW}{0.70pt} +\newlength{\PTLiouvilleGap} \setlength{\PTLiouvilleGap}{1.55pt} +\newlength{\PTLinkLW} \setlength{\PTLinkLW}{2.70pt} +\newlength{\PTBondLW} \setlength{\PTBondLW}{1.35pt} +\newlength{\PTClassicalLW} \setlength{\PTClassicalLW}{1.15pt} +\newlength{\PTCoreStroke} \setlength{\PTCoreStroke}{1.45pt} +% Fallback only; \PTTensor computes a size-relative radius from Fig. 8 +% (1.2mm on an 11mm square, 0.8mm on an 11mm-tall triangle). +\newlength{\PTCoreRadius} \setlength{\PTCoreRadius}{1.3pt} +\newlength{\PTBridgeHalo} \setlength{\PTBridgeHalo}{4.2pt} +\newlength{\PTRelRound} +\newlength{\PTRndCmpA} +\newlength{\PTRndCmpB} + +% Fig. 8 reference ratios: square 1.2/11, triangle 0.8/11, of min(width,height). +\newcommand{\PTSetSquareRound}[2]{% + \setlength{\PTRndCmpA}{#1}% + \setlength{\PTRndCmpB}{#2}% + \ifdim\PTRndCmpA>\PTRndCmpB + \pgfmathsetlength{\PTRelRound}{(1.2/11)*\PTRndCmpB}% + \else + \pgfmathsetlength{\PTRelRound}{(1.2/11)*\PTRndCmpA}% + \fi +} +\newcommand{\PTSetTriangleRound}[2]{% + \setlength{\PTRndCmpA}{#1}% + \setlength{\PTRndCmpB}{#2}% + \ifdim\PTRndCmpA>\PTRndCmpB + \pgfmathsetlength{\PTRelRound}{(0.8/11)*\PTRndCmpB}% + \else + \pgfmathsetlength{\PTRelRound}{(0.8/11)*\PTRndCmpA}% + \fi +} +\newlength{\PTTraceNodeRound} +\PTSetSquareRound{7.2mm}{7.2mm} +\setlength{\PTTraceNodeRound}{\PTRelRound} + +% Scratch length for the MPO drawer (not a public tuning knob). +\newlength{\PTMPOspacing} + +% ----------------------------------------------------------------------------- +% Layers: wires behind, labels in the middle, filled shapes in front. +% ----------------------------------------------------------------------------- +\pgfdeclarelayer{PTforeground} +\pgfsetlayers{background,main,PTforeground} +\tikzset{ + on PTforeground layer/.style={ + execute at begin scope={\pgfonlayer{PTforeground}}, + execute at end scope={\endpgfonlayer}, + }, +} + +% ----------------------------------------------------------------------------- +% Line and node styles +% ----------------------------------------------------------------------------- +\tikzset{ + PTPicture/.style={ + baseline, + line join=round, + >={Stealth[length=2.0mm, width=1.45mm]}, + }, + % --- wires (black; structure, not colour, distinguishes spaces) ---------- + PTWire/.style={ + draw=black, + line width=\PTHilbertLW, + line cap=butt, + line join=round, + double=none, + }, + PTHilbertKetLeg/.style={ + PTWire, + on background layer, + }, + PTHilbertBraLeg/.style={ + draw=black, + line width=\PTHilbertLW, + line cap=butt, + line join=round, + double=none, + on background layer, + }, + % Optional prime tick at the midpoint of a bra wire. + PTHilbertBraLeg primed/.style={ + PTHilbertBraLeg, + postaction={ + decorate, + decoration={ + markings, + mark=at position 0.55 with { + \node[font=\scriptsize, text=PTLabel, inner sep=0.4pt, + xshift=3.2pt, yshift=0.4pt] {\(\prime\)}; + } + } + }, + }, + PTLiouvilleLeg/.style={ + draw=black, + line width=\PTLiouvilleLW, + double=white, + double distance=\PTLiouvilleGap, + line cap=butt, + line join=round, + on background layer, + }, + % Prime tick at mid-stub: a thin, slightly tall slash through both rails. + PTLiouvilleLeg primed/.style={ + PTLiouvilleLeg, + postaction={ + decorate, + decoration={ + markings, + mark=at position 0.5 with { + \fill[white] (-0.28mm,-1.12mm) rectangle (0.62mm,1.20mm); + \draw[ + PTLabel, + line width=0.45pt, + line cap=round, + ] (-0.18mm,-0.98mm) -- (0.38mm,1.06mm); + } + } + }, + }, + PTBondLeg/.style={ + draw=black, + line width=\PTBondLW, + line cap=butt, + line join=round, + double=none, + on background layer, + }, + PTLinkLeg/.style={ + draw=black, + line width=\PTLinkLW, + line cap=butt, + line join=round, + double=none, + on background layer, + }, + PTClassicalLeg/.style={ + draw=black, + line width=\PTClassicalLW, + dash pattern=on 2.4pt off 1.6pt, + line cap=butt, + line join=round, + double=none, + on background layer, + }, + PTTraceCup/.style={ + draw=PTTrace, + line width=\PTHilbertLW, + line cap=round, + line join=round, + double=none, + on background layer, + }, + % White halo so an over-crossing does not look like a contraction. + PTBridge/.style={ + preaction={ + draw=white, + line width=\PTBridgeHalo, + double=none, + line cap=butt, + }, + }, + % --- cores --------------------------------------------------------------- + PTCore/.style={ + draw=black, + fill=PTTensor, + rounded corners=\PTCoreRadius, + minimum width=\PTCoreWidth, + minimum height=\PTCoreHeight, + inner sep=2.2pt, + outer sep=0pt, + line width=\PTCoreStroke, + font=\small, + text=PTLabel, + align=center, + }, + PTTraceNode/.style={ + draw=black, + fill=PTTrace!18, + rounded corners=\PTTraceNodeRound, + minimum width=7.2mm, + minimum height=7.2mm, + inner sep=1.4pt, + outer sep=0pt, + line width=\PTCoreStroke, + font=\small, + text=PTLabel, + align=center, + }, + PTDummyBond/.style={ + circle, + fill=black, + draw=none, + inner sep=1.15pt, + outer sep=0pt, + }, +} + +% ----------------------------------------------------------------------------- +% Index labels: math + ITensor tag + optional prime level +% ----------------------------------------------------------------------------- +\makeatletter +\define@key{ptindex}{anchor}{\def\PTIndexAnchor{#1}} +\define@key{ptindex}{math}{\def\PTIndexMath{#1}} +\define@key{ptindex}{tag}{\def\PTIndexTag{#1}} +\define@key{ptindex}{prime}{\def\PTIndexPrime{#1}} +\define@key{ptindex}{xshift}{\def\PTIndexX{#1}} +\define@key{ptindex}{yshift}{\def\PTIndexY{#1}} +\makeatother + +% Append a visual prime level to math (') and to a tag (ASCII apostrophe). +\newcommand{\PTIndexPrimeTokens}{% + \ifx\PTIndexPrime\empty + \else + \ifnum\PTIndexPrime>0 '\fi + \ifnum\PTIndexPrime>1 '\fi + \ifnum\PTIndexPrime>2 '\fi + \fi +} + +\newcommand{\PTIndexLabel}[2][]{% + \def\PTIndexAnchor{south}% + \def\PTIndexMath{}% + \def\PTIndexTag{}% + \def\PTIndexPrime{}% + \def\PTIndexX{0pt}% + \def\PTIndexY{0pt}% + \setkeys{ptindex}{#1}% + \node[ + inner sep=0.9pt, + outer sep=1.2pt, + anchor=\PTIndexAnchor, + align=center, + xshift=\PTIndexX, + yshift=\PTIndexY, + text=PTLabel, + ] at (#2) {% + \def\PTIndexMathEmpty{}% + \ifx\PTIndexMath\PTIndexMathEmpty + \else + {\small\(\PTIndexMath\PTIndexPrimeTokens\)}% + \ifx\PTIndexTag\PTIndexMathEmpty + \else \\[-0.15ex]% + \fi + \fi + \ifx\PTIndexTag\PTIndexMathEmpty + \else + {\scriptsize\ttfamily \PTIndexTag\PTIndexPrimeTokens}% + \fi + };% +} + +% ----------------------------------------------------------------------------- +% Ket and bra boundary tensors +% ----------------------------------------------------------------------------- +% Invisible rectangular node provides named anchors. The triangle is drawn +% on top so that .west (ket) / .east (bra) is the wire attachment (base). + +\newcommand{\PTKet}[3]{% + \node[ + name=#1, + inner sep=0pt, + outer sep=0pt, + minimum width=\PTKetWidth, + minimum height=\PTKetHeight, + font=\small, + ] at #2 {}; + \begin{scope}[on PTforeground layer] + \PTSetTriangleRound{\PTKetWidth}{\PTKetHeight} + \filldraw[ + draw=black, + fill=PTHilbertKet!18, + line width=\PTCoreStroke, + line join=round, + rounded corners=\PTRelRound, + ] (#1.west |- #1.south) -- (#1.east) -- (#1.west |- #1.north) -- cycle; + \node[font=\small, text=PTLabel, inner sep=0.4pt] + at ($ (#1.west)!0.36!(#1.east) $) {#3}; + \end{scope} +} + +\newcommand{\PTBra}[3]{% + \node[ + name=#1, + inner sep=0pt, + outer sep=0pt, + minimum width=\PTKetWidth, + minimum height=\PTKetHeight, + font=\small, + ] at #2 {}; + \begin{scope}[on PTforeground layer] + \PTSetTriangleRound{\PTKetWidth}{\PTKetHeight} + \filldraw[ + draw=black, + fill=PTHilbertBra!18, + line width=\PTCoreStroke, + line join=round, + rounded corners=\PTRelRound, + ] (#1.east |- #1.south) -- (#1.west) -- (#1.east |- #1.north) -- cycle; + \node[font=\small, text=PTLabel, inner sep=0.4pt] + at ($ (#1.east)!0.36!(#1.west) $) {#3}; + \end{scope} +} + +\newcommand{\PTLiouvilleKet}[3]{% + \node[ + name=#1, + inner sep=0pt, + outer sep=0pt, + minimum width=\PTKetWidth, + minimum height=\PTKetHeight, + font=\small, + ] at #2 {}; + \begin{scope}[on PTforeground layer] + \PTSetTriangleRound{\PTKetWidth}{\PTKetHeight} + \filldraw[ + draw=black, + fill=PTLiouville!16, + line width=\PTCoreStroke, + line join=round, + rounded corners=\PTRelRound, + ] (#1.west |- #1.south) -- (#1.east) -- (#1.west |- #1.north) -- cycle; + \node[font=\small, text=PTLabel, inner sep=0.4pt] + at ($ (#1.west)!0.36!(#1.east) $) {#3}; + \end{scope} +} + +\newcommand{\PTLiouvilleBra}[3]{% + \node[ + name=#1, + inner sep=0pt, + outer sep=0pt, + minimum width=\PTKetWidth, + minimum height=\PTKetHeight, + font=\small, + ] at #2 {}; + \begin{scope}[on PTforeground layer] + \PTSetTriangleRound{\PTKetWidth}{\PTKetHeight} + \filldraw[ + draw=black, + fill=PTLiouville!16, + line width=\PTCoreStroke, + line join=round, + rounded corners=\PTRelRound, + ] (#1.east |- #1.south) -- (#1.west) -- (#1.east |- #1.north) -- cycle; + \node[font=\small, text=PTLabel, inner sep=0.4pt] + at ($ (#1.east)!0.36!(#1.west) $) {#3}; + \end{scope} +} + +% Output Liouville ket when time runs right-to-left: left-pointing triangle, +% wire attached at .east (same geometry as a Liouville bra). +\newcommand{\PTLiouvilleKetLeft}[3]{\PTLiouvilleBra{#1}{#2}{#3}} + +% ----------------------------------------------------------------------------- +% Square tensor cores (thick stroke, slight corner rounding) +% ----------------------------------------------------------------------------- +\makeatletter +\define@key{pttensor}{fill}{\def\PTTensorFill{#1}} +\define@key{pttensor}{draw}{\def\PTTensorDrawCol{#1}} +\define@key{pttensor}{minimum width}{\def\PTTensorMinW{#1}} +\define@key{pttensor}{minimum height}{\def\PTTensorMinH{#1}} +\define@key{pttensor}{rounded}{\def\PTTensorRnd{#1}} +\define@key{pttensor}{font}{\def\PTTensorFont{#1}} +\makeatother + +\newcommand{\PTTensor}[4][]{% + \def\PTTensorFill{PTTensor}% + \def\PTTensorDrawCol{black}% + \def\PTTensorMinW{\PTCoreWidth}% + \def\PTTensorMinH{\PTCoreHeight}% + \def\PTTensorRnd{PTAUTO}% + \def\PTTensorFont{\small}% + \setkeys{pttensor}{#1}% + \def\PTAuto{PTAUTO}% + \ifx\PTTensorRnd\PTAuto + \PTSetSquareRound{\PTTensorMinW}{\PTTensorMinH}% + \else + \setlength{\PTRelRound}{\PTTensorRnd}% + \fi + \begin{scope}[on PTforeground layer] + \node[ + name=#2, + PTCore, + fill=\PTTensorFill, + draw=\PTTensorDrawCol, + minimum width=\PTTensorMinW, + minimum height=\PTTensorMinH, + rounded corners=\PTRelRound, + font=\PTTensorFont, + ] at #3 {#4}; + \end{scope} +} + +\newcommand{\PTOperator}[4][]{% + \PTTensor[fill=PTTensor, minimum width=\PTCoreWidth, minimum height=\PTCoreWidth, #1]{#2}{#3}{#4}% +} + +\newcommand{\PTSuperoperator}[4][]{% + \PTTensor[ + fill=PTTensor, + minimum width=\PTCoreWidth, + minimum height=\PTCoreWidth, + #1 + ]{#2}{#3}{#4}% +} + +\newcommand{\PTProcessCore}[4][]{% + \PTTensor[ + fill=PTProcess, + minimum width=\PTCoreWidth, + minimum height=\PTCoreWidth, + #1 + ]{#2}{#3}{#4}% +} + +\newcommand{\PTInstrument}[4][]{% + \PTTensor[fill=PTInstrument,minimum width=\PTCoreWidth,minimum height=\PTCoreWidth,#1]{#2}{#3}{#4}% +} + +% Vertical Hilbert ket (north) and bra (south) stubs on an operator. +\newcommand{\PTOperatorLegs}[1]{% + \draw[PTHilbertKetLeg] (#1.north) -- ++(0,\PTLegLength) coordinate (#1-ket); + \draw[PTHilbertBraLeg] (#1.south) -- ++(0,-\PTLegLength) coordinate (#1-bra); +} + +% ----------------------------------------------------------------------------- +% Combiners: s \otimes s' --> \ell and the inverse +% ----------------------------------------------------------------------------- +\makeatletter +\define@key{ptcomb}{direction}{\def\PTCombDir{#1}} +\makeatother +\def\PTCombDirUp{up} +\def\PTCombDirDown{down} +\def\PTCombDirLeft{left} +\def\PTCombDirRight{right} + +\newcommand{\PTCombiner}[4][]{% + \def\PTCombDir{up}% + \setkeys{ptcomb}{#1}% + \ifx\PTCombDir\PTCombDirDown + \def\PTCombRot{180}% + \else\ifx\PTCombDir\PTCombDirLeft + \def\PTCombRot{90}% + \else\ifx\PTCombDir\PTCombDirRight + \def\PTCombRot{270}% + \else + \def\PTCombRot{0}% + \fi\fi\fi + \PTSetTriangleRound{\PTCombinerSize}{\PTCombinerSize}% + \begin{scope}[on PTforeground layer] + \node[ + name=#2, + isosceles triangle, + isosceles triangle apex angle=60, + shape border rotate=\PTCombRot, + inner sep=0pt, + outer sep=0pt, + minimum width=\PTCombinerSize, + draw=black, + fill=white, + line width=\PTCoreStroke, + rounded corners=\PTRelRound, + font=\scriptsize, + text=PTLabel, + ] at #3 {}; + \node[font=\scriptsize, text=PTLabel, inner sep=0.8pt, anchor=east] + at ([xshift=-1.6pt]#2.west) {#4}; + \end{scope} + \coordinate (#2-fused) at (#2.apex); + \coordinate (#2-ket) at (#2.left corner); + \coordinate (#2-bra) at (#2.right corner); +} + +\newcommand{\PTCombinerInv}[4][]{% + \PTCombiner[direction=down, #1]{#2}{#3}{#4}% +} + +% ----------------------------------------------------------------------------- +% Cups, caps, time arrow, callouts +% ----------------------------------------------------------------------------- +% Cup (union, opens upward): used for traces and vectorisation bends. +% The two endpoints are the open wire tips; control points sit below them. +\newcommand{\PTCup}[3][]{% + \draw[#1] (#2) + .. controls ($ (#2) + (0,-\PTBendDepth) $) + and ($ (#3) + (0,-\PTBendDepth) $) .. + (#3); +} + +% Trace: left and right semicircles joined by a straight bar BELOW the core +% (never behind it). #2-loopmid is the bar midpoint for a single index tag. +% Drawn on the main layer so the bar is not covered by the core; the core +% still sits in front at the ports. +\newcommand{\PTTraceLoop}[2][PTWire]{% + \draw[#1] + (#2.west) + arc[start angle=90, delta angle=180, radius=4.0mm] + -- ([yshift=-8.0mm]#2.east) + arc[start angle=270, delta angle=180, radius=4.0mm]; + \coordinate (#2-loopmid) at ([yshift=-8.0mm]#2.center); +} + +% Same loop, but above the core (used for Tr(A) and Tr(O rho) in Fig. 2). +\newcommand{\PTTraceLoopAbove}[2][PTWire]{% + \draw[#1] + (#2.west) + arc[start angle=270, delta angle=-180, radius=4.0mm] + -- ([yshift=8.0mm]#2.east) + arc[start angle=90, delta angle=-180, radius=4.0mm]; + \coordinate (#2-looptop) at ([yshift=8.0mm]#2.center); +} + +% Gradual S-dip: horizontal span \PTVecDipH, drop 2 R - fuse gap. +\makeatletter +% Bend the east (bra) wire over the top, then dip it down next to the +% ket so the pair reads as a fused Liouville leg. |A>> +% #1-ket = lower left (j), #1-bra = upper left (k). +\newcommand{\PTVectoriseToKet}[1]{% + \draw[PTHilbertBraLeg] + (#1.east) + arc[start angle=270, delta angle=90, radius=\PTVecBendR] + arc[start angle=0, delta angle=90, radius=\PTVecBendR] + -- ($ (#1.west) + (-\PTVecOverhang, 2*\PTVecBendR) $) + .. controls + ($ (#1.west) + (-\PTVecOverhang-0.42*\PTVecDipH, 2*\PTVecBendR) $) + and + ($ (#1.west) + (-\PTVecOverhang-0.58*\PTVecDipH, \PTFuseGap) $) .. + ($ (#1.west) + (-\PTVecOverhang-\PTVecDipH, \PTFuseGap) $) + -- ++(-\PTVecRun,0) coordinate (#1-bra); + \draw[PTHilbertKetLeg] + (#1.west) -- (#1-bra |- #1.west) coordinate (#1-ket); +} + +% Bend a west-port wire over the top (left-bulging semicircle), then dip +% it down next to the east port: < +% becomes the left factor). +\newcommand{\PTVecBend}[3][]{% + \draw[#1] (#2) + .. controls ($ (#2) + (0,-\PTBendDepth) $) + and ($ (#3) + (0,-\PTBendDepth) $) .. + (#3); +} + +% Time arrow: ALWAYS left-pointing. #1 = right coord, #2 = left coord. +% Optional first argument is a TikZ node-anchor for the label (default below). +\newcommand{\PTTimeArrow}[4][below=2.4pt]{% + \draw[ + -{Stealth[length=2.2mm, width=1.55mm]}, + line width=1.05pt, + draw=PTLabel, + line cap=round, + ] (#2) -- (#3) + node[midway, #1, font=\small, text=PTLabel, inner sep=0.6pt] {#4}; +} + +\newcommand{\PTPanelLabel}[2]{% + \node[ + font=\small\bfseries, + text=PTLabel, + inner sep=0.4pt, + anchor=south west, + ] at #1 {#2}; +} + +\newcommand{\PTCallout}[3][]{% + \node[ + font=\scriptsize, + text=PTLabel, + inner sep=0.8pt, + align=left, + #1, + ] at (#2) {#3}; +} + +\newcommand{\PTMapsTo}[2][]{% + \node[font=\small, text=PTLabel, inner sep=1pt, #1] at #2 {\(\longmapsto\)}; +} + +% ----------------------------------------------------------------------------- +% Instruments +% ----------------------------------------------------------------------------- +\newif\ifPTInstClassical +\PTInstClassicalfalse +\def\PTInstKindBox{box} +\def\PTInstKindKet{ket} +\def\PTInstKindBra{bra} +\def\PTInstKindTrace{trace} + +\makeatletter +\define@key{ptinst}{fill}{\def\PTInstFill{#1}} +\define@key{ptinst}{draw}{\def\PTInstDraw{#1}} +\define@key{ptinst}{minimum width}{\def\PTInstMinW{#1}} +\define@key{ptinst}{minimum height}{\def\PTInstMinH{#1}} +\define@key{ptinst}{kind}{\def\PTInstKind{#1}} +\define@key{ptinst}{classical}{\def\PTInstClassDir{#1}} +\define@key{ptinst}{classical label}{\def\PTInstClassLab{#1}} +\define@key{ptinst}{classical length}{\def\PTInstClassLen{#1}} +\define@key{ptinst}{rounded}{\def\PTInstRnd{#1}} +\makeatother + +% Generic constructor. Quantum legs are NOT auto-drawn: connect to the +% node anchors (north, south, east, west, or the triangle bases). +% Classical legs ARE auto-drawn when classical != none. +\newcommand{\PTInstrumentNode}[4][]{% + \def\PTInstFill{PTInstrument}% + \def\PTInstDraw{black}% + \def\PTInstMinW{\PTCoreWidth}% + \def\PTInstMinH{\PTCoreWidth}% + \def\PTInstKind{box}% + \def\PTInstClassDir{none}% + \def\PTInstClassLab{}% + \def\PTInstClassLen{6.2mm}% + \def\PTInstRnd{PTAUTO}% + \setkeys{ptinst}{#1}% + \def\PTInstNone{none}% + \ifx\PTInstKind\PTInstKindKet + \PTSetTriangleRound{\PTKetWidth}{\PTKetHeight}% + \node[ + name=#2, + inner sep=0pt, + outer sep=0pt, + minimum width=\PTKetWidth, + minimum height=\PTKetHeight, + ] at #3 {}; + \begin{scope}[on PTforeground layer] + \filldraw[ + draw=black, + fill=\PTInstFill, + line width=\PTCoreStroke, + line join=round, + rounded corners=\PTRelRound, + ] (#2.west |- #2.south) -- (#2.east) -- (#2.west |- #2.north) -- cycle; + \node[font=\small, text=PTLabel, inner sep=0.4pt] + at ($ (#2.west)!0.36!(#2.east) $) {#4}; + \end{scope} + \else + \ifx\PTInstKind\PTInstKindBra + \PTSetTriangleRound{\PTKetWidth}{\PTKetHeight}% + \node[ + name=#2, + inner sep=0pt, + outer sep=0pt, + minimum width=\PTKetWidth, + minimum height=\PTKetHeight, + ] at #3 {}; + \begin{scope}[on PTforeground layer] + \filldraw[ + draw=black, + fill=\PTInstFill, + line width=\PTCoreStroke, + line join=round, + rounded corners=\PTRelRound, + ] (#2.east |- #2.south) -- (#2.west) -- (#2.east |- #2.north) -- cycle; + \node[font=\small, text=PTLabel, inner sep=0.4pt] + at ($ (#2.east)!0.36!(#2.west) $) {#4}; + \end{scope} + \else + \ifx\PTInstKind\PTInstKindTrace + \begin{scope}[on PTforeground layer] + \node[name=#2, PTTraceNode] at #3 {#4}; + \end{scope} + \else + \def\PTAuto{PTAUTO}% + \ifx\PTInstRnd\PTAuto + \PTSetSquareRound{\PTInstMinW}{\PTInstMinH}% + \else + \setlength{\PTRelRound}{\PTInstRnd}% + \fi + \begin{scope}[on PTforeground layer] + \node[ + name=#2, + PTCore, + fill=\PTInstFill, + draw=\PTInstDraw, + minimum width=\PTInstMinW, + minimum height=\PTInstMinH, + rounded corners=\PTRelRound, + ] at #3 {#4}; + \end{scope} + \fi + \fi + \fi + \ifx\PTInstClassDir\PTInstNone + \else + \def\PTInstDirDown{down}% + \def\PTInstDirUp{up}% + \def\PTInstDirLeft{left}% + \def\PTInstDirRight{right}% + \ifx\PTInstClassDir\PTInstDirDown + \draw[PTClassicalLeg] (#2.south) -- ++(0,-\PTInstClassLen) + coordinate (#2-classical); + \else\ifx\PTInstClassDir\PTInstDirUp + \draw[PTClassicalLeg] (#2.north) -- ++(0,\PTInstClassLen) + coordinate (#2-classical); + \else\ifx\PTInstClassDir\PTInstDirLeft + \draw[PTClassicalLeg] (#2.west) -- ++(-\PTInstClassLen,0) + coordinate (#2-classical); + \else + \draw[PTClassicalLeg] (#2.east) -- ++(\PTInstClassLen,0) + coordinate (#2-classical); + \fi\fi\fi + \def\PTInstClassEmpty{}% + \ifx\PTInstClassLab\PTInstClassEmpty + \else + \node[font=\small, text=PTLabel, inner sep=0.8pt, anchor=north] + at ([yshift=-1.0pt]#2-classical) {\(\PTInstClassLab\)}; + \fi + \fi +} + +\newcommand{\PTIdentity}[4][]{% + \PTInstrumentNode[fill=PTTensor, #1]{#2}{#3}{#4}% +} + +\newcommand{\PTControl}[4][]{% + \PTInstrumentNode[#1]{#2}{#3}{#4}% +} + +\newcommand{\PTUnitary}[4][]{% + \PTInstrumentNode[#1]{#2}{#3}{#4}% +} + +\newcommand{\PTPrepare}[4][]{% + \PTInstrumentNode[kind=ket, fill=PTInstrument, #1]{#2}{#3}{#4}% +} + +\newcommand{\PTMeasure}[4][]{% + \PTInstrumentNode[ + classical=down, + classical label={x}, + #1 + ]{#2}{#3}{#4}% +} + +\newcommand{\PTProjective}[4][]{% + \PTInstrumentNode[ + classical=down, + classical label={x}, + #1 + ]{#2}{#3}{#4}% +} + +% Measurement (earlier, right) then preparation (later, left). +% Quantum wire is broken; the classical outcome is drawn separately. +% Nodes: -E, -P, -x. +\newcommand{\PTCausalBreak}[3][]{% + \begin{scope}[shift={#3}] + \PTInstrumentNode[ + classical=none, + minimum width=8.4mm, + minimum height=8.4mm, + #1 + ]{#2-E}{(0,0)}{\(E^{(x)}\)} + \PTPrepare{#2-P}{(-1.65,0)}{\(\mathcal{P}\)} + \draw[PTClassicalLeg] (#2-E.south) -- ++(0,-5.8mm) coordinate (#2-x); + \node[font=\small, text=PTLabel, inner sep=0.6pt, anchor=north] + at ([yshift=-0.8pt]#2-x) {\(x\)}; + \end{scope} +} + +% Hilbert-space trace: magenta cup closing ket and bra tips. +\newcommand{\PTTraceHilbert}[2]{% + \PTCup[PTTraceCup]{#1}{#2}% + \node[font=\small, text=PTTrace, inner sep=0.5pt, anchor=north] + at ($ (#1)!0.5!(#2) + (0,-\PTBendDepth-0.5pt) $) {\(\mathrm{Tr}\)}; +} + +% Liouville-space trace: compact Tr node sitting on a fused leg. +\newcommand{\PTTraceLiouville}[3]{% + \begin{scope}[on PTforeground layer] + \node[name=#1, PTTraceNode] at #2 {#3}; + \end{scope} +} + +% ----------------------------------------------------------------------------- +% PT-MPO / joint process tensor +% ----------------------------------------------------------------------------- +\newif\ifPTMPOjoint +\newif\ifPTMPOtags +\newif\ifPTMPOchi +\newif\ifPTMPOtimes +\newif\ifPTMPOarrow +\newif\ifPTMPOfinal +\PTMPOjointfalse +\PTMPOtagstrue +\PTMPOchitrue +\PTMPOtimestrue +\PTMPOarrowtrue +\PTMPOfinalfalse + +\makeatletter +\define@key{ptmpo}{n}{\def\PTMPOn{#1}} +\define@key{ptmpo}{at}{\def\PTMPOat{#1}} +\define@key{ptmpo}{name}{\def\PTMPOname{#1}} +\define@key{ptmpo}{spacing}{\setlength{\PTMPOspacing}{#1}} +\define@key{ptmpo}{joint}{\csname PTMPOjoint#1\endcsname} +\define@key{ptmpo}{tags}{\csname PTMPOtags#1\endcsname} +\define@key{ptmpo}{chi}{\csname PTMPOchi#1\endcsname} +\define@key{ptmpo}{times}{\csname PTMPOtimes#1\endcsname} +\define@key{ptmpo}{time arrow}{\csname PTMPOarrow#1\endcsname} +\define@key{ptmpo}{final}{\csname PTMPOfinal#1\endcsname} +\define@key{ptmpo}{label}{\def\PTMPOlabel{#1}} +\makeatother + +% Draw n time cores W^{[0]} (right, t_0) ... W^{[n-1]} (left). +% Node names: -W- +% IO tips: -out- (process output, RIGHT of pair) +% -in- (process input, LEFT of pair) +% Time arrow: -t0 (right) --> -tn (left) +\newcommand{\PTDrawProcessMPO}[1][]{% + \PTMPOjointfalse + \PTMPOtagstrue + \PTMPOchitrue + \PTMPOtimestrue + \PTMPOarrowtrue + \PTMPOfinalfalse + \setlength{\PTMPOspacing}{\PTTimeSpacing}% + \def\PTMPOn{3}% + \def\PTMPOat{0,0}% + \def\PTMPOname{pt}% + \def\PTMPOlabel{}% + \setkeys{ptmpo}{#1}% + \coordinate (\PTMPOname-origin) at (\PTMPOat); + \edef\PTMPOlast{\PTMPOname-W-\the\numexpr\PTMPOn-1\relax}% + % --- cores, right (k=0) to left ------------------------------------------ + \foreach \k in {0,...,\the\numexpr\PTMPOn-1\relax} {% + \path (\PTMPOname-origin) + ++(-\k*\PTMPOspacing,0) + coordinate (\PTMPOname-c-\k); + \PTProcessCore{\PTMPOname-W-\k}{(\PTMPOname-c-\k)}{\(W^{[\k]}\)}% + % Local ports: output on the right, input on the left (local time). + \coordinate (\PTMPOname-outsrc-\k) at + ([xshift=\PTIOSep]\PTMPOname-W-\k.south); + \coordinate (\PTMPOname-insrc-\k) at + ([xshift=-\PTIOSep]\PTMPOname-W-\k.south); + \draw[PTLiouvilleLeg] + (\PTMPOname-outsrc-\k) -- ++(0,-\PTLegLength) + coordinate (\PTMPOname-out-\k); + \draw[PTLiouvilleLeg] + (\PTMPOname-insrc-\k) -- ++(0,-\PTLegLength) + coordinate (\PTMPOname-in-\k); + }% + % --- joint body (no exposed link indices) or MPO links ------------------- + \ifPTMPOjoint + \node[ + fit=(\PTMPOname-W-0) (\PTMPOlast), + inner xsep=3.6pt, + inner ysep=4.6pt, + rounded corners=5.5pt, + fill=PTProcess!55, + draw=black, + line width=0.7pt, + ] (\PTMPOname-body) {}; + \draw[ + draw=PTProcess!70!black, + line width=3.4pt, + line cap=round, + ] (\PTMPOlast.center) -- (\PTMPOname-W-0.center); + \else + \ifnum\PTMPOn>1 + \foreach \k [ + evaluate=\k as \knext using int(\k+1) + ] in {0,...,\the\numexpr\PTMPOn-2\relax} {% + \draw[PTLinkLeg] + (\PTMPOname-W-\k.west) -- (\PTMPOname-W-\knext.east) + coordinate[midway] (\PTMPOname-link-\k); + \ifPTMPOchi + \node[font=\small, text=PTLabel, inner sep=0.5pt, above=1.4pt] + at (\PTMPOname-link-\k) {\(\chi_{\k}\)}; + \fi + \ifPTMPOtags + \PTIndexLabel[ + anchor=south, + tag={PTLink,t-0\k}, + yshift=11.0pt, + ]{\PTMPOname-link-\k}% + \fi + }% + \fi + % Dimension-1 boundary stubs. + \draw[PTLinkLeg] (\PTMPOname-W-0.east) -- ++(4.2mm,0) + coordinate (\PTMPOname-bnd-R); + \begin{scope}[on PTforeground layer] + \node[PTDummyBond] (\PTMPOname-dummy-R) at (\PTMPOname-bnd-R) {}; + \end{scope} + \draw[PTLinkLeg] + (\PTMPOlast.west) -- ++(-4.2mm,0) + coordinate (\PTMPOname-bnd-L); + \begin{scope}[on PTforeground layer] + \node[PTDummyBond] (\PTMPOname-dummy-L) at (\PTMPOname-bnd-L) {}; + \end{scope} + \fi + % --- optional final (t_n) physical output on the far left ---------------- + \ifPTMPOfinal + \coordinate (\PTMPOname-final-src) at ([yshift=1.6mm]\PTMPOlast.west); + \draw[PTLiouvilleLeg] (\PTMPOname-final-src) -- ++(-7.5mm,0) + coordinate (\PTMPOname-final); + \fi + % --- time labels and left-pointing arrow --------------------------------- + \coordinate (\PTMPOname-t0) at ([yshift=-15.2mm, xshift=5.0mm]\PTMPOname-W-0.south); + \coordinate (\PTMPOname-tn) at ([yshift=-15.2mm, xshift=-5.0mm]\PTMPOlast.south); + \ifPTMPOtimes + \node[font=\small, text=PTLabel, inner sep=0.4pt, anchor=west] + at (\PTMPOname-t0) {\(t_{0}\)}; + \node[font=\small, text=PTLabel, inner sep=0.4pt, anchor=east] + at (\PTMPOname-tn) {\(t_{n}\)}; + \fi + \ifPTMPOarrow + \PTTimeArrow[below=1.2pt]{\PTMPOname-t0}{\PTMPOname-tn}{\(t\)}% + \fi + \ifPTMPOtags + \PTIndexLabel[ + anchor=west, + math={\ell_{\mathrm{out}}}, + tag={Output,t-00}, + xshift=3.0pt, + ]{\PTMPOname-out-0}% + \PTIndexLabel[ + anchor=east, + math={\ell_{\mathrm{in}}}, + tag={Input,t-00}, + xshift=-3.0pt, + yshift=-4.2pt, + ]{\PTMPOname-in-0}% + \fi + \def\PTMPOlabelEmpty{}% + \ifx\PTMPOlabel\PTMPOlabelEmpty + \else + \node[font=\small, text=PTLabel, inner sep=1pt, above=3.6pt] + at ($ (\PTMPOname-W-0.north)!0.5!(\PTMPOlast.north) $) + {\PTMPOlabel}; + \fi +} + +\newcommand{\PTDrawJointProcess}[1][]{% + \PTDrawProcessMPO[joint=true,chi=false,tags=false,#1]% +} + +% ----------------------------------------------------------------------------- +% Example of a future instrument (commented; copy and adapt). +% ----------------------------------------------------------------------------- +% \newcommand{\PTReset}[4][]{% +% \PTInstrumentNode[fill=PTInstrument, classical=none, #1]{#2}{#3}{#4}% +% } +% % Tester / ancilla interaction (two-site box): +% \newcommand{\PTTester}[4][]{% +% \PTInstrumentNode[ +% fill=PTInstrument, +% minimum width=1.55\PTCoreWidth, +% #1 +% ]{#2}{#3}{#4}% +% } +% % Feedback-conditioned operation: reuse classical=down and wire it into +% % a later \PTInstrumentNode. +% +% This file is a library. Compile pt_tikz_demo.tex for a catalogue of +% every primitive. diff --git a/docs/tikz/quantum_channel.tex b/docs/tikz/quantum_channel.tex new file mode 100644 index 0000000..e4ed027 --- /dev/null +++ b/docs/tikz/quantum_channel.tex @@ -0,0 +1,6 @@ +\documentclass[tikz]{standalone} +\usepackage{amsmath,amssymb} +\input{pt_tikz_styles.tex} +\begin{document} +\input{quantum_channel.tikz} +\end{document} diff --git a/docs/tikz/quantum_channel.tikz b/docs/tikz/quantum_channel.tikz new file mode 100644 index 0000000..d150890 --- /dev/null +++ b/docs/tikz/quantum_channel.tikz @@ -0,0 +1,59 @@ +% A one-time quantum channel. Time runs right to left. +% Requires pt_tikz_styles.tex in the preamble. +\begin{tikzpicture}[PTPicture] + \def\mapSide{11.0mm}% + \def\ketAlt{9.526mm}% + \tikzset{ + g5eq/.style={ + font=\footnotesize, + text=PTLabel, + align=center, + inner sep=0.4pt, + anchor=north, + }, + } + \newcommand{\GFiveMapKet}[3]{% + \node[ + name=#1, + inner sep=0pt, + outer sep=0pt, + minimum width=\ketAlt, + minimum height=\mapSide, + ] at #2 {}; + \begin{scope}[on PTforeground layer] + \PTSetTriangleRound{\ketAlt}{\mapSide}% + \filldraw[ + draw=black, + fill=PTLiouville!16, + line width=\PTCoreStroke, + line join=round, + rounded corners=\PTRelRound, + ] + (#1.west |- #1.south) -- (#1.east) -- (#1.west |- #1.north) -- cycle; + \node[ + font=\scriptsize, + text=PTLabel, + inner sep=0pt, + outer sep=0pt, + ] + at ($ (#1.west)!0.40!(#1.east) $) {#3}; + \end{scope} + } + + \GFiveMapKet{a-out}{(0.52, 0.48)}{\(\rho(t)\)} + \draw[PTLiouvilleLeg] (a-out.west) -- ++(-0.22,0); + \PTProcessCore[minimum width=\mapSide,minimum height=\mapSide,font=\normalsize] + {a-Phi}{(3.12, 0.48)}{\(\hat{\Phi}_{t:s}\)} + \GFiveMapKet{a-in}{(4.38, 0.48)}{\(\rho(s)\)} + \draw[PTLiouvilleLeg] (a-Phi.east) -- (a-in.west); + \draw[PTLiouvilleLeg] (a-Phi.west) -- ++(-0.24,0) coordinate (a-free); + \draw[ + -{Stealth[length=2.6mm, width=1.8mm]}, + line width=1.15pt, + draw=PTLabel, + line cap=round, + ] ([xshift=-1.8mm]a-free) -- ([xshift=1.8mm]a-out.east); + \node[g5eq] at (2.45,-0.78) + {\(\lvert\rho(t)\rangle\!\rangle + =\hat{\Phi}_{t:s}\lvert\rho(s)\rangle\!\rangle\)}; +\end{tikzpicture} diff --git a/docs/tikz/tensor_contractions.tex b/docs/tikz/tensor_contractions.tex new file mode 100644 index 0000000..243a5c3 --- /dev/null +++ b/docs/tikz/tensor_contractions.tex @@ -0,0 +1,6 @@ +\documentclass[tikz]{standalone} +\usepackage{amsmath,amssymb} +\input{pt_tikz_styles.tex} +\begin{document} +\input{tensor_contractions.tikz} +\end{document} diff --git a/docs/tikz/tensor_contractions.tikz b/docs/tikz/tensor_contractions.tikz new file mode 100644 index 0000000..a9b2d8f --- /dev/null +++ b/docs/tikz/tensor_contractions.tikz @@ -0,0 +1,62 @@ +% Open legs, a contraction, and a trace. +% Requires pt_tikz_styles.tex in the preamble. +\begin{tikzpicture}[PTPicture] + \setlength{\PTCoreStroke}{1.95pt} + \setlength{\PTHilbertLW}{1.8pt} + \setlength{\PTBondLW}{1.85pt} + \tikzset{ + tvlab/.style={ + font=\small\bfseries, + text=PTLabel, + inner sep=1.0pt, + anchor=north west, + }, + tveq/.style={ + font=\small, + text=PTLabel, + align=center, + inner sep=0.3pt, + anchor=north, + }, + } + + % (a) two open legs + \begin{scope}[shift={(0,0)}] + \node[tvlab] at (0, 2.05) {(a)}; + \PTTensor[minimum width=11mm, minimum height=11mm, fill=PTHilbertKet!28]{aT}{(1.85,0.78)}{\(A\)} + \draw[PTHilbertKetLeg] (aT.west) -- ++(-0.68,0) coordinate (a-i); + \draw[PTHilbertBraLeg primed] (aT.east) -- ++(0.68,0) coordinate (a-j); + \PTIndexLabel[anchor=east, math={i}, xshift=-1.6pt]{a-i} + \PTIndexLabel[anchor=west, math={j}, xshift=1.6pt]{a-j} + \node[tveq] at (1.85,-0.15) {open legs}; + \end{scope} + + % (b) contracted leg + \begin{scope}[shift={(4.35,0)}] + \node[tvlab] at (0, 2.05) {(b)}; + \PTTensor[minimum width=11mm, minimum height=11mm, fill=PTHilbertKet!28]{bA}{(1.05,0.78)}{\(A\)} + \PTTensor[minimum width=11mm, minimum height=11mm, fill=PTHilbertBra!34]{bB}{(2.95,0.78)}{\(B\)} + \draw[PTHilbertKetLeg] (bA.west) -- ++(-0.55,0) coordinate (b-i); + \draw[PTBondLeg] (bA.east) -- (bB.west) coordinate[midway] (b-j); + \draw[PTHilbertBraLeg primed] (bB.east) -- ++(0.55,0) coordinate (b-k); + \PTIndexLabel[anchor=east, math={i}, xshift=-1.4pt]{b-i} + \PTIndexLabel[anchor=south, math={j}, yshift=1.6pt]{b-j} + \PTIndexLabel[anchor=west, math={k}, xshift=1.4pt]{b-k} + \node[tveq] at (2.00,-0.15) {contracted leg}; + \end{scope} + + % (c) trace. i sits in the gap between the core and the lower bar. + % The caption stays on the same line as the other panels. + \begin{scope}[shift={(8.85,0)}] + \node[tvlab] at (0, 2.20) {(c)}; + \PTTensor[minimum width=11mm, minimum height=11mm, fill=PTHilbertKet!28]{cA}{(1.70,1.06)}{\(A\)} + \draw[PTWire] + (cA.west) + arc[start angle=90, delta angle=180, radius=4.7mm] + -- ([yshift=-9.4mm]cA.east) + arc[start angle=270, delta angle=180, radius=4.7mm]; + \coordinate (cA-loopmid) at ([yshift=-9.4mm]cA.center); + \PTIndexLabel[anchor=south, math={i}, yshift=1.6pt]{cA-loopmid} + \node[tveq] at (1.70,-0.15) {trace}; + \end{scope} +\end{tikzpicture} diff --git a/scripts/boundary_driven_xxz_transport.jl b/scripts/boundary_driven_xxz_transport.jl deleted file mode 100644 index e35a6ac..0000000 --- a/scripts/boundary_driven_xxz_transport.jl +++ /dev/null @@ -1,495 +0,0 @@ -# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors -# SPDX-License-Identifier: MIT -# -# File: scripts/boundary_driven_xxz_transport.jl -# Contributor: Gauthameshwar S. -# -# Evolves a boundary-driven XXZ spin chain for several anisotropies with -# Liouville-space TDVP and plots current buildup, magnetisation, and bond currents. -# -# Run with: -# julia --project=. scripts/boundary_driven_xxz_transport.jl - -import Pkg - -const REPO_ROOT = dirname(@__DIR__) -const _PLOT_ENV = joinpath(@__DIR__, ".plot_examples_env") - -function activate_plot_examples_env!() - mkpath(_PLOT_ENV) - Pkg.activate(_PLOT_ENV) - manifest = joinpath(_PLOT_ENV, "Manifest.toml") - if !isfile(manifest) - Pkg.develop(Pkg.PackageSpec(path=REPO_ROOT)) - Pkg.add([ - Pkg.PackageSpec(name="CairoMakie"), - Pkg.PackageSpec(name="LaTeXStrings"), - ]) - else - Pkg.resolve() - Pkg.instantiate() - end - return nothing -end - -activate_plot_examples_env!() - -using Printf -using Statistics: mean -using CairoMakie -using ITensors -using LaTeXStrings -using ProcessTensors - -CairoMakie.activate!() - -# ------------------------------------------------------------------------------ -# 1. Small script utilities -# ------------------------------------------------------------------------------ - -const STATUS_WIDTH = 96 - -function print_section(title::AbstractString) - println() - println(title) - println("-"^length(title)) -end - -function update_status(message::AbstractString) - print("\r", rpad(message, STATUS_WIDTH)) - flush(stdout) -end - -function finish_status(message::AbstractString="") - print("\r", " "^STATUS_WIDTH, "\r") - if !isempty(message) - println(message) - end - flush(stdout) -end - -function xxz_hamiltonian(N::Int, J::Float64, Δ::Float64) - H = OpSum() - for j in 1:(N - 1) - H += J, "Sx", j, "Sx", j + 1 - H += J, "Sy", j, "Sy", j + 1 - H += J * Δ, "Sz", j, "Sz", j + 1 - end - return H -end - -function boundary_jump_ops(N::Int, Γ::Float64, μ::Float64) - return [ - (Γ * (1 + μ), "S+", 1), - (Γ * (1 - μ), "S-", 1), - (Γ * (1 - μ), "S+", N), - (Γ * (1 + μ), "S-", N), - ] -end - -function magnetisation_liouville_ops(physical_sites, liouville_sites) - N = length(physical_sites) - return [ - let observable = OpSum() - observable += 1.0, "Sz", j - to_liouville(MPO(observable, physical_sites); sites=liouville_sites) - end for j in 1:N - ] -end - -function current_liouville_ops(physical_sites, liouville_sites, J::Float64) - N = length(physical_sites) - return [ - let observable = OpSum() - # Continuity current for the XXZ convention used above. - # Transport-regime labels (ballistic / diffusive / insulating) - # require system-size scaling and are intentionally not attempted here. - observable += J, "Sx", j, "Sy", j + 1 - observable += -J, "Sy", j, "Sx", j + 1 - to_liouville(MPO(observable, physical_sites); sites=liouville_sites) - end for j in 1:(N - 1) - ] -end - -# ------------------------------------------------------------------------------ -# 2. User-adjustable parameters -# ------------------------------------------------------------------------------ - -const N = 8 -const exchange = 1.0 -const anisotropies = [0.0, 0.5, 1.0] -const bath_coupling = 1.0 -const bath_bias = 0.4 - -const dt = 0.05 -const final_time = 12.0 -const nsite = 2 -const maxdim = 200 -const cutoff = 1e-10 - -const late_time_window = 20 -const current_drift_warn = 5e-4 -# At N=8 and final_time=12 the mean bond current settles in time, but the -# bond-current profile typically remains sloped. Raise final_time when a flatter -# profile is needed; the script warns instead of claiming a NESS. -const current_spread_warn = 5e-3 -const trace_assert_tol = 1e-2 -const trace_warn_tol = 5e-4 - -output_dir = joinpath(@__DIR__, "figures") -mkpath(output_dir) -fig_path = joinpath(output_dir, "boundary_driven_xxz_transport.png") - -# ------------------------------------------------------------------------------ -# 3. Physical model and observable construction -# ------------------------------------------------------------------------------ - -print_section("Problem setup") - -println("Boundary-driven XXZ transport: Liouville TDVP for several anisotropies.") -println() -@printf("chain length N = %d\n", N) -@printf("exchange J = %.3f\n", exchange) -@printf("anisotropies Δ = %s\n", join(string.(anisotropies), ", ")) -@printf("bath coupling Γ = %.3f\n", bath_coupling) -@printf("boundary bias μ = %.3f\n", bath_bias) -@printf("timestep dt = %.3f\n", dt) -@printf("final time = %.3f\n", final_time) -@printf("TDVP nsite = %d\n", nsite) -@printf("max bond dimension = %d\n", maxdim) -@printf("SVD cutoff = %.1e\n", cutoff) - -physical_sites = siteinds("S=1/2", N) -liouville_sites = liouv_sites(physical_sites) -middle_bond = N ÷ 2 - -jump_ops = boundary_jump_ops(N, bath_coupling, bath_bias) -magnetisation_ops = magnetisation_liouville_ops(physical_sites, liouville_sites) -current_ops = current_liouville_ops(physical_sites, liouville_sites, exchange) - -initial_state = MPS(physical_sites, fill("Dn", N)) -initial_density = to_dm(initial_state) -initial_density_liouville = - to_liouville(initial_density; sites=liouville_sites) - -nsteps = round(Int, final_time / dt) -@assert isapprox(nsteps * dt, final_time; atol=100eps(Float64)) -times = collect(range(0.0; step=dt, length=nsteps + 1)) - -@assert isapprox(real(tr(initial_density)), 1.0; atol=1e-12) -@assert length(jump_ops) == 4 -@assert length(current_ops) == N - 1 -@assert 1 <= middle_bond <= N - 1 - -# ------------------------------------------------------------------------------ -# 4. Main computation -# ------------------------------------------------------------------------------ - -print_section("Main computation") - -# Store one NamedTuple per anisotropy. The TDVP call stays visible in the loop. -trajectories = NamedTuple[] - -for (run_idx, Δ) in enumerate(anisotropies) - hamiltonian = xxz_hamiltonian(N, exchange, Δ) - liouvillian = liouvillian_mpo( - hamiltonian, - liouville_sites; - jump_ops=jump_ops, - ) - - density = copy(initial_density_liouville) - mean_current = Float64[] - middle_current = Float64[] - trace_errors = Float64[] - bond_dimensions = Int[] - - elapsed = @elapsed begin - for step in eachindex(times) - density_trace = tr(to_hilbert(density)) - bond_currents = [ - real(inner(observable, density) / density_trace) - for observable in current_ops - ] - push!(mean_current, mean(bond_currents)) - push!(middle_current, bond_currents[middle_bond]) - push!(trace_errors, abs(density_trace - 1)) - push!(bond_dimensions, maxlinkdim(density)) - - if step % 10 == 0 || step == length(times) - update_status( - @sprintf( - " Δ=%.1f run %d/%d step %d/%d t=%.2f J̄=%.4f χ=%d", - Δ, - run_idx, - length(anisotropies), - step, - length(times), - times[step], - mean_current[end], - bond_dimensions[end], - ) - ) - end - - step == length(times) && continue - - density = tdvp( - liouvillian, - dt, - density; - time_step=dt, - nsite=nsite, - maxdim=maxdim, - cutoff=cutoff, - outputlevel=0, - ) - end - end - - density_trace = tr(to_hilbert(density)) - magnetisation_profile = [ - real(inner(observable, density) / density_trace) - for observable in magnetisation_ops - ] - current_profile = [ - real(inner(observable, density) / density_trace) - for observable in current_ops - ] - - finish_status( - @sprintf( - " Δ=%.1f complete in %.1f s (final J̄=%.5f, χmax=%d)", - Δ, - elapsed, - mean_current[end], - maximum(bond_dimensions), - ) - ) - - push!( - trajectories, - ( - anisotropy=Δ, - times=times, - mean_current=mean_current, - middle_current=middle_current, - magnetisation_profile=magnetisation_profile, - current_profile=current_profile, - trace_errors=trace_errors, - bond_dimensions=bond_dimensions, - elapsed=elapsed, - ), - ) -end - -# ------------------------------------------------------------------------------ -# 5. Diagnostics and sanity checks -# ------------------------------------------------------------------------------ - -print_section("Diagnostics") - -diagnostics = NamedTuple[] - -for traj in trajectories - Δ = traj.anisotropy - max_trace_error = maximum(traj.trace_errors) - max_bond_dimension = maximum(traj.bond_dimensions) - current_spread = maximum(abs.(traj.current_profile .- mean(traj.current_profile))) - tail_start = max(1, length(traj.mean_current) - late_time_window + 1) - late_segment = traj.mean_current[tail_start:end] - late_time_drift = length(late_segment) < 2 ? 0.0 : maximum(abs.(diff(late_segment))) - magnetisation_drop = - first(traj.magnetisation_profile) - last(traj.magnetisation_profile) - - println() - @printf("Δ = %.1f\n", Δ) - @printf(" runtime = %.3f s\n", traj.elapsed) - @printf(" final mean bond current = %.6f\n", traj.mean_current[end]) - @printf(" final middle-bond current = %.6f\n", traj.middle_current[end]) - @printf(" left-to-right magnetisation drop = %.6f\n", magnetisation_drop) - @printf(" final current spread = %.3e\n", current_spread) - @printf(" late-time mean-current drift = %.3e\n", late_time_drift) - @printf(" max trace error = %.3e\n", max_trace_error) - @printf(" max bond dimension = %d\n", max_bond_dimension) - - @assert all(isfinite, traj.mean_current) - @assert all(isfinite, traj.middle_current) - @assert all(isfinite, traj.magnetisation_profile) - @assert all(isfinite, traj.current_profile) - @assert all(value -> -0.5 - 1e-8 ≤ value ≤ 0.5 + 1e-8, traj.magnetisation_profile) - @assert max_trace_error < trace_assert_tol - - if max_trace_error > trace_warn_tol - @warn "Trace drift exceeds the soft script tolerance." Δ max_trace_error trace_warn_tol - end - if late_time_drift > current_drift_warn - @warn( - "Mean bond current has not settled over the late-time window; the finite chain may still be approaching a nonequilibrium stationary state.", - Δ, - late_time_drift, - current_drift_warn, - final_time, - ) - end - if current_spread > current_spread_warn - @warn( - "Final bond-current profile is not spatially uniform enough to claim a settled transport profile.", - Δ, - current_spread, - current_spread_warn, - ) - end - - push!( - diagnostics, - ( - anisotropy=Δ, - elapsed=traj.elapsed, - final_mean_current=traj.mean_current[end], - final_middle_current=traj.middle_current[end], - magnetisation_drop=magnetisation_drop, - current_spread=current_spread, - late_time_drift=late_time_drift, - max_trace_error=max_trace_error, - max_bond_dimension=max_bond_dimension, - ), - ) -end - -# ------------------------------------------------------------------------------ -# 6. Plotting and saved outputs -# ------------------------------------------------------------------------------ - -print_section("Plotting") - -# Keep one colour / marker / line identity per anisotropy across all panels. -Δ_styles = Dict( - 0.0 => (color=:dodgerblue, linestyle=:solid, marker=:circle), - 0.5 => (color=:darkorange, linestyle=:dash, marker=:rect), - 1.0 => (color=:seagreen, linestyle=:dot, marker=:utriangle), -) - -fig = Figure(size=(980, 980)) -ga = fig[1, 1] = GridLayout() - -ax_current = Axis( - ga[1, 1]; - xlabel=L"$t$", - ylabel=L"$\overline{\mathcal{J}}(t)$", - title="Mean bond current", -) -ax_mag = Axis( - ga[2, 1]; - xlabel=L"site $j$", - ylabel=L"$\langle S_j^z \rangle$", - title="Final magnetisation profile", -) -ax_jprof = Axis( - ga[3, 1]; - xlabel=L"bond $j$", - ylabel=L"$\mathcal{J}_j$", - title="Final bond-current profile", -) - -# Isolated-bath targets ±μ/2 as subtle guides (not the interacting NESS values). -hlines!( - ax_mag, - [bath_bias / 2, -bath_bias / 2]; - color=(:gray, 0.55), - linestyle=:dash, - linewidth=1.6, -) - -legend_plots = AbstractPlot[] -legend_labels = Any[] - -for traj in trajectories - Δ = traj.anisotropy - style = Δ_styles[Δ] - label = LaTeXString("\\Delta = $(string(Δ))") - - p = lines!( - ax_current, - traj.times, - traj.mean_current; - color=style.color, - linestyle=style.linestyle, - linewidth=2.4, - ) - scatterlines!( - ax_mag, - 1:N, - traj.magnetisation_profile; - color=style.color, - linestyle=style.linestyle, - marker=style.marker, - linewidth=2.0, - markersize=11, - ) - scatterlines!( - ax_jprof, - 1:(N - 1), - traj.current_profile; - color=style.color, - linestyle=style.linestyle, - marker=style.marker, - linewidth=2.0, - markersize=11, - ) - push!(legend_plots, p) - push!(legend_labels, label) -end - -axislegend(ax_current, legend_plots, legend_labels; position=:rb, fontsize=11) -axislegend(ax_mag, legend_plots, legend_labels; position=:rt, fontsize=11) -axislegend(ax_jprof, legend_plots, legend_labels; position=:rt, fontsize=11) - -xlims!(ax_current, 0, final_time) -xlims!(ax_mag, 0.5, N + 0.5) -xlims!(ax_jprof, 0.5, (N - 1) + 0.5) - -Label( - ga[0, 1], - "Boundary-driven XXZ transport (N=$N, J=$exchange, Γ=$bath_coupling, μ=$bath_bias)", - fontsize=16, - tellwidth=false, -) - -rowgap!(ga, 18) -save(fig_path, fig) -println("Saved figure:") -println(" $fig_path") - -# ------------------------------------------------------------------------------ -# 7. Final summary -# ------------------------------------------------------------------------------ - -print_section("Summary") - -println("Completed boundary-driven XXZ transport script.") -println() -println("Main output:") -println(" figure: $fig_path") -println() -println("Per-anisotropy diagnostics:") -for diag in diagnostics - @printf( - " Δ=%.1f: J̄=%.6f, spread=%.3e, late drift=%.3e, χmax=%d\n", - diag.anisotropy, - diag.final_mean_current, - diag.current_spread, - diag.late_time_drift, - diag.max_bond_dimension, - ) -end -println() -@printf(" max trace error over all runs = %.3e\n", maximum(d -> d.max_trace_error, diagnostics)) -@printf(" max bond dimension over all runs = %d\n", maximum(d -> d.max_bond_dimension, diagnostics)) -println() -println( - "Interpretation is limited to finite-chain transport under fixed baths: ", - "anisotropies reshape the magnetisation drop and transmitted current. ", - "Ballistic / diffusive / insulating classifications require system-size scaling ", - "and are intentionally not claimed here.", -) diff --git a/scripts/central_spin_ace.jl b/scripts/central_spin_ace.jl index 4cdf20c..3cc665d 100644 --- a/scripts/central_spin_ace.jl +++ b/scripts/central_spin_ace.jl @@ -4,616 +4,219 @@ # File: scripts/central_spin_ace.jl # Contributor: Gauthameshwar S. # -# Reproduces the fully polarized central-spin model of Cygorek et al., -# Nature Physics 18, 662–668 (2022), Fig. 4a, with ProcessTensors.jl. +# Fully polarised central-spin benchmark: H = (J/N) Σ_k S⋅s_k, ħ=1. +# Central spin initially +x, bath spins +z, no free Hamiltonians. +# Based on Cygorek et al., Nature Physics 18, 662–668 (2022), Fig. 4a. # # Run with: -# julia --project=. scripts/central_spin_ace.jl +# julia --project=. -t auto scripts/central_spin_ace.jl # -# Matching ACE caches in scripts/.cache skip process-tensor construction. -# Override the cache directory with PT_CENTRAL_CACHE_DIR, or force a rebuild -# with PT_ACE_REBUILD=1. +# Set PT_ACE_REBUILD=1 after changing model code or package versions. +# PT_CENTRAL_CACHE_DIR overrides the cache directory. -import Pkg +# --- Parameters: start here when exploring --- +const J = 1.0 +const dt = 0.01 +const final_time = 20.0 +const nsteps = round(Int, final_time / dt) + 1 +const N_bath_values = [5, 10, 100, 1000] # Use [5, 10] for a shorter first run. +const ace_cutoff = 1e-10 +const ace_maxdim = 1024 +const ace_compression = :zipup_cpp +const trace_warning_tolerance = 1e-4 +const spin_bound_tolerance = 1e-4 +const reference_nmarkers = 21 +const log_error_floor = 1e-16 +const line_colors = [:dodgerblue, :darkorange, :seagreen, :mediumpurple] +@assert dt > 0 && J > 0 && all(N -> N > 0, N_bath_values) +@assert !isempty(N_bath_values) && isapprox((nsteps - 1) * dt, final_time) +# --- Plot environment (same automatic setup as the original script) --- +import Pkg const REPO_ROOT = dirname(@__DIR__) -const _PLOT_ENV = joinpath(@__DIR__, ".plot_examples_env") - -function activate_plot_examples_env!() - mkpath(_PLOT_ENV) - Pkg.activate(_PLOT_ENV) - manifest = joinpath(_PLOT_ENV, "Manifest.toml") - - if !isfile(manifest) - Pkg.develop(Pkg.PackageSpec(path=REPO_ROOT)) - Pkg.add([ - Pkg.PackageSpec(name="CairoMakie"), - Pkg.PackageSpec(name="LaTeXStrings"), - ]) - else - Pkg.resolve() - Pkg.instantiate() - end - return nothing +plot_env = joinpath(@__DIR__, ".plot_examples_env") +Pkg.activate(plot_env) +if !isfile(joinpath(plot_env, "Manifest.toml")) + Pkg.develop(Pkg.PackageSpec(path=REPO_ROOT)) + Pkg.add(["CairoMakie", "LaTeXStrings"]) +else + Pkg.resolve() + Pkg.instantiate() end -activate_plot_examples_env!() - using Logging using LinearAlgebra -using Printf using Serialization using CairoMakie using ITensors using ITensors.Ops: Trotter -using LaTeXStrings using ProcessTensors - CairoMakie.activate!() -function slim_process_tensor(pt) - return ProcessTensor( - pt.core, - pt.system, - nothing, - pt.dt, - pt.nsteps, - pt.coupling_site, - ) -end - -function save_ace_cache(path, payload) - mkpath(dirname(path)) - open(path, "w") do io - serialize(io, payload) - end - return path -end - -function cache_parameter_mismatch(metadata, params, keys, format) - mismatches = String[] - hasproperty(metadata, :format) || return ["format: missing"] - metadata.format == format || return [ - "format: cache=$(metadata.format) script=$format", - ] - for key in keys - cached = getproperty(metadata, key) - current = getproperty(params, key) - agrees = if cached isa Integer && current isa Integer - cached == current - elseif cached isa Number && current isa Number - isapprox(cached, current; atol=0, rtol=1e-12) - else - cached == current - end - agrees || push!(mismatches, "$key: cache=$cached script=$current") - end - return mismatches -end - -function load_or_build_ace_cache( - path, - params, - keys, - format, - builder; - label::AbstractString, -) - force_rebuild = get(ENV, "PT_ACE_REBUILD", "0") == "1" - if force_rebuild - println("PT_ACE_REBUILD=1; constructing $label.") - elseif isfile(path) - payload = try - open(deserialize, path) - catch err - @warn "Could not read the process-tensor cache; rebuilding." exception = ( - err, - catch_backtrace(), - ) - nothing - end - if payload !== nothing - mismatches = cache_parameter_mismatch(payload.metadata, params, keys, format) - if isempty(mismatches) - println("Found a matching ACE cache; skipping process-tensor construction.") - println(" cache file: $path") - if hasproperty(payload.metadata, :maxlinkdim) - @printf( - " maximum PT bond dimension: %d\n", - payload.metadata.maxlinkdim, - ) - end - return payload, 0.0 - end - println("Cached process tensor does not match the script parameters; rebuilding.") - for line in mismatches - println(" $line") - end - end - else - println("No process-tensor cache at $path; building.") - end - - build_seconds = @elapsed begin - payload = builder() - end - save_ace_cache(path, payload) - @printf(" ACE build time: %.3f s\n", build_seconds) - @printf(" wrote cache: %s\n", path) - if hasproperty(payload.metadata, :maxlinkdim) - @printf(" maximum PT bond dimension: %d\n", payload.metadata.maxlinkdim) - end - return payload, build_seconds -end - -# ------------------------------------------------------------------------------ -# 1. Small script utilities -# ------------------------------------------------------------------------------ - -const STATUS_WIDTH = 100 - -function print_section(title::AbstractString) - println() - println(title) - println("-"^length(title)) -end - -function update_status(message::AbstractString) - print("\r", rpad(message, STATUS_WIDTH)) - flush(stdout) -end - -function finish_status(message::AbstractString="") - print("\r", " "^STATUS_WIDTH, "\r") - isempty(message) || println(message) - flush(stdout) -end - -function one_site_density_matrix(ρ) - T = foldl(*, ρ) - site = only(filter(i -> plev(i) == 0 && hastags(i, "Site"), inds(T))) - return ComplexF64.(Array(T, prime(site), site)) -end - -function uniform_sample_indices(n::Int; nmarkers::Int) - nmarkers = clamp(nmarkers, 1, n) - return unique(round.(Int, range(1, n; length=nmarkers))) -end - -function central_spin_diagnostics(trajectory, Sx_matrix) - sx = Float64[] - trace_errors = Float64[] - hermiticity_errors = Float64[] - - for ρ in trajectory.states_hilbert - ρ_matrix = one_site_density_matrix(ρ) - trace_value = tr(ρ_matrix) - ρ_norm = norm(ρ_matrix) - push!(sx, real(tr(Sx_matrix * ρ_matrix) / trace_value)) - push!(trace_errors, abs(trace_value - 1)) - push!( - hermiticity_errors, - ρ_norm == 0 ? 0.0 : norm(ρ_matrix - ρ_matrix') / ρ_norm, - ) - end - - analytical_sx = 0.5 .* cos.(trajectory.times ./ 2) - ed_errors = abs.(sx .- analytical_sx) - return sx, trace_errors, hermiticity_errors, ed_errors, analytical_sx -end - -function polarized_spin_density(physical_site, liouville_site) - return to_liouville( - to_dm(MPS([physical_site], ["Up"])); - sites=[liouville_site], - ) -end +output_dir = joinpath(@__DIR__, "figures") +cache_dir = get(ENV, "PT_CENTRAL_CACHE_DIR", joinpath(@__DIR__, ".cache")) +mkpath(output_dir) +mkpath(cache_dir) +figure_path = joinpath(output_dir, "central_spin_ace.png") -function polarized_central_spin_bath(N_bath::Int; J::Real) - Jk = J / N_bath - bath_sites = siteinds("S=1/2", N_bath) - bath_liouville_sites = liouv_sites(bath_sites) +# --- Three small utilities: bath assembly, cache reading, trajectory diagnostics --- +# Only constructors with intentional zero Hamiltonians have their warnings silenced. +function polarised_bath(N, J) + sites = siteinds("S=1/2", N) + liouville_sites = liouv_sites(sites) + coupling = OpSum() + coupling += J / N, "Sx", 1, "Sx", 2 + coupling += J / N, "Sy", 1, "Sy", 2 + coupling += J / N, "Sz", 1, "Sz", 2 modes = SpinMode[] - - with_logger(NullLogger()) do - for k in 1:N_bath - update_status(" preparing polarized bath mode $k / $N_bath") - - coupling = OpSum() - coupling += Jk, "Sx", 1, "Sx", 2 - coupling += Jk, "Sy", 1, "Sy", 2 - coupling += Jk, "Sz", 1, "Sz", 2 - - push!( - modes, - spin_mode( - [bath_liouville_sites[k]], - OpSum(), - polarized_spin_density(bath_sites[k], bath_liouville_sites[k]); - coupling=coupling, - ), - ) - end + for k in 1:N + ρ = to_dm(MPS([sites[k]], ["Up"])) + ρ_l = to_liouville(ρ; sites=[liouville_sites[k]]) + mode = with_logger(() -> spin_mode([liouville_sites[k]], OpSum(), ρ_l; + coupling=copy(coupling)), NullLogger()) + push!(modes, mode) end - - finish_status(" prepared $N_bath polarized bath modes") - bath = with_logger(NullLogger()) do - spin_bath(modes) + return spin_bath(modes) +end + +# Early returns keep cache handling separate from the visible construction call. +# Compare all construction settings; corrupted or incompatible caches rebuild. +function read_cache(path, params) + get(ENV, "PT_ACE_REBUILD", "0") == "1" && return nothing + isfile(path) || return nothing + try + payload = deserialize(path) + metadata = payload.metadata + metadata.format == 2 || return nothing + matches = all(k -> hasproperty(metadata, k) && + getproperty(metadata, k) == getproperty(params, k), keys(params)) + matches || return nothing + hasproperty(payload, :process_tensor) && hasproperty(payload, :system_sites) || return nothing + return payload + catch err + err isa InterruptException && rethrow() + @warn "Unreadable cache; rebuilding" path exception=err + return nothing end - return bath end -# ------------------------------------------------------------------------------ -# 2. User-adjustable parameters -# ------------------------------------------------------------------------------ - -const J = 1.0 -const dt = 0.01 -const final_time = 20.0 -const nsteps = round(Int, final_time / dt) + 1 -const ace_cutoff = 1e-10 -const ace_maxdim = 1024 -const ace_compression = :zipup_cpp -const N_bath_values = [5, 10, 100, 1000] -const published_polarized_rank = 4 -const trace_warning_tolerance = 1e-4 -const spin_bound_tolerance = 1e-4 -const ed_nmarkers = 21 -const log_error_floor = 1e-16 -const line_colors = [:dodgerblue, :darkorange, :seagreen, :mediumpurple] - -output_dir = joinpath(@__DIR__, "figures") -mkpath(output_dir) -figure_path = joinpath(output_dir, "central_spin_ace.png") -cache_dir = get( - ENV, - "PT_CENTRAL_CACHE_DIR", - joinpath(@__DIR__, ".cache"), -) -const CENTRAL_CACHE_FORMAT = 1 -const CENTRAL_CACHE_KEYS = ( - :N_bath, - :J, - :dt, - :final_time, - :nsteps, - :ace_cutoff, - :ace_maxdim, - :ace_compression, -) - -# ------------------------------------------------------------------------------ -# 3. Physical problem -# ------------------------------------------------------------------------------ - -print_section("Cygorek polarized central-spin benchmark") - -println("Reproducing Fig. 4a of Cygorek et al.: fully polarized bath, N sweep.") -println(" H_S: 0") -println(" bath free Hamiltonians: 0") -println(" bath initial state: all spins along +z") -println(" interaction: J_k (Sx sx + Sy sy + Sz sz)") -println(" coupling per mode: J_k = J / N") -@printf(" J = ħ: %.1f\n", J) -@printf(" dt: %.3f\n", dt) -@printf(" final time: %.1f\n", final_time) -@printf(" snapshots: %d\n", nsteps) -@printf(" ACE cutoff ε: %.1e\n", ace_cutoff) -@printf(" ACE maxdim safety cap: %d\n", ace_maxdim) -println(" ACE compression: $ace_compression") -println(" ACE mode maps: Hilbert U = exp(-i H Δt), fused onto Liouville PT legs") -println(" N values: $(join(N_bath_values, ", "))") -println(" published polarized d_max: $published_polarized_rank") - -system_sites = siteinds("S=1/2", 1) -system = with_logger(NullLogger()) do - spin_system(system_sites, OpSum()) +function trajectory_diagnostics(trajectory, N, J) + sx, trace_errors, hermiticity_errors = Float64[], Float64[], Float64[] + Sx = ComplexF64[0 1; 1 0] / 2 + for state in trajectory.states_hilbert + tensor = foldl(*, state) + site = only(filter(i -> plev(i) == 0 && hastags(i, "Site"), inds(tensor))) + ρ = ComplexF64.(Array(tensor, prime(site), site)) + z = tr(ρ) + isfinite(z) && abs(z) > 1e-12 || error("Nonfinite or vanishing trace") + push!(sx, real(tr(Sx * ρ) / z)) + push!(trace_errors, abs(z - 1)) + push!(hermiticity_errors, norm(ρ - ρ') / norm(ρ)) + end + times = trajectory.times + exact_sx = (1 .+ N .* cos.(J * (N + 1) .* times ./ (2N))) ./ (2(N + 1)) + limiting_sx = 0.5 .* cos.(J .* times ./ 2) + return (; times, sx, trace_errors, hermiticity_errors, exact_sx, + finite_N_errors=abs.(sx .- exact_sx), + large_N_deviations=abs.(sx .- limiting_sx)) end -initial_density = to_dm(MPS(system_sites, ["+"])) -Sx_matrix = ComplexF64.( - Array( - op("Sx", system_sites[1]), - prime(system_sites[1]), - system_sites[1], - ), -) - -# ------------------------------------------------------------------------------ -# 4. ACE N sweep -# ------------------------------------------------------------------------------ - -print_section("ACE scaling sweep") +# --- Construct (or reload) each process, then evaluate it --- results = Dict{Int,NamedTuple}() - for N_bath in N_bath_values - println() - println("N = $N_bath") - + println((bath_spins=N_bath, coupling=J / N_bath, dt=dt, final_time=final_time)) cache_path = joinpath(cache_dir, "central_spin_ace_N$(N_bath).jls") - cache_params = (; - N_bath, - J, - dt, - final_time, - nsteps, - ace_cutoff, - ace_maxdim, - ace_compression, - ) - payload, build_time = load_or_build_ace_cache( - cache_path, - cache_params, - CENTRAL_CACHE_KEYS, - CENTRAL_CACHE_FORMAT, - () -> begin - bath = polarized_central_spin_bath(N_bath; J=J) - update_status(" building ACE PT: polarized, N=$N_bath") - process_tensor = build_process_tensor( - system; - method=ACE( - cutoff=ace_cutoff, - maxdim=ace_maxdim, - compression=ace_compression, - ), - environment=bath, - dt=dt, - nsteps=nsteps, - sys_alg=Trotter{2}(), - combine_alg=Trotter{2}(), + params = (; N_bath, J, dt, final_time, nsteps, ace_cutoff, ace_maxdim, ace_compression) + payload = read_cache(cache_path, params) + build_time = 0.0 + cache_hit = payload !== nothing + + if !cache_hit + system_sites = siteinds("S=1/2", 1) + system = with_logger(() -> spin_system(system_sites, OpSum()), NullLogger()) + build_time = @elapsed begin + bath = polarised_bath(N_bath, J) + pt = build_process_tensor( + system; environment=bath, dt=dt, nsteps=nsteps, + method=ACE(cutoff=ace_cutoff, maxdim=ace_maxdim, compression=ace_compression), + sys_alg=Trotter{2}(), combine_alg=Trotter{2}(), ) - slim = slim_process_tensor(process_tensor) - return (; - process_tensor=slim, - system_sites, - metadata=(; - format=CENTRAL_CACHE_FORMAT, - N_bath, - J, - dt, - final_time, - nsteps, - ace_cutoff, - ace_maxdim, - ace_compression, - maxlinkdim=maxlinkdim(slim), - ), - ) - end; - label="polarized central-spin ACE process tensor (N=$N_bath)", - ) - - process_tensor = payload.process_tensor - open_system_sites = payload.system_sites - open_initial_density = to_dm(MPS(open_system_sites, ["+"])) - open_Sx_matrix = ComplexF64.( - Array( - op("Sx", open_system_sites[1]), - prime(open_system_sites[1]), - open_system_sites[1], - ), - ) - - finish_status( - @sprintf(" ACE PT ready: polarized N=%4d", N_bath), - ) - - bond_dimensions = Int[d for d in linkdims(process_tensor) if d !== nothing] - isempty(bond_dimensions) && error("Process tensor has no temporal link dimensions.") - max_bond_dimension = maximum(bond_dimensions) - - @printf( - " J_k = %.6f, max(linkdims(PT)) = %d\n", - J / N_bath, - max_bond_dimension, - ) - - if max_bond_dimension >= ace_maxdim - @warn "ACE reached the maxdim safety cap." N_bath=N_bath max_bond_dimension=max_bond_dimension - end - if max_bond_dimension != published_polarized_rank - @warn "Published fully polarized benchmark reports d_max=$published_polarized_rank." N_bath=N_bath max_bond_dimension=max_bond_dimension - end - - update_status(" evolving polarized PT, N=$N_bath") - evolution_time = @elapsed begin - trajectory = evolve(process_tensor, open_initial_density) - end - finish_status( - @sprintf(" trajectory evolved: polarized N=%4d in %.3f s", N_bath, evolution_time), - ) - - sx, trace_errors, hermiticity_errors, ed_errors, _ = - central_spin_diagnostics(trajectory, open_Sx_matrix) - max_trace_error = maximum(trace_errors) - max_hermiticity_error = maximum(hermiticity_errors) - max_spin_bound_excess = max(maximum(abs, sx) - 0.5, 0.0) - analytical_error = maximum(ed_errors) - - @printf( - " max |tr ρ-1| = %.3e, max ‖ρ-ρ†‖/‖ρ‖ = %.3e, max |Sx|-1/2 = %.3e, max |Sx - ED| = %.3e\n", - max_trace_error, - max_hermiticity_error, - max_spin_bound_excess, - analytical_error, - ) - - if max_trace_error > trace_warning_tolerance - @warn "Trace drift exceeds the benchmark warning tolerance." N_bath=N_bath max_trace_error=max_trace_error + end + # Keep the system and temporal cores; omit the original bath from the cache. + slim = ProcessTensor(pt.core, pt.system, nothing, pt.dt, pt.nsteps, pt.coupling_site) + payload = (; process_tensor=slim, system_sites, + metadata=(; format=2, params..., maxlinkdim=maxlinkdim(slim))) + serialize(cache_path * ".tmp", payload) + mv(cache_path * ".tmp", cache_path; force=true) end - max_spin_bound_excess <= spin_bound_tolerance || error( - "Unphysical central-spin trajectory for N=$N_bath: max |Sx|-1/2 = $max_spin_bound_excess.", - ) - results[N_bath] = ( - max_bond_dimension=max_bond_dimension, - bond_dimensions=bond_dimensions, - build_time=build_time, - evolution_time=evolution_time, - times=trajectory.times, - sx=sx, - trace_errors=trace_errors, - hermiticity_errors=hermiticity_errors, - ed_errors=ed_errors, - max_trace_error=max_trace_error, - max_hermiticity_error=max_hermiticity_error, - max_spin_bound_excess=max_spin_bound_excess, - analytical_error=analytical_error, - ) + # Cached Index identities must be reused for the initial preparation. + process_tensor = payload.process_tensor + initial_density = to_dm(MPS(payload.system_sites, ["+"])) + evolution_time = @elapsed trajectory = evolve(process_tensor, initial_density) + diagnostics = trajectory_diagnostics(trajectory, N_bath, J) + bonds = Int[d for d in linkdims(process_tensor) if d !== nothing] + max_bond = maxlinkdim(process_tensor) + max_trace_error = maximum(diagnostics.trace_errors) + max_spin_bound_excess = max(maximum(abs, diagnostics.sx) - 0.5, 0.0) + + @assert all(isfinite, diagnostics.sx) + @assert max_spin_bound_excess <= spin_bound_tolerance "Transverse spin exceeds its physical bound" + max_trace_error > trace_warning_tolerance && @warn "Trace drift exceeds tolerance" N_bath max_trace_error + max_bond >= ace_maxdim && @warn "ACE reached the bond cap; check convergence" N_bath max_bond + results[N_bath] = (; diagnostics..., bond_dimensions=bonds, max_bond_dimension=max_bond, + build_time, evolution_time, cache_hit, max_trace_error, max_spin_bound_excess) + println((cache_hit=cache_hit, max_bond=max_bond, build_seconds=build_time, + evolution_seconds=evolution_time, + max_trace_error=max_trace_error, + max_hermiticity_error=maximum(diagnostics.hermiticity_errors), + max_finite_N_error=maximum(diagnostics.finite_N_errors), + max_large_N_deviation=maximum(diagnostics.large_N_deviations))) +end + +# --- Plot: physical finite-size effects and numerical diagnostics --- +# All reference curves use the actual returned times and the adjustable J. +# Clipping to log_error_floor is for display only; stored diagnostics are raw. +figure = Figure(size=(1400, 900), fontsize=18) +dynamics_axis = Axis(figure[1, 1]; ylabel="⟨Sx⟩ / ħ", + title="Fully polarised central-spin dynamics", + xticklabelsvisible=false, xticksvisible=false) +error_axis = Axis(figure[2, 1]; xlabel="tJ / ħ", ylabel="absolute / relative deviation", + yscale=log10, title="Error against the exact finite-N result") + +for (i, N) in enumerate(N_bath_values) + r = results[N] + color = line_colors[mod1(i, length(line_colors))] + time_axis = J .* r.times + lines!(dynamics_axis, time_axis, r.sx; color=color, linewidth=2.2, label="N = $N") + lines!(error_axis, time_axis, max.(r.finite_N_errors, log_error_floor); + color=color, linestyle=:solid, linewidth=1.8) + lines!(error_axis, time_axis, max.(r.trace_errors, log_error_floor); + color=color, linestyle=:dash, linewidth=1.8) + lines!(error_axis, time_axis, max.(r.hermiticity_errors, log_error_floor); + color=color, linestyle=:dot, linewidth=1.8) end -# ------------------------------------------------------------------------------ -# 5. Figure -# ------------------------------------------------------------------------------ - -print_section("Plotting") - -# Extra width is for the right-hand legends so the stacked panels keep -# the previous 1100 × 850 plot aspect. -figure = Figure(size=(1300, 850), fontsize=18) times = results[last(N_bath_values)].times -ed_sx = 0.5 .* cos.(times ./ 2) -ed_idx = uniform_sample_indices(length(times); nmarkers=ed_nmarkers) - -dynamics_axis = Axis( - figure[1, 1]; - ylabel=L"$\langle S_x\rangle/\hbar$", - title="Fully polarized central-spin dynamics", - xticklabelsvisible=false, - xticksvisible=false, - xlabelvisible=false, -) - -for (i, N_bath) in enumerate(N_bath_values) - trajectory = results[N_bath] - lines!( - dynamics_axis, - trajectory.times, - trajectory.sx; - color=line_colors[i], - linewidth=2.2, - label="N = $N_bath", - ) -end - -scatter!( - dynamics_axis, - times[ed_idx], - ed_sx[ed_idx]; - marker=:x, - markersize=14, - color=:black, - label=L"$N \to \infty$", -) - +marker_indices = unique(round.(Int, range(1, length(times); + length=clamp(reference_nmarkers, 1, length(times))))) +scatter!(dynamics_axis, J .* times[marker_indices], 0.5 .* cos.(J .* times[marker_indices] ./ 2); + marker=:x, markersize=14, color=:black, label="N → ∞") ylims!(dynamics_axis, -0.55, 0.55) - -Legend( - figure[1, 2], - dynamics_axis; - labelsize=16, - nbanks=1, - tellheight=false, - valign=:center, - halign=:left, -) - -error_axis = Axis( - figure[2, 1]; - xlabel=L"$tJ/\hbar$", - ylabel="density-matrix error", - yscale=log10, - title="Trace, Hermiticity, and ED errors", -) - -for (i, N_bath) in enumerate(N_bath_values) - trajectory = results[N_bath] - color = line_colors[i] - lines!( - error_axis, - trajectory.times, - max.(trajectory.ed_errors, log_error_floor); - color=color, - linestyle=:solid, - linewidth=1.8, - ) - lines!( - error_axis, - trajectory.times, - max.(trajectory.trace_errors, log_error_floor); - color=color, - linestyle=:dash, - linewidth=1.8, - ) - lines!( - error_axis, - trajectory.times, - max.(trajectory.hermiticity_errors, log_error_floor); - color=color, - linestyle=:dot, - linewidth=1.8, - ) -end - -Legend( - figure[2, 2], - [ - LineElement(color=:gray40, linestyle=:solid, linewidth=2), - LineElement(color=:gray40, linestyle=:dash, linewidth=2), - LineElement(color=:gray40, linestyle=:dot, linewidth=2), - ], - [ - L"$|\langle S_x\rangle-\frac{1}{2}\cos(t/2)|$", - L"$|\mathrm{tr}\,\rho-1|$", - L"$\Vert\rho-\rho^\dagger\Vert/\Vert\rho\Vert$", - ]; - labelsize=16, - nbanks=1, - tellheight=false, - valign=:center, - halign=:left, -) - +Legend(figure[1, 2], dynamics_axis; labelsize=16, tellheight=false) +Legend(figure[2, 2], + [LineElement(color=:gray40, linestyle=s, linewidth=2) for s in (:solid, :dash, :dot)], + ["|⟨Sx⟩ − exact finite-N|", "|tr ρ − 1|", "‖ρ − ρ†‖ / ‖ρ‖"]; + labelsize=16, tellheight=false) linkxaxes!(dynamics_axis, error_axis) rowgap!(figure.layout, 12) colgap!(figure.layout, 12) -rowsize!(figure.layout, 1, Relative(0.5)) -rowsize!(figure.layout, 2, Relative(0.5)) - save(figure_path, figure) - -println("Saved figure:") -println(" $figure_path") - -# ------------------------------------------------------------------------------ -# 6. Final summary -# ------------------------------------------------------------------------------ - -print_section("Summary") - -println("Completed polarized Cygorek central-spin ACE benchmark.") -println(" figure: $figure_path") - -ranks = [results[N].max_bond_dimension for N in N_bath_values] -analytical_errors = [results[N].analytical_error for N in N_bath_values] - -for N_bath in N_bath_values - result = results[N_bath] - @printf( - " N=%4d J_k=%8.5f chi_max=%4d |trρ-1|_max=%.3e ‖ρ-ρ†‖_max=%.3e |Sx-ED|_max=%.3e build=%8.3f s evolve=%8.3f s\n", - N_bath, - J / N_bath, - result.max_bond_dimension, - result.max_trace_error, - result.max_hermiticity_error, - result.analytical_error, - result.build_time, - result.evolution_time, - ) -end - -println() -println("Sanity checks:") -println(" published d_max = $published_polarized_rank for polarized N = 10, 100, 1000") -println(" observed chi_max = $(join(ranks, ", "))") -println(" analytical errors should decrease with N: $(join(round.(analytical_errors; sigdigits=3), ", "))") +println("Saved figure: $figure_path") + +# The published d_max=4 (N=10,100,1000) is a comparison point, not an invariant +# of every representation/compression setting. Report bonds without asserting it. +println((bath_sizes=N_bath_values, + maximum_bonds=[results[N].max_bond_dimension for N in N_bath_values])) +# Small trace/Hermiticity defects or a physical Sx do not prove positivity. +# For convergence, vary dt/cutoff/maxdim at fixed N and inspect finite_N_errors. diff --git a/scripts/driven_dissipative_bose_hubbard.jl b/scripts/driven_dissipative_bose_hubbard.jl index 44e632b..3673380 100644 --- a/scripts/driven_dissipative_bose_hubbard.jl +++ b/scripts/driven_dissipative_bose_hubbard.jl @@ -8,7 +8,7 @@ # onsite interactions and plots the common pump with the mean-occupation response. # # Run with: -# julia --project=. scripts/driven_dissipative_bose_hubbard.jl +# julia --project=. -t auto scripts/driven_dissipative_bose_hubbard.jl import Pkg diff --git a/scripts/figures/boundary_driven_xxz_transport.png b/scripts/figures/boundary_driven_xxz_transport.png deleted file mode 100644 index ae4ff58..0000000 Binary files a/scripts/figures/boundary_driven_xxz_transport.png and /dev/null differ diff --git a/scripts/figures/central_spin_ace.png b/scripts/figures/central_spin_ace.png index d682fbf..1fd5d09 100644 Binary files a/scripts/figures/central_spin_ace.png and b/scripts/figures/central_spin_ace.png differ diff --git a/scripts/figures/noisy_quantum_circuit_tester.png b/scripts/figures/noisy_quantum_circuit_tester.png index 6e7efab..d7b143d 100644 Binary files a/scripts/figures/noisy_quantum_circuit_tester.png and b/scripts/figures/noisy_quantum_circuit_tester.png differ diff --git a/scripts/figures/noisy_quantum_circuit_tester_protocol.pdf b/scripts/figures/noisy_quantum_circuit_tester_protocol.pdf new file mode 100644 index 0000000..79024f4 Binary files /dev/null and b/scripts/figures/noisy_quantum_circuit_tester_protocol.pdf differ diff --git a/scripts/figures/noisy_quantum_circuit_tester_protocol.png b/scripts/figures/noisy_quantum_circuit_tester_protocol.png new file mode 100644 index 0000000..80b32ed Binary files /dev/null and b/scripts/figures/noisy_quantum_circuit_tester_protocol.png differ diff --git a/scripts/figures/pt_multitime_correlations.png b/scripts/figures/pt_multitime_correlations.png index f8676ce..7f3aa12 100644 Binary files a/scripts/figures/pt_multitime_correlations.png and b/scripts/figures/pt_multitime_correlations.png differ diff --git a/scripts/figures/pt_tfim_multimode.png b/scripts/figures/pt_tfim_multimode.png index 0138b28..e9d2966 100644 Binary files a/scripts/figures/pt_tfim_multimode.png and b/scripts/figures/pt_tfim_multimode.png differ diff --git a/scripts/figures/pt_tfim_singlemode.png b/scripts/figures/pt_tfim_singlemode.png index d966278..0709299 100644 Binary files a/scripts/figures/pt_tfim_singlemode.png and b/scripts/figures/pt_tfim_singlemode.png differ diff --git a/scripts/figures/ramsey_povm_protocol.pdf b/scripts/figures/ramsey_povm_protocol.pdf new file mode 100644 index 0000000..1b2cb7f Binary files /dev/null and b/scripts/figures/ramsey_povm_protocol.pdf differ diff --git a/scripts/figures/ramsey_povm_protocol.png b/scripts/figures/ramsey_povm_protocol.png new file mode 100644 index 0000000..0f06c04 Binary files /dev/null and b/scripts/figures/ramsey_povm_protocol.png differ diff --git a/scripts/figures/ramsey_povm_records.pdf b/scripts/figures/ramsey_povm_records.pdf new file mode 100644 index 0000000..99b4dea Binary files /dev/null and b/scripts/figures/ramsey_povm_records.pdf differ diff --git a/scripts/figures/ramsey_povm_records.png b/scripts/figures/ramsey_povm_records.png new file mode 100644 index 0000000..8f126c4 Binary files /dev/null and b/scripts/figures/ramsey_povm_records.png differ diff --git a/scripts/figures/tdvp_tfim_unitary_conserved.png b/scripts/figures/tdvp_tfim_unitary_conserved.png deleted file mode 100644 index 6066a30..0000000 Binary files a/scripts/figures/tdvp_tfim_unitary_conserved.png and /dev/null differ diff --git a/scripts/figures/tdvp_tfim_unitary_hilbert_dynamics_mx.png b/scripts/figures/tdvp_tfim_unitary_hilbert_dynamics_mx.png deleted file mode 100644 index 88b5dda..0000000 Binary files a/scripts/figures/tdvp_tfim_unitary_hilbert_dynamics_mx.png and /dev/null differ diff --git a/scripts/figures/tdvp_tfim_unitary_hilbert_energy_drift.png b/scripts/figures/tdvp_tfim_unitary_hilbert_energy_drift.png deleted file mode 100644 index 29ac907..0000000 Binary files a/scripts/figures/tdvp_tfim_unitary_hilbert_energy_drift.png and /dev/null differ diff --git a/scripts/figures/tdvp_tfim_unitary_hilbert_rho_error.png b/scripts/figures/tdvp_tfim_unitary_hilbert_rho_error.png deleted file mode 100644 index 600fc49..0000000 Binary files a/scripts/figures/tdvp_tfim_unitary_hilbert_rho_error.png and /dev/null differ diff --git a/scripts/figures/tdvp_tfim_unitary_liouville_dynamics_mx.png b/scripts/figures/tdvp_tfim_unitary_liouville_dynamics_mx.png deleted file mode 100644 index 6479163..0000000 Binary files a/scripts/figures/tdvp_tfim_unitary_liouville_dynamics_mx.png and /dev/null differ diff --git a/scripts/figures/tdvp_tfim_unitary_liouville_energy_drift.png b/scripts/figures/tdvp_tfim_unitary_liouville_energy_drift.png deleted file mode 100644 index a452903..0000000 Binary files a/scripts/figures/tdvp_tfim_unitary_liouville_energy_drift.png and /dev/null differ diff --git a/scripts/figures/tdvp_tfim_unitary_liouville_rho_error.png b/scripts/figures/tdvp_tfim_unitary_liouville_rho_error.png deleted file mode 100644 index f8e0997..0000000 Binary files a/scripts/figures/tdvp_tfim_unitary_liouville_rho_error.png and /dev/null differ diff --git a/scripts/figures/tdvp_tfim_unitary_mx.png b/scripts/figures/tdvp_tfim_unitary_mx.png deleted file mode 100644 index 8596451..0000000 Binary files a/scripts/figures/tdvp_tfim_unitary_mx.png and /dev/null differ diff --git a/scripts/figures/tdvp_tfim_unitary_mz.png b/scripts/figures/tdvp_tfim_unitary_mz.png deleted file mode 100644 index c3115da..0000000 Binary files a/scripts/figures/tdvp_tfim_unitary_mz.png and /dev/null differ diff --git a/scripts/figures/tebd_tfim_unitary_hilbert_dynamics_mx.png b/scripts/figures/tebd_tfim_unitary_hilbert_dynamics_mx.png deleted file mode 100644 index fcdd162..0000000 Binary files a/scripts/figures/tebd_tfim_unitary_hilbert_dynamics_mx.png and /dev/null differ diff --git a/scripts/figures/tebd_tfim_unitary_hilbert_rho_error.png b/scripts/figures/tebd_tfim_unitary_hilbert_rho_error.png deleted file mode 100644 index e610471..0000000 Binary files a/scripts/figures/tebd_tfim_unitary_hilbert_rho_error.png and /dev/null differ diff --git a/scripts/figures/tebd_tfim_unitary_liouville_dynamics_mx.png b/scripts/figures/tebd_tfim_unitary_liouville_dynamics_mx.png deleted file mode 100644 index ecff0d0..0000000 Binary files a/scripts/figures/tebd_tfim_unitary_liouville_dynamics_mx.png and /dev/null differ diff --git a/scripts/figures/tebd_tfim_unitary_liouville_rho_error.png b/scripts/figures/tebd_tfim_unitary_liouville_rho_error.png deleted file mode 100644 index c0a0d04..0000000 Binary files a/scripts/figures/tebd_tfim_unitary_liouville_rho_error.png and /dev/null differ diff --git a/scripts/figures/thermal_spinboson_ace.png b/scripts/figures/thermal_spinboson_ace.png index ebdd900..a531c5f 100644 Binary files a/scripts/figures/thermal_spinboson_ace.png and b/scripts/figures/thermal_spinboson_ace.png differ diff --git a/scripts/laser_driven_tdvp.jl b/scripts/laser_driven_tdvp.jl index 4ba1e54..b05ceac 100644 --- a/scripts/laser_driven_tdvp.jl +++ b/scripts/laser_driven_tdvp.jl @@ -8,7 +8,7 @@ # two-site TDVP and plots its energy and excitation response. # # Run with: -# julia --project=. scripts/laser_driven_tdvp.jl +# julia --project=. -t auto scripts/laser_driven_tdvp.jl import Pkg diff --git a/scripts/noisy_quantum_circuit_tester.jl b/scripts/noisy_quantum_circuit_tester.jl index afa6cb2..2142297 100644 --- a/scripts/noisy_quantum_circuit_tester.jl +++ b/scripts/noisy_quantum_circuit_tester.jl @@ -4,810 +4,245 @@ # File: scripts/noisy_quantum_circuit_tester.jl # Contributor: Gauthameshwar S. # -# Builds or reuses a cached thermal-bosonic ACE process tensor and contracts -# spectator, store–wait–retrieve, phase-tag, and idle-time-sweep tester circuits. +# Reuses one thermal-bath process tensor for a baseline, an idle tester, and +# the SWAP–Z–SWAP protocol, then writes the two-panel figure. # # Run with: -# julia --project=. scripts/noisy_quantum_circuit_tester.jl -# -# A matching cache at scripts/.cache/noisy_quantum_circuit_tester_pt.jls skips ACE -# construction. Override the path with PT_TESTER_CACHE, or force a rebuild -# with PT_TESTER_REBUILD=1. - +# julia --project=. -t auto scripts/noisy_quantum_circuit_tester.jl [--rebuild] + +# --- User parameters: bath, compression, and the three protocol events --- +const N_bath = 24 +const local_dim = 3 +const alpha = 0.008 +const omega_cutoff = 4.0 +const omega_max = 20.0 +const thermal_frequency = 2.5 +const dt = 0.20 +const final_time = 5.0 +const nsteps = round(Int, final_time / dt) + 1 +const ace_cutoff = 1e-5 +const ace_maxdim = 512 +const ace_compression = :zipup_cpp +const store_time_target, phase_time_target, retrieve_time_target = 1.0, 2.0, 3.0 +const trace_warning_tolerance, trace_assert_tolerance = 1e-3, 5e-2 +@assert N_bath > 0 && local_dim >= 2 && alpha >= 0 && omega_max > 0 && dt > 0 +@assert thermal_frequency >= 0 && isfinite(thermal_frequency) +@assert isapprox((nsteps - 1) * dt, final_time; atol=100eps(Float64)) +@assert 0 < store_time_target < phase_time_target < retrieve_time_target <= final_time + +# --- Automatic plot environment, as in the original script --- import Pkg - -const REPO_ROOT = dirname(@__DIR__) -const PLOT_ENV = joinpath(@__DIR__, ".plot_examples_env") - -function activate_plot_environment!() - mkpath(PLOT_ENV) - Pkg.activate(PLOT_ENV) - manifest = joinpath(PLOT_ENV, "Manifest.toml") - if !isfile(manifest) - Pkg.develop(Pkg.PackageSpec(path=REPO_ROOT)) - Pkg.add([ - Pkg.PackageSpec(name="CairoMakie"), - Pkg.PackageSpec(name="LaTeXStrings"), - ]) - else - Pkg.resolve() - Pkg.instantiate() - end - return nothing +plot_env = joinpath(@__DIR__, ".plot_examples_env") +Pkg.activate(plot_env) +if !isfile(joinpath(plot_env, "Manifest.toml")) + Pkg.develop(Pkg.PackageSpec(path=dirname(@__DIR__))) + Pkg.add(["CairoMakie", "LaTeXStrings"]) +else + Pkg.resolve() + Pkg.instantiate() end -activate_plot_environment!() - using LinearAlgebra using Logging -using Printf using Serialization using CairoMakie using ITensors -using LaTeXStrings +using ITensors.Ops: Trotter using ProcessTensors - CairoMakie.activate!() -# ----------------------------------------------------------------------------- -# Bath parameters, ACE construction, and on-disk cache -# ----------------------------------------------------------------------------- - -const TESTER_MEMORY_CACHE_FORMAT = 1 -const CACHE_COMPARE_KEYS = ( - :N_bath, - :local_dim, - :alpha, - :omega_cutoff, - :omega_max, - :thermal_frequency, - :dt, - :final_time, - :nsteps, - :ace_cutoff, - :ace_maxdim, -) - -function tester_memory_parameters() - N_bath = parse(Int, get(ENV, "PT_TESTER_NBATH", "24")) - local_dim = parse(Int, get(ENV, "PT_TESTER_LOCAL_DIM", "3")) - alpha = parse(Float64, get(ENV, "PT_TESTER_ALPHA", "0.008")) - omega_cutoff = 4.0 - omega_max = parse(Float64, get(ENV, "PT_TESTER_OMEGA_MAX", "20.0")) - thermal_frequency = parse(Float64, get(ENV, "PT_TESTER_THERMAL", "2.5")) - dt = parse(Float64, get(ENV, "PT_TESTER_DT", "0.20")) - final_time = parse(Float64, get(ENV, "PT_TESTER_FINAL_TIME", "5.0")) - nsteps = round(Int, final_time / dt) + 1 - ace_cutoff = parse(Float64, get(ENV, "PT_TESTER_ACE_CUTOFF", "1e-5")) - ace_maxdim = parse(Int, get(ENV, "PT_TESTER_ACE_MAXDIM", "512")) - cache_path = get( - ENV, - "PT_TESTER_CACHE", - joinpath(@__DIR__, ".cache", "noisy_quantum_circuit_tester_pt.jls"), - ) - isapprox((nsteps - 1) * dt, final_time; atol=100eps(Float64)) || throw( - ArgumentError( - "tester_memory_parameters: (nsteps-1)*dt must equal final_time; " * - "got nsteps=$nsteps, dt=$dt, final_time=$final_time.", - ), - ) - return (; - N_bath, - local_dim, - alpha, - omega_cutoff, - omega_max, - thermal_frequency, - dt, - final_time, - nsteps, - ace_cutoff, - ace_maxdim, - cache_path, - ) -end - -function print_tester_memory_parameters(params) - @printf(" bath modes: %d\n", params.N_bath) - @printf(" boson local dimension: %d\n", params.local_dim) - @printf(" Ohmic strength alpha: %.4f\n", params.alpha) - @printf(" cutoff frequency omega_c: %.3f\n", params.omega_cutoff) - @printf(" thermal frequency kBT/hbar: %.3f\n", params.thermal_frequency) - @printf(" timestep: %.4f\n", params.dt) - @printf(" final time: %.3f\n", params.final_time) - @printf(" nsteps: %d\n", params.nsteps) - @printf(" ACE cutoff: %.2e\n", params.ace_cutoff) - @printf(" ACE maxdim: %d\n", params.ace_maxdim) - return nothing -end - -function tester_memory_mode_grid(params) - frequency_spacing = params.omega_max / params.N_bath - frequencies = [(k - 0.5) * frequency_spacing for k in 1:params.N_bath] - spectral_weights = - 2 * params.alpha .* frequencies .* exp.(-frequencies ./ params.omega_cutoff) - couplings = sqrt.(spectral_weights .* frequency_spacing) - return frequencies, couplings -end - -function tester_memory_bath(params) - frequencies, couplings = tester_memory_mode_grid(params) - bath_sites = siteinds("Boson", params.N_bath; dim=params.local_dim) - bath_liouville_sites = liouv_sites(bath_sites) - modes = [ - let - omega = frequencies[k] - coupling = couplings[k] - occupations = 0:(params.local_dim - 1) - thermal_weights = - exp.(-omega .* occupations ./ params.thermal_frequency) - thermal_weights ./= sum(thermal_weights) - number_states = [ - MPS([bath_sites[k]], [string(n)]) - for n in occupations - ] - rho_mode = to_liouville( - to_dm(number_states; coeffs=thermal_weights); - sites=[bath_liouville_sites[k]], - ) - - H_mode = OpSum() + (omega, "N", 1) - H_coupling = OpSum() - H_coupling += coupling, "A", 1, "Z", 2 - H_coupling += coupling, "Adag", 1, "Z", 2 - - bosonic_mode( - [bath_liouville_sites[k]], - H_mode, - rho_mode; - coupling=H_coupling, - ) - end for k in 1:params.N_bath - ] - return Logging.with_logger(Logging.NullLogger()) do - bosonic_bath(modes) +if !(isempty(ARGS) || ARGS == ["--rebuild"]) + error("Usage: julia --project=. -t auto scripts/noisy_quantum_circuit_tester.jl [--rebuild]") +end +force_rebuild = "--rebuild" in ARGS +cache_path = joinpath(@__DIR__, ".cache", "noisy_quantum_circuit_tester_pt.jls") +figure_path = joinpath(@__DIR__, "figures", "noisy_quantum_circuit_tester.png") +mkpath(dirname(cache_path)) +mkpath(dirname(figure_path)) +params = (; N_bath, local_dim, alpha, omega_cutoff, omega_max, thermal_frequency, + dt, final_time, nsteps, ace_cutoff, ace_maxdim, ace_compression) + +# --- Cache reader: fail closed on missing/mismatched metadata --- +function read_cache(path, params) + isfile(path) || return nothing + try + payload = deserialize(path) + if payload.metadata.format != 2 + @warn "Cache format is not reusable; rebuilding" path + return nothing + end + matches = all(k -> hasproperty(payload.metadata, k) && + getproperty(payload.metadata, k) == getproperty(params, k), keys(params)) + if !matches + @warn "Cached process tensor does not match these parameters; rebuilding" path + return nothing + end + hasproperty(payload, :process_tensor) && hasproperty(payload, :system_sites) || return nothing + return payload + catch err + err isa InterruptException && rethrow() + @warn "Unreadable cache; rebuilding" path exception=err + return nothing end end -function build_tester_memory_process_tensor(params) +# --- Construct the noise once; controls are not part of the cache key --- +payload = force_rebuild ? nothing : read_cache(cache_path, params) +cache_hit = payload !== nothing +build_seconds = 0.0 +if !cache_hit + started = time_ns() system_sites = siteinds("Qubit", 1) system = qubit_system(system_sites) - bath = tester_memory_bath(params) - process_tensor = build_process_tensor( - system; - method=ACE(cutoff=params.ace_cutoff, maxdim=params.ace_maxdim), - environment=bath, - dt=params.dt, - nsteps=params.nsteps, - sys_alg=ITensors.Ops.Trotter{2}(), - combine_alg=ITensors.Ops.Trotter{2}(), - progress=true, - ) - cached = ProcessTensor( - process_tensor.core, - process_tensor.system, - nothing, - process_tensor.dt, - process_tensor.nsteps, - process_tensor.coupling_site, - ) - metadata = (; - format=TESTER_MEMORY_CACHE_FORMAT, - N_bath=params.N_bath, - local_dim=params.local_dim, - alpha=params.alpha, - omega_cutoff=params.omega_cutoff, - omega_max=params.omega_max, - thermal_frequency=params.thermal_frequency, - dt=params.dt, - final_time=params.final_time, - nsteps=params.nsteps, - ace_cutoff=params.ace_cutoff, - ace_maxdim=params.ace_maxdim, - maxlinkdim=maxlinkdim(cached), - ) - return (; - process_tensor=cached, - system_sites, - metadata, - ) -end - -function save_tester_memory_cache(path, payload) - mkpath(dirname(path)) - open(path, "w") do io - serialize(io, payload) - end - return path -end - -function cache_parameter_mismatch(metadata, params) - mismatches = String[] - hasproperty(metadata, :format) || return ["format: missing"] - metadata.format == TESTER_MEMORY_CACHE_FORMAT || return [ - "format: cache=$(metadata.format) script=$TESTER_MEMORY_CACHE_FORMAT", - ] - for key in CACHE_COMPARE_KEYS - cached = getproperty(metadata, key) - current = getproperty(params, key) - agrees = cached isa Integer ? - cached == current : - isapprox(cached, current; atol=0, rtol=1e-12) - agrees || push!( - mismatches, - "$key: cache=$cached script=$current", - ) - end - return mismatches -end - -function load_or_build_tester_memory_process_tensor(params) - force_rebuild = get(ENV, "PT_TESTER_REBUILD", "0") == "1" - if force_rebuild - println("PT_TESTER_REBUILD=1; constructing a new ACE process tensor.") - elseif isfile(params.cache_path) - payload = try - open(deserialize, params.cache_path) - catch err - @warn "Could not read the process-tensor cache; rebuilding." exception = ( - err, - catch_backtrace(), - ) - nothing - end - if payload !== nothing - mismatches = cache_parameter_mismatch(payload.metadata, params) - if isempty(mismatches) - println( - "Found a matching ACE cache; skipping process-tensor construction.", - ) - println(" cache file: $(params.cache_path)") - @printf( - " maximum PT bond dimension: %d\n", - payload.metadata.maxlinkdim, - ) - return payload - end - println( - "Cached process tensor does not match the script parameters; rebuilding.", - ) - for line in mismatches - println(" $line") - end - end - else - println("No process-tensor cache at $(params.cache_path); building.") - end - - build_seconds = @elapsed begin - payload = build_tester_memory_process_tensor(params) + Δω = omega_max / N_bath + frequencies = [(k - 0.5) * Δω for k in 1:N_bath] + couplings = sqrt.(2alpha .* frequencies .* exp.(-frequencies ./ omega_cutoff) .* Δω) + bath_sites = siteinds("Boson", N_bath; dim=local_dim) + bath_liouville_sites = liouv_sites(bath_sites) + modes = BosonicMode[] + for k in 1:N_bath + H_mode = OpSum() + (frequencies[k], "N", 1) + coupling = OpSum() + coupling += couplings[k], "A", 1, "Z", 2 + coupling += couplings[k], "Adag", 1, "Z", 2 + push!(modes, thermal_mode([bath_liouville_sites[k]], H_mode, + thermal_frequency; coupling=coupling)) end - save_tester_memory_cache(params.cache_path, payload) - @printf(" ACE build time: %.3f s\n", build_seconds) - @printf(" wrote cache: %s\n", params.cache_path) - @printf(" maximum PT bond dimension: %d\n", payload.metadata.maxlinkdim) - return payload -end - -# ----------------------------------------------------------------------------- -# Small utilities -# ----------------------------------------------------------------------------- - -function print_section(title::AbstractString) - println() - println(title) - println("-"^length(title)) -end - + bath = with_logger(() -> bosonic_bath(modes), NullLogger()) + pt = build_process_tensor( + system; environment=bath, dt=dt, nsteps=nsteps, + method=ACE(cutoff=ace_cutoff, maxdim=ace_maxdim, compression=ace_compression), + sys_alg=Trotter{2}(), combine_alg=Trotter{2}(), progress=true, + ) + # Store cores and system, omitting the original bath from the payload. + slim = ProcessTensor(pt.core, pt.system, nothing, pt.dt, pt.nsteps, pt.coupling_site) + payload = (; process_tensor=slim, system_sites, + metadata=(; format=2, params..., maxlinkdim=maxlinkdim(slim))) + serialize(cache_path * ".tmp", payload) + mv(cache_path * ".tmp", cache_path; force=true) + build_seconds = (time_ns() - started) / 1e9 +end +process_tensor, system_sites = payload.process_tensor, payload.system_sites +println((cache_hit=cache_hit, build_seconds=build_seconds, max_pt_bond=maxlinkdim(process_tensor))) +maxlinkdim(process_tensor) >= ace_maxdim && @warn "ACE reached the bond cap; check convergence" +# Free-oscillator initial Gibbs tail omitted by the local Fock cutoff. +lowest_frequency = omega_max / (2N_bath) +thermal_tail = thermal_frequency == 0 ? 0.0 : exp(-local_dim * lowest_frequency / thermal_frequency) +println((largest_omitted_Gibbs_weight=thermal_tail,)) + +# --- Explicit tester circuit: SWAP to store, Z to phase-tag, SWAP to retrieve --- +rho_Q0 = to_dm(MPS(system_sites, ["+"])) +ancilla_sites = siteinds("Qubit", 1) +memory = tester(ancilla_sites, to_dm(MPS(ancilla_sites, ["0"]))) +store_step, phase_step, retrieve_step = round.(Int, [store_time_target, phase_time_target, retrieve_time_target] ./ dt) .+ 1 +@assert 1 <= store_step < phase_step < retrieve_step <= nsteps +swap_gate = op("SWAP", only(system_sites), only(ancilla_sites)) +phase_gate = op("Z", only(ancilla_sites)) +controls = TesterSeq(nsteps=nsteps) +add!(controls, joint_unitary(swap_gate, system_sites, ancilla_sites), store_step) +add!(controls, tester_unitary(phase_gate, ancilla_sites), phase_step) +add!(controls, joint_unitary(swap_gate, system_sites, ancilla_sites), retrieve_step) + +# Joint controls apply half-gates on each side of a noisy slab; they are not +# instantaneous SWAPs. Report the actual grid labels after rounding target times. +baseline_seconds = @elapsed baseline = evolve(process_tensor, rho_Q0; progress=true) +spectator_seconds = @elapsed spectator = evolve(process_tensor, rho_Q0; tester=memory, progress=true) +controlled_seconds = @elapsed controlled = evolve( + process_tensor, rho_Q0; tester=memory, tester_seq=controls, return_joint=true, progress=true, +) +event_steps = [store_step, phase_step, retrieve_step] +event_times = controlled.times[event_steps] +println((store_time=event_times[1], phase_time=event_times[2], retrieve_time=event_times[3], + baseline_seconds=baseline_seconds, spectator_seconds=spectator_seconds, + controlled_seconds=controlled_seconds)) + +# --- Two utilities for all one- and two-qubit outputs --- function density_matrix(state) - pairs = siteinds(state) - physical_sites = Index[ - only(filter(index -> plev(index) == 0, pair)) - for pair in pairs - ] + sites = [only(filter(i -> plev(i) == 0, pair)) for pair in siteinds(state)] tensor = foldl(*, state) - dimension = prod(dim.(physical_sites)) - return reshape( - ComplexF64.(Array( - tensor, - prime.(physical_sites)..., - physical_sites..., - )), - dimension, - dimension, - ) -end - -function expectation(state, operator) - rho = density_matrix(state) - return real(tr(operator * rho) / tr(rho)) -end - -function mutual_information(rho_system, rho_tester, rho_joint) - function entropy(rho) - normalized = (rho + rho') / (2real(tr(rho))) - probabilities = clamp.(real.(eigvals(Hermitian(normalized))), 0, Inf) - probabilities ./= sum(probabilities) - return -sum(p * log2(p) for p in probabilities if p > eps(Float64)) - end - return entropy(rho_system) + entropy(rho_tester) - entropy(rho_joint) -end - -function store_retrieve_schedule( - process_tensor, - swap_gate, - phase_gate, - system_sites, - tester_sites, - store_step, - phase_step, - retrieve_step, -) - 1 <= store_step < phase_step < retrieve_step <= process_tensor.nsteps || throw( - ArgumentError( - "Expected store_step < phase_step < retrieve_step within the process.", - ), - ) - controls = TesterSeq(nsteps=process_tensor.nsteps) - add!( - controls, - joint_unitary(swap_gate, system_sites, tester_sites), - store_step, - ) - add!( - controls, - tester_unitary(phase_gate, tester_sites), - phase_step, - ) - add!( - controls, - joint_unitary(swap_gate, system_sites, tester_sites), - retrieve_step, - ) - return controls -end - -step_at_time(time, dt, nsteps) = - clamp(round(Int, time / dt) + 1, 1, nsteps) - -const SWAP_MARKER_COLOR = (0.72, 0.12, 0.18, 0.95) - -function mark_swap_times!(axis, times) - vlines!( - axis, - times; - color=SWAP_MARKER_COLOR, - linestyle=:dot, - linewidth=4.5, - ) - return nothing -end - -function mark_phase_time!(axis, time) - vlines!( - axis, - [time]; - color=(:gray35, 0.9), - linestyle=:dash, - linewidth=4.0, - ) - return nothing -end - -function annotate_protocol_times!(axis, store_t, phase_t, retrieve_t, y_label) - text!( - axis, - store_t, - y_label; - text="SWAP", - color=SWAP_MARKER_COLOR, - align=(:left, :top), - fontsize=16, - offset=Vec2f(8, 0), - ) - text!( - axis, - retrieve_t, - y_label; - text="SWAP", - color=SWAP_MARKER_COLOR, - align=(:left, :top), - fontsize=16, - offset=Vec2f(8, 0), - ) - text!( - axis, - phase_t, - y_label; - text="Z gate", - color=(:gray20, 0.95), - align=(:left, :top), - fontsize=16, - offset=Vec2f(8, 0), - ) - return nothing -end - -# ----------------------------------------------------------------------------- -# Cached process tensor and protocol times -# ----------------------------------------------------------------------------- - -const store_time_target = 1.0 -const readout_delay_target = 0.50 -const trace_warning_tolerance = 1e-3 -const trace_assert_tolerance = 5e-2 - -output_dir = joinpath(@__DIR__, "figures") -mkpath(output_dir) -figure_png = joinpath(output_dir, "noisy_quantum_circuit_tester.png") - -# ----------------------------------------------------------------------------- -# Physical model and reusable process tensor -# ----------------------------------------------------------------------------- - -print_section("Testers and noisy quantum qubits") -println("One ACE process tensor is reused for many memory-control circuits.") - -params = tester_memory_parameters() -print_tester_memory_parameters(params) -payload = load_or_build_tester_memory_process_tensor(params) -process_tensor = payload.process_tensor -system_sites = payload.system_sites -dt = payload.metadata.dt -final_time = payload.metadata.final_time -nsteps = payload.metadata.nsteps - -println(process_tensor) - -rho_system_0 = to_dm(MPS(system_sites, ["+"])) -tester_sites = siteinds("Qubit", 1) -rho_tester_0 = to_dm(MPS(tester_sites, ["0"])) -memory = tester(tester_sites, rho_tester_0) - -# ----------------------------------------------------------------------------- -# Baseline, spectator, and one representative memory circuit -# ----------------------------------------------------------------------------- - -print_section("Reference and store–wait–retrieve trajectories") - -baseline = evolve(process_tensor, rho_system_0; progress=true) -spectator = evolve( - process_tensor, - rho_system_0; - tester=memory, - progress=true, -) - -X_system = ComplexF64.(Array( - op("X", only(system_sites)), - prime(only(system_sites)), - only(system_sites), -)) -Y_system = ComplexF64.(Array( - op("Y", only(system_sites)), - prime(only(system_sites)), - only(system_sites), -)) -X_tester = ComplexF64.(Array( - op("X", only(tester_sites)), - prime(only(tester_sites)), - only(tester_sites), -)) - -x_baseline = [expectation(rho, X_system) for rho in baseline.states_hilbert] -x_spectator = [expectation(rho, X_system) for rho in spectator.states_hilbert] -spectator_error = maximum(abs.(x_spectator .- x_baseline)) -@printf(" identity-tester max |Δ|: %.3e\n", spectator_error) - -store_step = step_at_time(store_time_target, dt, nsteps) -retrieve_time_target = min( - store_time_target + 2.0, - final_time - readout_delay_target, -) -retrieve_step = step_at_time(retrieve_time_target, dt, nsteps) -phase_step = store_step + (retrieve_step - store_step) ÷ 2 - -swap_gate = op("SWAP", only(system_sites), only(tester_sites)) -phase_gate = op("Z", only(tester_sites)) -controls = store_retrieve_schedule( - process_tensor, - swap_gate, - phase_gate, - system_sites, - tester_sites, - store_step, - phase_step, - retrieve_step, -) - -controlled = evolve( - process_tensor, - rho_system_0; - tester=memory, - tester_seq=controls, - return_joint=true, - progress=true, -) - -x_system = [expectation(rho, X_system) for rho in controlled.states_hilbert] -x_tester = [ - expectation(rho, X_tester) - for rho in controlled.tester_states_hilbert -] -information = [ - mutual_information( - density_matrix(controlled.states_hilbert[k]), - density_matrix(controlled.tester_states_hilbert[k]), - density_matrix(controlled.joint_states_hilbert[k]), - ) - for k in eachindex(controlled.times) -] - -@printf(" store time: %.3f\n", controlled.times[store_step]) -@printf(" phase-tag time: %.3f\n", controlled.times[phase_step]) -@printf(" retrieve time: %.3f\n", controlled.times[retrieve_step]) -@printf(" after store: %+.6f\n", x_tester[store_step]) -@printf(" after phase tag: %+.6f\n", x_tester[phase_step]) -@printf(" after retrieve: %+.6f\n", x_system[retrieve_step]) -@printf(" maximum I(S:A): %.6e bits\n", maximum(information)) - -# ----------------------------------------------------------------------------- -# Idle-time sweep using the same process tensor -# ----------------------------------------------------------------------------- - -print_section("Idle-time sweep") - -readout_steps = max(1, round(Int, readout_delay_target / dt)) -minimum_idle_steps = max(2, round(Int, 0.35 / dt)) -idle_stride = max(1, round(Int, 0.20 / dt)) -maximum_idle_steps = nsteps - store_step - readout_steps - 1 - -maximum_idle_steps >= minimum_idle_steps || error( - "Simulation window is too short for the requested idle-time sweep.", -) - -idle_steps = collect(minimum_idle_steps:idle_stride:maximum_idle_steps) -idle_times = Float64[] -retrieval_coherence = Float64[] -baseline_coherence = Float64[] - -report_stride = max(1, cld(length(idle_steps), 8)) -for (iteration, idle_duration) in enumerate(idle_steps) - retrieve = store_step + idle_duration - phase = store_step + idle_duration ÷ 2 - readout = retrieve + readout_steps - - schedule = store_retrieve_schedule( - process_tensor, - swap_gate, - phase_gate, - system_sites, - tester_sites, - store_step, - phase, - retrieve, - ) - trajectory = evolve( - process_tensor, - rho_system_0; - tester=memory, - tester_seq=schedule, - return_tester=false, - return_joint=false, - progress=false, - ) - - controlled_x = expectation(trajectory.states_hilbert[readout], X_system) - controlled_y = expectation(trajectory.states_hilbert[readout], Y_system) - baseline_x = expectation(baseline.states_hilbert[readout], X_system) - baseline_y = expectation(baseline.states_hilbert[readout], Y_system) - - push!(idle_times, idle_duration * dt) - push!(retrieval_coherence, hypot(controlled_x, controlled_y)) - push!(baseline_coherence, hypot(baseline_x, baseline_y)) - - if iteration == 1 || iteration == length(idle_steps) || - iteration % report_stride == 0 - @printf( - " %2d/%2d | idle %.3f | retrieved %.6f | bare %.6f\n", - iteration, - length(idle_steps), - last(idle_times), - last(retrieval_coherence), - last(baseline_coherence), - ) + d = prod(dim.(sites)) + return reshape(ComplexF64.(Array(tensor, prime.(sites)..., sites...)), d, d) +end + +function entropy_diagnostics(ρ; tolerance=1e-7) + z = tr(ρ) + isfinite(z) && real(z) > 1e-12 || error("Invalid density-matrix trace") + hermiticity_error = norm(ρ - ρ') / norm(ρ) + λ = eigvals(Hermitian((ρ + ρ') / (2real(z)))) + min_eigenvalue = minimum(λ) + valid = min_eigenvalue >= -tolerance && hermiticity_error <= tolerance + entropy = NaN + if valid + p = max.(λ, 0) # Only negativity within the declared tolerance is clipped. + p ./= sum(p) + entropy = -sum(x * log2(x) for x in p if x > 0) end -end - -# ----------------------------------------------------------------------------- -# Numerical checks -# ----------------------------------------------------------------------------- - -print_section("Numerical checks") - -trace_errors = Float64[] -for states in ( - baseline.states_hilbert, - controlled.states_hilbert, - controlled.tester_states_hilbert, - controlled.joint_states_hilbert, -) - append!(trace_errors, [abs(tr(density_matrix(rho)) - 1) for rho in states]) -end - -rho_joint_final = density_matrix(controlled.joint_states_hilbert[end]) -joint_array = reshape(rho_joint_final, 2, 2, 2, 2) -rho_system_from_joint = zeros(ComplexF64, 2, 2) -rho_tester_from_joint = zeros(ComplexF64, 2, 2) -for tester_index in 1:2 - rho_system_from_joint .+= @view joint_array[:, tester_index, :, tester_index] -end -for system_index in 1:2 - rho_tester_from_joint .+= @view joint_array[system_index, :, system_index, :] -end - -system_reduction_error = norm( - rho_system_from_joint - density_matrix(controlled.states_hilbert[end]), -) -tester_reduction_error = norm( - rho_tester_from_joint - density_matrix(controlled.tester_states_hilbert[end]), -) - -@printf(" maximum trace error: %.3e\n", maximum(trace_errors)) -@printf(" final system reduction: %.3e\n", system_reduction_error) -@printf(" final tester reduction: %.3e\n", tester_reduction_error) - -if maximum(trace_errors) > trace_warning_tolerance - @warn( - "ACE compression can violate exact trace preservation; " * - "this is a truncation remainder, not a tester-contraction bug.", - max_trace_error=maximum(trace_errors), - trace_warning_tolerance=trace_warning_tolerance, - ) -end - + return (; entropy, min_eigenvalue, hermiticity_error, trace_error=abs(z - 1), valid) +end + +rho_Q = density_matrix.(controlled.states_hilbert) +rho_A = density_matrix.(controlled.tester_states_hilbert) +rho_QA = density_matrix.(controlled.joint_states_hilbert) +rho_baseline = density_matrix.(baseline.states_hilbert) +rho_spectator = density_matrix.(spectator.states_hilbert) +X = ComplexF64[0 1; 1 0] +x_Q = [real(tr(X * ρ) / tr(ρ)) for ρ in rho_Q] +x_A = [real(tr(X * ρ) / tr(ρ)) for ρ in rho_A] +x_baseline = [real(tr(X * ρ) / tr(ρ)) for ρ in rho_baseline] +Q_data, A_data, QA_data = entropy_diagnostics.(rho_Q), entropy_diagnostics.(rho_A), entropy_diagnostics.(rho_QA) +information = [q.entropy + a.entropy - qa.entropy for (q, a, qa) in zip(Q_data, A_data, QA_data)] +checks = vcat(Q_data, A_data, QA_data, entropy_diagnostics.(rho_baseline), entropy_diagnostics.(rho_spectator)) +max_trace_error = maximum(c.trace_error for c in checks) +min_eigenvalue = minimum(c.min_eigenvalue for c in checks) +spectator_error = maximum(norm.(rho_spectator .- rho_baseline)) +invalid_entropy_inputs = count(c -> !c.valid, checks) + +# The returned joint basis has Q as its first (fastest) local index. +# Compare its final partial traces with the separately returned marginals. +joint = reshape(last(rho_QA), 2, 2, 2, 2) +Q_from_joint = joint[:, 1, :, 1] + joint[:, 2, :, 2] +A_from_joint = joint[1, :, 1, :] + joint[2, :, 2, :] +reduction_errors = (processor=norm(Q_from_joint - last(rho_Q)), ancilla=norm(A_from_joint - last(rho_A))) +println((identity_tester_error=spectator_error, reduction_errors=reduction_errors, + max_trace_error=max_trace_error, minimum_eigenvalue=min_eigenvalue, + max_hermiticity_error=maximum(c.hermiticity_error for c in checks), + invalid_entropy_inputs=invalid_entropy_inputs)) +max_trace_error > trace_warning_tolerance && @warn "Trace drift exceeds tolerance; check compression and numerical settings" max_trace_error +invalid_entropy_inputs > 0 && @warn "Invalid entropy inputs; affected mutual-information samples are NaN and appear as gaps" invalid_entropy_inputs @assert spectator_error < 1e-8 -@assert length(controlled.times) == length(controlled.states_hilbert) -@assert length(controlled.times) == length(controlled.tester_states_hilbert) -@assert length(controlled.times) == length(controlled.joint_states_hilbert) -@assert maximum(trace_errors) < trace_assert_tolerance -@assert system_reduction_error < 1e-7 -@assert tester_reduction_error < 1e-7 -@assert all(isfinite, x_baseline) -@assert all(isfinite, x_system) -@assert all(isfinite, x_tester) -@assert all(isfinite, information) -@assert minimum(information) > -1e-8 -@assert all(value -> abs(value) <= 1 + 1e-3, x_system) -@assert all(isfinite, retrieval_coherence) - -# ----------------------------------------------------------------------------- -# Three-panel figure -# ----------------------------------------------------------------------------- - -print_section("Generating figure") - -figure = Figure(size=(1120, 1200), fontsize=20) - -coherence_axis = Axis( - figure[1, 1], - xlabel=L"t", - ylabel=L"\langle X\rangle", - title="Store, phase-tag, and retrieve the qubit state", -) -lines!( - coherence_axis, - baseline.times, - x_baseline; - linewidth=2.5, - linestyle=:dash, - label=L"S\ \mathrm{without\ tester}", -) -lines!( - coherence_axis, - controlled.times, - x_system; - linewidth=3, - label=L"S\ \mathrm{controlled}", -) -lines!( - coherence_axis, - controlled.times, - x_tester; - linewidth=3, - label=L"A\ \mathrm{memory}", -) -mark_phase_time!(coherence_axis, controlled.times[phase_step]) -mark_swap_times!( - coherence_axis, - controlled.times[[store_step, retrieve_step]], -) -axislegend(coherence_axis; position=:lb, framevisible=false) -coherence_hi = maximum(( - maximum(x_baseline), - maximum(x_system), - maximum(x_tester), -)) -coherence_lo = minimum(( - minimum(x_baseline), - minimum(x_system), - minimum(x_tester), -)) -coherence_span = coherence_hi - coherence_lo -coherence_ymin = coherence_lo - 0.06 * coherence_span -coherence_ymax = coherence_hi + 0.16 * coherence_span -ylims!(coherence_axis, coherence_ymin, coherence_ymax) -annotate_protocol_times!( - coherence_axis, - controlled.times[store_step], - controlled.times[phase_step], - controlled.times[retrieve_step], - coherence_ymin + 0.96 * (coherence_ymax - coherence_ymin), -) - -information_axis = Axis( - figure[2, 1], - xlabel=L"t", - ylabel=L"I(S{:}A)\ \mathrm{[bits]}", - title="System–memory correlations", -) -lines!( - information_axis, - controlled.times, - information; - linewidth=3, -) -mark_phase_time!(information_axis, controlled.times[phase_step]) -mark_swap_times!( - information_axis, - controlled.times[[store_step, retrieve_step]], -) -information_hi = maximum(information) -information_lo = minimum(information) -information_span = max(information_hi - information_lo, 1e-3) -information_ymin = information_lo - 0.08 * information_span -information_ymax = information_hi + 0.20 * information_span -ylims!(information_axis, information_ymin, information_ymax) -annotate_protocol_times!( - information_axis, - controlled.times[store_step], - controlled.times[phase_step], - controlled.times[retrieve_step], - information_ymin + 0.96 * (information_ymax - information_ymin), -) - -idle_axis = Axis( - figure[3, 1], - xlabel=L"\tau_{\mathrm{idle}}", - ylabel=L"C_{xy}", - title="Coherence read a fixed delay after retrieval", -) -scatterlines!( - idle_axis, - idle_times, - retrieval_coherence; - linewidth=3, - marker=:circle, - label=L"\mathrm{store/retrieve}", -) -scatterlines!( - idle_axis, - idle_times, - baseline_coherence; - linewidth=2.5, - linestyle=:dash, - marker=:rect, - label=L"\mathrm{bare\ noisy\ qubit}", -) -axislegend(idle_axis; position=:lb, framevisible=false) - -save(figure_png, figure) - -println() -println("Saved:") -println(" $figure_png") -println("Done.") -println( - "The idle-time curve is a memory-sensitive control observable, " * - "not a universal non-Markovianity measure.", -) +@assert max_trace_error < trace_assert_tolerance +@assert maximum(values(reduction_errors)) < 1e-7 +@assert all(isfinite, x_Q) && all(isfinite, x_A) && all(isfinite, x_baseline) +@assert maximum(abs, x_Q) <= 1 + 1e-3 && maximum(abs, x_A) <= 1 + 1e-3 + +for (event, k) in zip((:store, :phase, :retrieve), event_steps) + println((event=event, time=controlled.times[k], x_Q=x_Q[k], x_A=x_A[k], + ancilla_coherence=2abs(rho_A[k][1, 2] / tr(rho_A[k])), mutual_information=information[k])) +end + +# --- Two-panel figure; no fixed-delay readout or idle-time sweep --- +figure = Figure(size=(1120, 850), fontsize=20) +signal_axis = Axis(figure[1, 1]; ylabel="⟨X⟩", title="Store, phase-tag, and retrieve", + xticklabelsvisible=false) +lines!(signal_axis, baseline.times, x_baseline; linewidth=2.5, linestyle=:dash, label="Q without tester") +lines!(signal_axis, controlled.times, x_Q; linewidth=3, label="Q controlled") +lines!(signal_axis, controlled.times, x_A; linewidth=3, label="A memory") +axislegend(signal_axis; position=:lb, framevisible=false) +ylims!(signal_axis, -1.05, 1.22) + +information_axis = Axis(figure[2, 1]; xlabel="t", ylabel="I(Q:A) [bits]", + title="Processor–ancilla correlations") +lines!(information_axis, controlled.times, information; linewidth=3) +for axis in (signal_axis, information_axis) + vlines!(axis, event_times[[1, 3]]; color=:crimson, linestyle=:dot, linewidth=2.5) + vlines!(axis, [event_times[2]]; color=:gray40, linestyle=:dash, linewidth=2.5) +end +for (t, label) in zip(event_times, ("SWAP", "Z", "SWAP")) + text!(signal_axis, t, 1.12; text=label, fontsize=16, align=(:left, :center), offset=Vec2f(7, 0)) +end +linkxaxes!(signal_axis, information_axis) +rowgap!(figure.layout, 16) +save(figure_path, figure) +println("Saved figure: $figure_path") +# Nonzero mutual information is not an entanglement or non-Markovianity witness. +# The ideal instantaneous-SWAP limit differs from these finite-slab joint gates. diff --git a/scripts/noisy_quantum_circuit_tester_protocol.jl b/scripts/noisy_quantum_circuit_tester_protocol.jl new file mode 100644 index 0000000..77a8d3b --- /dev/null +++ b/scripts/noisy_quantum_circuit_tester_protocol.jl @@ -0,0 +1,190 @@ +# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors +# SPDX-License-Identifier: MIT +# +# File: scripts/noisy_quantum_circuit_tester_protocol.jl +# Contributor: Gauthameshwar S. +# +# Draws the SWAP–Z–SWAP tester protocol on the same time grid as the +# noisy-circuit result figure. +# +# Run with: +# julia --project=. -t auto scripts/noisy_quantum_circuit_tester_protocol.jl + +# Event times match scripts/noisy_quantum_circuit_tester.jl: store, phase, retrieve. +const FINAL_TIME = 5.0 +const SWAP_TIMES = (1.0, 3.0) +const Z_TIME = 2.0 + +import Pkg +plot_env = joinpath(@__DIR__, ".plot_examples_env") +Pkg.activate(plot_env) +if !isfile(joinpath(plot_env, "Manifest.toml")) + Pkg.add("CairoMakie") +else + Pkg.instantiate() +end + +using CairoMakie +CairoMakie.activate!() + +const PT_COLOR = "#3A9A5B" +const PT_FILL = (PT_COLOR, 0.28) +const QUBIT_COLOR = "#E07B2D" +const TESTER_COLOR = "#7A4EAB" +const ARROW_LENGTH_PX = 18.0 +const PT_Y = 2.0 +const QUBIT_Y = 1.0 +const TESTER_Y = 0.0 + +# Axis window in data units. Pixel scales keep the Z box and the SWAP crosses +# square on the page; one data unit is wider than it is tall. +# A small margin left of t = 0 keeps the initialization circles inside the frame. +# The rails and the tick marks still begin at zero and end at t = 5. +const TIME_Y = -0.62 +const X_LIMITS = (-0.07, FINAL_TIME + 0.08) +const Y_LIMITS = (-1.05, 2.55) +const FIGURE_SIZE = (1120, 420) +const PX_PER_X = 1000 / (X_LIMITS[2] - X_LIMITS[1]) +const PX_PER_Y = 320 / (Y_LIMITS[2] - Y_LIMITS[1]) + +function rounded_square(center, y, half_px, radius_px; n=6) + half_x = half_px / PX_PER_X + half_y = half_px / PX_PER_Y + rx = radius_px / PX_PER_X + ry = radius_px / PX_PER_Y + x0, x1 = center - half_x, center + half_x + y0, y1 = y - half_y, y + half_y + function corner(cx, cy, a0, a1) + return [Point2f(cx + rx * cos(a), cy + ry * sin(a)) for a in range(a0, a1; length=n)] + end + return vcat( + corner(x1 - rx, y1 - ry, 0, π / 2), + corner(x0 + rx, y1 - ry, π / 2, π), + corner(x0 + rx, y0 + ry, π, 3π / 2), + corner(x1 - rx, y0 + ry, 3π / 2, 2π), + ) +end + +arrow_base(tip) = tip - ARROW_LENGTH_PX / PX_PER_X + +function arrowhead(tip, y) + base = arrow_base(tip) + half_h = 7.0 / PX_PER_Y + return Point2f[(tip, y), (base, y + half_h), (base, y - half_h)] +end + +function cross!(axis, x, y; color=:black, arm_px=11.0, linewidth=4.0) + dx = arm_px / PX_PER_X + dy = arm_px / PX_PER_Y + lines!(axis, [x - dx, x + dx], [y - dy, y + dy]; color=color, linewidth=linewidth) + lines!(axis, [x - dx, x + dx], [y + dy, y - dy]; color=color, linewidth=linewidth) + return nothing +end + +figure = Figure(size=FIGURE_SIZE, figure_padding=(12, 18, 8, 16)) +Label( + figure[1, 1:2], + "Noisy qubit with tester interactions"; + fontsize=22, + font=:bold, + tellwidth=false, +) + +label_axis = Axis(figure[2, 1]; width=78, limits=(0, 1, Y_LIMITS...)) +hidedecorations!(label_axis) +hidespines!(label_axis) +text!(label_axis, 1, PT_Y; text="PT", align=(:right, :center), color=PT_COLOR, fontsize=18) +text!(label_axis, 1, QUBIT_Y; text="QUBIT", align=(:right, :center), color=QUBIT_COLOR, fontsize=18) +text!(label_axis, 1, TESTER_Y; text="TESTER", align=(:right, :center), color=TESTER_COLOR, fontsize=18) +text!(label_axis, 1, TIME_Y; text="Time", align=(:right, :center), color=:black, fontsize=18) + +axis = Axis( + figure[2, 2]; + limits=(X_LIMITS..., Y_LIMITS...), + xgridvisible=false, + ygridvisible=false, +) +linkyaxes!(label_axis, axis) +colgap!(figure.layout, 1, 6) +hidedecorations!(axis) +hidespines!(axis) +time_axis_end = arrow_base(FINAL_TIME) +lines!(axis, [0.0, time_axis_end], [TIME_Y, TIME_Y]; color=:black, linewidth=1.6) +poly!(axis, arrowhead(FINAL_TIME, TIME_Y); color=:black, strokewidth=0) +tick_drop = 8.0 / PX_PER_Y +for tick in 0:4 + lines!(axis, [tick, tick], [TIME_Y, TIME_Y - tick_drop]; color=:black, linewidth=1.2) + text!( + axis, + tick, + TIME_Y - tick_drop; + text=string(tick), + align=(:center, :top), + offset=(0, -3), + fontsize=16, + ) +end + +fill_end = arrow_base(FINAL_TIME) +band!( + axis, + [0.0, fill_end], + [QUBIT_Y, QUBIT_Y], + [PT_Y, PT_Y]; + color=PT_FILL, +) + +for (y, color, initialize) in ( + (PT_Y, PT_COLOR, false), + (QUBIT_Y, QUBIT_COLOR, true), + (TESTER_Y, TESTER_COLOR, true), + ) + lines!(axis, [0.0, fill_end], [y, y]; color=color, linewidth=2.6) + poly!(axis, arrowhead(FINAL_TIME, y); color=color, strokewidth=0) + if initialize + scatter!( + axis, + [0.0], + [y]; + marker=:circle, + markersize=16, + color=color, + strokecolor=color, + strokewidth=1.5, + ) + end +end + +for swap_time in SWAP_TIMES + lines!( + axis, + [swap_time, swap_time], + [QUBIT_Y, TESTER_Y]; + color=TESTER_COLOR, + linewidth=4.0, + ) + cross!(axis, swap_time, QUBIT_Y; color=TESTER_COLOR) + cross!(axis, swap_time, TESTER_Y; color=TESTER_COLOR) +end + +z_box = rounded_square(Z_TIME, TESTER_Y, 36.0, 8.0) +poly!(axis, z_box; color=:white, strokecolor=TESTER_COLOR, strokewidth=2.2) +text!( + axis, + Z_TIME, + TESTER_Y; + text="Z", + align=(:center, :center), + color=TESTER_COLOR, + fontsize=22, +) + +output_dir = joinpath(@__DIR__, "figures") +mkpath(output_dir) +png_path = joinpath(output_dir, "noisy_quantum_circuit_tester_protocol.png") +pdf_path = joinpath(output_dir, "noisy_quantum_circuit_tester_protocol.pdf") +save(png_path, figure; px_per_unit=2) +save(pdf_path, figure) +println("Saved:") +println(" $png_path") +println(" $pdf_path") diff --git a/scripts/pt_multitime_correlations.jl b/scripts/pt_multitime_correlations.jl index f5d374d..f660f3f 100644 --- a/scripts/pt_multitime_correlations.jl +++ b/scripts/pt_multitime_correlations.jl @@ -7,7 +7,7 @@ # Single-mode bath process tensor: two-time correlator heatmaps via `two_time_correlation_seq`. # # Run with: -# julia --project=. scripts/pt_multitime_correlations.jl +# julia --project=. -t auto scripts/pt_multitime_correlations.jl import Pkg diff --git a/scripts/pt_tfim_multimode.jl b/scripts/pt_tfim_multimode.jl index 3992c09..ea0156b 100644 --- a/scripts/pt_tfim_multimode.jl +++ b/scripts/pt_tfim_multimode.jl @@ -7,7 +7,7 @@ # Multimode spin-bath process tensor: fused-memory `evolve` vs joint Liouville ED. # # Run with: -# julia --project=. scripts/pt_tfim_multimode.jl +# julia --project=. -t auto scripts/pt_tfim_multimode.jl import Pkg diff --git a/scripts/pt_tfim_singlemode.jl b/scripts/pt_tfim_singlemode.jl index 36c29bb..ec0097d 100644 --- a/scripts/pt_tfim_singlemode.jl +++ b/scripts/pt_tfim_singlemode.jl @@ -7,7 +7,7 @@ # Single-mode spin-bath process tensor: split `evolve` vs joint Liouville ED reference. # # Run with: -# julia --project=. scripts/pt_tfim_singlemode.jl +# julia --project=. -t auto scripts/pt_tfim_singlemode.jl import Pkg diff --git a/scripts/ramsey_povm.jl b/scripts/ramsey_povm.jl new file mode 100644 index 0000000..0abfb60 --- /dev/null +++ b/scripts/ramsey_povm.jl @@ -0,0 +1,390 @@ +# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors +# SPDX-License-Identifier: MIT +# +# File: scripts/ramsey_povm.jl +# Contributor: Gauthameshwar S. +# +# Three unsharp Ramsey readouts, each followed by the same active reset. +# +# Run with: +# julia --project=. -t auto scripts/ramsey_povm.jl +# PT_RAMSEY_CACHE overrides the cache path; PT_RAMSEY_REBUILD=1 forces rebuilding. +# Force rebuilding after changing bath construction or package versions. + +# --- Edit the experiment here (ħ = k_B = 1) --- +const N_BATH, LOCAL_DIM = 4, 3 +const ALPHA, OMEGA_C, OMEGA_MAX = 0.20, 4.0, 20.0 +const TEMPERATURE = 2.5 +const DT, NSTEPS = 0.15, 14 +const ACE_CUTOFF, ACE_MAXDIM = 1e-5, 512 +const ACE_COMPRESSION = :zipup_cpp +const ETA, OUTCOMES = 0.90, (-1, 1) +const READOUT_STEPS = (4, 8, 12) +@assert N_BATH > 0 && LOCAL_DIM >= 2 && ALPHA >= 0 +@assert OMEGA_C > 0 && OMEGA_MAX > 0 && TEMPERATURE >= 0 && DT > 0 +@assert 0 <= ETA <= 1 && ACE_CUTOFF > 0 && ACE_MAXDIM > 0 +@assert length(READOUT_STEPS) == 3 && issorted(READOUT_STEPS) +@assert length(unique(READOUT_STEPS)) == 3 +@assert all(s -> 0 < s < NSTEPS, READOUT_STEPS) + +# --- Automatic plotting environment --- +import Pkg +plot_env = joinpath(@__DIR__, ".plot_examples_env") +Pkg.activate(plot_env) +if !isfile(joinpath(plot_env, "Manifest.toml")) + Pkg.develop(Pkg.PackageSpec(path=dirname(@__DIR__))) + Pkg.add(["CairoMakie", "LaTeXStrings"]) +else + Pkg.instantiate() +end + +using CairoMakie +using ITensors +using ITensors.Ops: Trotter +using LaTeXStrings +using Logging +using Serialization +using ProcessTensors +CairoMakie.activate!() + +# --- Reuse a matching process tensor, including its original site indices --- +# Visibility and readout times do not affect the bath PT, so are not cache keys. +parameters = (; N_bath=N_BATH, local_dim=LOCAL_DIM, alpha=ALPHA, + omega_cutoff=OMEGA_C, omega_max=OMEGA_MAX, thermal_frequency=TEMPERATURE, + dt=DT, nsteps=NSTEPS, ace_cutoff=ACE_CUTOFF, ace_maxdim=ACE_MAXDIM, + ace_compression=ACE_COMPRESSION) +cache_path = get(ENV, "PT_RAMSEY_CACHE", joinpath(@__DIR__, ".cache", "ramsey_povm_pt.jls")) + +function read_cache(path, parameters) + get(ENV, "PT_RAMSEY_REBUILD", "0") == "1" && return nothing + isfile(path) || return nothing + try + payload = open(deserialize, path) + get(payload.metadata, :format, 0) == 2 || return nothing + all(k -> get(payload.metadata, k, nothing) == parameters[k], keys(parameters)) || return nothing + return payload + catch err + @warn "Could not read the PT cache; rebuilding" exception=(err, catch_backtrace()) + return nothing + end +end + +payload = read_cache(cache_path, parameters) +cache_hit = payload !== nothing +if !cache_hit + system_sites = siteinds("Qubit", 1) + system = with_logger(() -> qubit_system(system_sites), NullLogger()) + # H_E = Σ ω_k b†_k b_k; H_SE = Z Σ g_k(b_k+b†_k), with Pauli Z. + # J(ω) = 2αω exp(-ω/ω_c), g_k² = J(ω_k) Δω; each mode starts thermal. + Δω = OMEGA_MAX / N_BATH + frequencies = [(k - 0.5) * Δω for k in 1:N_BATH] + couplings = sqrt.(2ALPHA .* frequencies .* exp.(-frequencies ./ OMEGA_C) .* Δω) + bath_sites = siteinds("Boson", N_BATH; dim=LOCAL_DIM) + bath_liouville_sites = liouv_sites(bath_sites) + modes = BosonicMode[] + for k in eachindex(frequencies) + H_mode = OpSum() + (frequencies[k], "N", 1) + coupling = OpSum() + coupling += couplings[k], "A", 1, "Z", 2 + coupling += couplings[k], "Adag", 1, "Z", 2 + push!(modes, thermal_mode([bath_liouville_sites[k]], H_mode, TEMPERATURE; + coupling=coupling)) + end + bath = with_logger(() -> bosonic_bath(modes), NullLogger()) + pt = build_process_tensor( + system; method=ACE(cutoff=ACE_CUTOFF, maxdim=ACE_MAXDIM, compression=ACE_COMPRESSION), + environment=bath, dt=DT, nsteps=NSTEPS, + sys_alg=Trotter{2}(), combine_alg=Trotter{2}(), progress=false) + # The contracted core no longer needs the bath object in the serialized cache. + process_tensor = ProcessTensor(pt.core, pt.system, nothing, pt.dt, pt.nsteps, pt.coupling_site) + metadata = (; format=2, parameters..., maxlinkdim=maxlinkdim(process_tensor)) + payload = (; process_tensor, system_sites, metadata) + mkpath(dirname(cache_path)) + temporary_path = cache_path * ".tmp" + open(io -> serialize(io, payload), temporary_path, "w") + mv(temporary_path, cache_path; force=true) +end +process_tensor, system_sites = payload.process_tensor, payload.system_sites +println((cache_hit=cache_hit, maximum_bond_dimension=maxlinkdim(process_tensor))) + +# --- Unsharp X readout followed by reset --- +# E_x = (I + x η X)/2; A_x(ρ) = Tr(E_x ρ) ρ_reset. +# In Liouville space A_x = |ρ_reset⟩⟩⟨⟨E_x|, not the reversed outer product. +# This resets the qubit, while the conditional bath state can retain the record. +rho_reset = to_dm(MPS(system_sites, ["+"])) +effects = Dict(x => OpSum() + (0.5, "Id", 1) + (x * ETA / 2, "X", 1) + for x in OUTCOMES) +ramsey_instruments = Dict( + x => observable_measurement(effects[x]) * state_preparation(rho_reset) + for x in OUTCOMES) + +# --- Joint probabilities and independent-record reference --- +# Slot s closes out_(s-1) after s propagations, then prepares the next input. +# The original schedule gives times 0.6, 1.2, 1.8: three equal waits. +readout_times = collect(READOUT_STEPS) .* DT +final_time = NSTEPS * DT +println((readout_times=readout_times, final_time=final_time, visibility=ETA)) + +function branch_probability(record) + sequence = default_schedule(process_tensor) + add!(sequence, state_preparation(rho_reset), 0) + for (step, outcome) in zip(READOUT_STEPS, record) + add!(sequence, ramsey_instruments[outcome], step) + end + add!(sequence, trace_out(), process_tensor.nsteps) + value = evaluate_process(process_tensor, sequence; progress=false) + @assert isfinite(value) && abs(imag(value)) < 1e-7 + return real(value) +end + +records = vec(collect(Iterators.product(OUTCOMES, OUTCOMES, OUTCOMES))) +probabilities = branch_probability.(records) +normalization_error = abs(sum(probabilities) - 1) +println((normalization_error=normalization_error, minimum_probability=minimum(probabilities), + probability_sum=sum(probabilities))) +@assert minimum(probabilities) >= -1e-7 +@assert normalization_error < 5e-3 + +# q(x₁,x₂,x₃) = ∏ⱼ pⱼ(xⱼ): no assumption that the three marginals agree. +# For exact probabilities D = ½Σ|p-q| is total variation distance. +# Keep numerical weights raw; normalization/positivity errors remain visible. +# Columns contain P(-) and P(+) for each round; retain the raw PT weights. +marginals = zeros(3, 2) +for (record, probability) in zip(records, probabilities) + for round in 1:3 + marginals[round, record[round] == -1 ? 1 : 2] += probability + end +end +independent_probabilities = [ + prod(marginals[j, record[j] == -1 ? 1 : 2] for j in 1:3) + for record in records] +factorization_residual = sum(abs.(probabilities .- independent_probabilities)) / 2 +println((normalization_error=normalization_error, minimum_probability=minimum(probabilities), + factorization_residual=factorization_residual)) + +labels = [join(x == 1 ? "+" : "-" for x in record) for record in records] +for (label, p, q) in zip(labels, probabilities, independent_probabilities) + println((record=label, joint=p, independent=q)) +end +normalization_error > 1e-5 && @warn "Check ACE convergence before interpreting the residual" normalization_error + +# --- Protocol diagram and record-probability figures --- +set_theme!( + Theme( + fontsize=20, + Axis=( + xgridvisible=false, + ygridvisible=true, + topspinevisible=false, + rightspinevisible=false, + titlesize=22, + xlabelsize=22, + ylabelsize=22, + xticklabelsize=18, + yticklabelsize=18, + ), + Legend=(labelsize=18,), + ), +) + +const PREPARE_COLOR = "#F7E58B" +const POVM_COLOR = "#FCB06D" +const PT_COLOR = "#3A9A5B" +const PT_FILL = (PT_COLOR, 0.28) +const SYS_Y = 0.0 +const PT_Y = 0.28 + +protocol_figure = Figure(size=(1050, 260), figure_padding=(10, 16, 6, 12)) +Label( + protocol_figure[0, 1], + "Three repeated Ramsey readouts"; + fontsize=22, + font=:bold, + tellwidth=false, +) +Label( + protocol_figure[1, 1], + L"\rho_{\mathrm{r}}=|+\rangle\langle+| \qquad E_x^{(\eta)}=\frac{1}{2}(I+x\eta\sigma_x),\ \eta=0.90"; + fontsize=19, + tellwidth=false, +) + +readout_x = (2.50, 5.00, 7.50) +pair_half = 0.14 +marker_clearance = 0.10 +prepare_x = 0.0 +line_start = 0.0 +line_end = 10.0 +xpad = 1.05 + +protocol_axis = Axis( + protocol_figure[2, 1]; + limits=(-xpad, line_end + xpad, -0.48, 0.58), +) +hidedecorations!(protocol_axis) +hidespines!(protocol_axis) + +povm_xs = readout_x .- pair_half +reset_xs = readout_x .+ pair_half +evolution_intervals = ( + (line_start, povm_xs[1] - marker_clearance), + (reset_xs[1] + marker_clearance, povm_xs[2] - marker_clearance), + (reset_xs[2] + marker_clearance, povm_xs[3] - marker_clearance), + (reset_xs[3] + marker_clearance, line_end), +) + +for (x0, x1) in evolution_intervals + xs = range(x0, x1; length=24) + band!( + protocol_axis, + xs, + fill(SYS_Y, length(xs)), + fill(PT_Y, length(xs)); + color=PT_FILL, + ) + lines!(protocol_axis, [x0, x1], [SYS_Y, SYS_Y]; color=:black, linewidth=2.4) +end + +lines!(protocol_axis, [line_start, line_end], [PT_Y, PT_Y]; color=PT_COLOR, linewidth=2.4) +text!( + protocol_axis, + line_start - 0.08, + PT_Y; + text="PT", + align=(:right, :center), + color=PT_COLOR, + fontsize=18, +) +text!( + protocol_axis, + line_start - 0.08, + SYS_Y; + text="QUBIT", + align=(:right, :center), + color=:black, + fontsize=18, +) +scatter!( + protocol_axis, + [line_end], + [PT_Y]; + marker=:rtriangle, + markersize=18, + color=PT_COLOR, +) +scatter!( + protocol_axis, + [line_end], + [SYS_Y]; + marker=:rtriangle, + markersize=18, + color=:black, +) + +scatter!( + protocol_axis, + [prepare_x], + [SYS_Y]; + color=PREPARE_COLOR, + markersize=18, + strokecolor=:black, + strokewidth=2.4, +) +text!( + protocol_axis, + prepare_x, + -0.18; + text=L"\rho_{\mathrm{r}}", + align=(:center, :top), + fontsize=22, +) + +outcome_labels = (L"x_1", L"x_2", L"x_3") +for (round, center) in enumerate(readout_x) + scatter!( + protocol_axis, + [povm_xs[round]], + [SYS_Y]; + color=POVM_COLOR, + markersize=18, + strokecolor=:black, + strokewidth=2.4, + ) + scatter!( + protocol_axis, + [reset_xs[round]], + [SYS_Y]; + color=PREPARE_COLOR, + markersize=18, + strokecolor=:black, + strokewidth=2.4, + ) + text!( + protocol_axis, + center, + PT_Y + 0.10; + text=outcome_labels[round], + align=(:center, :bottom), + fontsize=22, + ) + text!( + protocol_axis, + povm_xs[round] - 0.22, + -0.14; + text=L"E_x^{(\eta)}", + align=(:center, :top), + fontsize=22, + ) + text!( + protocol_axis, + reset_xs[round] + 0.22, + -0.14; + text=L"\rho_{\mathrm{r}}", + align=(:center, :top), + fontsize=22, + ) +end + +record_figure = Figure(size=(1050, 380), figure_padding=(16, 16, 10, 12)) +probability_axis = Axis( + record_figure[1, 1]; + title="Outcome-record probabilities", + xlabel=L"(x_1 x_2 x_3)", + ylabel=L"p(x_1,x_2,x_3)", + xticks=(1:length(labels), labels), +) + +positions = collect(1:length(records)) +barplot!( + probability_axis, + positions .- 0.15, + probabilities; + width=0.26, + color=PT_COLOR, + label="process-tensor record", +) +barplot!( + probability_axis, + positions .+ 0.15, + independent_probabilities; + width=0.26, + color=(POVM_COLOR, 0.92), + label="product of marginals", +) +axislegend(probability_axis; position=:rt, framevisible=false) +ylims!(probability_axis, 0, 1.12 * maximum(vcat(probabilities, independent_probabilities))) + +output_dir = joinpath(@__DIR__, "figures") +mkpath(output_dir) +for (stem, figure) in (("ramsey_povm_protocol", protocol_figure), ("ramsey_povm_records", record_figure)) + save(joinpath(output_dir, stem * ".pdf"), figure) + save(joinpath(output_dir, stem * ".png"), figure; px_per_unit=2) +end +println((figures=output_dir,)) + +# Try changing: test detector blindness, bath decoupling, or the memory timescale. +# ETA=0 gives eight equiprobable records; ALPHA=0 gives independent biased coins. +# For equal waits m*DT, use slots (m, 2m, 3m), NSTEPS > 3m. +# Converge DT, ACE_CUTOFF/ACE_MAXDIM, LOCAL_DIM, and N_BATH before assigning +# physical significance to a small residual. Factorization does not rule out +# memory that this particular instrument cannot detect. diff --git a/scripts/tdvp_tfim_dissipative.jl b/scripts/tdvp_tfim_dissipative.jl index 3320d41..0397791 100644 --- a/scripts/tdvp_tfim_dissipative.jl +++ b/scripts/tdvp_tfim_dissipative.jl @@ -8,7 +8,7 @@ # frequencies, together with 2-TDVP and dense exp(tL). # # Run with: -# julia --project=. scripts/tdvp_tfim_dissipative.jl +# julia --project=. -t auto scripts/tdvp_tfim_dissipative.jl using Printf using ProcessTensors diff --git a/scripts/tdvp_tfim_unitary.jl b/scripts/tdvp_tfim_unitary.jl deleted file mode 100644 index 762b3cf..0000000 --- a/scripts/tdvp_tfim_unitary.jl +++ /dev/null @@ -1,731 +0,0 @@ -# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors -# SPDX-License-Identifier: MIT -# -# File: scripts/tdvp_tfim_unitary.jl -# Contributor: Gauthameshwar S. -# -# Unitary TFIM (N=4): Hilbert- and Liouville-space TDVP with plain 1-TDVP, -# 1-TDVP + global subspace expansion, and 2-TDVP against dense Schrödinger evolution. -# -# Run with: -# julia --project=. scripts/tdvp_tfim_unitary.jl - -using Printf -using ProcessTensors -using ITensors -import ITensorMPS -using LinearAlgebra -using Statistics: mean -using CairoMakie -using LaTeXStrings - -# ------------------------------------------------------------------------------ -# 1. Small script utilities -# ------------------------------------------------------------------------------ - -const STATUS_WIDTH = 90 - -function print_section(title::AbstractString) - println() - println(title) - println("-" ^ length(title)) -end - -function update_status(message::AbstractString) - print("\r", rpad(message, STATUS_WIDTH)) - flush(stdout) -end - -function finish_status(message::AbstractString = "") - print("\r", " "^STATUS_WIDTH, "\r") - if !isempty(message) - println(message) - end - flush(stdout) -end - -function hilbert_mpo_to_dense(ρ::AbstractMPO{Hilbert}, physical_sites) - T = foldl(*, ρ) - A = Array(T, prime.(physical_sites)..., physical_sites...) - return reshape(ComplexF64.(A), prod(dim.(physical_sites)), prod(dim.(physical_sites))) -end - -function hilbert_matrix_to_mpo(M::AbstractMatrix{<:Number}, physical_sites) - dims = vcat(dim.(prime.(physical_sites)), dim.(physical_sites)) - T = ITensor(reshape(ComplexF64.(M), Tuple(dims)), prime.(physical_sites)..., physical_sites...) - return MPO(T, physical_sites) -end - -function hilbert_mps_to_dense(ψ::AbstractMPS{Hilbert}, physical_sites) - T = foldl(*, ψ) - return vec(ComplexF64.(Array(T, physical_sites...))) -end - -function liouville_state_to_dense(ρ_vec::AbstractMPS{Liouville}, physical_sites) - return hilbert_mpo_to_dense(to_hilbert(ρ_vec), physical_sites) -end - -function dense_liouvillian_matrix(os_H::OpSum, jump_ops, physical_sites, liouv_sites_shared) - L_mpo = liouvillian_mpo(os_H, liouv_sites_shared; jump_ops=jump_ops) - d = prod(dim.(physical_sites)) - d2 = d * d - L_dense = zeros(ComplexF64, d2, d2) - for b in 1:d, a in 1:d - q = a + (b - 1) * d - E = zeros(ComplexF64, d, d) - E[a, b] = 1.0 - basis_q = to_liouville(hilbert_matrix_to_mpo(E, physical_sites); sites=liouv_sites_shared) - σ_q = apply(L_mpo, basis_q; cutoff=0.0, maxdim=typemax(Int)) - L_dense[:, q] = vec(liouville_state_to_dense(σ_q, physical_sites)) - end - return L_dense -end - -function dense_hamiltonian_matrix(os_H::OpSum, physical_sites) - return hilbert_mpo_to_dense(MPO(os_H, physical_sites), physical_sites) -end - -function single_site_pauli_mpos(op::AbstractString, physical_sites) - N = length(physical_sites) - return MPO{Hilbert}[ - let os = OpSum() - os += 1.0, op, j - MPO(os, physical_sites) - end for j in 1:N - ] -end - -function mean_pauli_trace_mpo(ρ_vec::MPS{Liouville}, pauli_mpos::Vector{MPO{Hilbert}}) - ρ_h = to_hilbert(ρ_vec) - s = 0.0 - for O in pauli_mpos - ρO = apply(O, ρ_h; alg="naive", truncate=false) - s += real(tr(ρO)) - end - return s / length(pauli_mpos) -end - -function vectorized_identity_state(physical_sites, liouv_sites_shared) - d = prod(dim.(physical_sites)) - identity_mpo = hilbert_matrix_to_mpo(Matrix{ComplexF64}(I, d, d), physical_sites) - return to_liouville(identity_mpo; sites=liouv_sites_shared) -end - -function liouville_trace(ρ_vec::AbstractMPS{Liouville}, trace_bra::AbstractMPS{Liouville}) - return inner(trace_bra, ρ_vec) -end - -function energy_expectation_mpo(ρ_vec::MPS{Liouville}, H_mpo::MPO{Hilbert}) - ρ_h = to_hilbert(ρ_vec) - return real(tr(apply(H_mpo, ρ_h; alg="naive", truncate=false))) -end - -function dense_density_metrics(ρ_dense::AbstractMatrix{<:Number}) - ρ = ComplexF64.(ρ_dense) - herm_defect = norm(ρ - ρ') / max(norm(ρ), eps(Float64)) - ρ_herm = (ρ + ρ') / 2 - λmin = minimum(real.(eigvals(Hermitian(ρ_herm)))) - return (trace=tr(ρ), hermiticity=herm_defect, min_eig=λmin) -end - -function tfim_hamiltonian(N::Int; J::Float64=1.0, h::Float64=1.2) - os_H = OpSum() - for j in 1:(N - 1) - os_H += -J, "Z", j, "Z", j + 1 - end - for j in 1:N - os_H += -h, "X", j - end - return os_H -end - -function dense_one_site_operator(op_name::AbstractString, physical_sites, site::Int) - local_ops = Matrix{ComplexF64}[] - for (j, s) in enumerate(physical_sites) - if j == site - push!(local_ops, Array(op(op_name, s), prime(s), s)) - else - push!(local_ops, Matrix{ComplexF64}(I, dim(s), dim(s))) - end - end - return foldl(kron, local_ops) -end - -function average_observable_dense(ρ::AbstractMatrix{<:Number}, embedded_ops) - return real(sum(tr(ρ * O) for O in embedded_ops) / length(embedded_ops)) -end - -density_error(ρ::AbstractMatrix, ρ_ref::AbstractMatrix) = - norm(ComplexF64.(ρ) - ComplexF64.(ρ_ref)) / max(norm(ComplexF64.(ρ_ref)), eps(Float64)) - -function exact_density_trajectory(H_dense, ψ0_dense::AbstractVector, times::AbstractVector) - densities = Matrix{ComplexF64}[] - Hd = ComplexF64.(H_dense) - ψ0 = ComplexF64.(ψ0_dense) - for t in times - ψt = t == 0 ? ψ0 : exp(-1im * t * Hd) * ψ0 - push!(densities, ψt * ψt') - end - return densities -end - -function exact_metrics(density_trajectory, H_dense, x_ops, z_ops) - energy, trace_err, herm, min_eig, sx, sz = Float64[], Float64[], Float64[], Float64[], Float64[], Float64[] - energy0 = real(tr(first(density_trajectory) * H_dense)) - for ρ_dense in density_trajectory - metrics = dense_density_metrics(ρ_dense) - push!(energy, real(tr(ρ_dense * H_dense))) - push!(trace_err, abs(metrics.trace - 1)) - push!(herm, metrics.hermiticity) - push!(min_eig, metrics.min_eig) - push!(sx, average_observable_dense(ρ_dense, x_ops)) - push!(sz, average_observable_dense(ρ_dense, z_ops)) - end - return ( - energy=energy, - energy_drift=abs.(energy .- energy0), - trace_err=trace_err, - hermiticity=herm, - min_eig=min_eig, - sx=sx, - sz=sz, - ) -end - -state_density_dense(state::AbstractMPS{Hilbert}, physical_sites) = let ψ = hilbert_mps_to_dense(state, physical_sites) - ψ * ψ' -end -state_density_dense(state::AbstractMPS{Liouville}, physical_sites) = liouville_state_to_dense(state, physical_sites) - -state_energy(state::AbstractMPS{Hilbert}, H_mpo) = real(inner(state', H_mpo, state)) -state_energy(state::AbstractMPS{Liouville}, H_mpo) = energy_expectation_mpo(state, H_mpo) - -state_mean_pauli(state::AbstractMPS{Hilbert}, pauli_mpos) = mean(real(inner(state', O, state)) for O in pauli_mpos) -state_mean_pauli(state::AbstractMPS{Liouville}, pauli_mpos) = mean_pauli_trace_mpo(state, pauli_mpos) - -function gse_expand_state( - state::MPS{Hilbert}, - operator; - krylovdim::Int, - gse_cutoff::Float64, - gse_maxdim::Int, -) - expanded_core = ITensorMPS.expand( - state.core, - operator.core; - alg="global_krylov", - krylovdim=krylovdim, - cutoff=gse_cutoff, - apply_kwargs=(; maxdim=gse_maxdim), - ) - ITensorMPS.orthogonalize!(expanded_core, 1) - return MPS{Hilbert}(expanded_core) -end - -function gse_expand_state( - state::MPS{Liouville}, - operator; - krylovdim::Int, - gse_cutoff::Float64, - gse_maxdim::Int, -) - expanded_core = ITensorMPS.expand( - state.core, - operator.core; - alg="global_krylov", - krylovdim=krylovdim, - cutoff=gse_cutoff, - apply_kwargs=(; maxdim=gse_maxdim), - ) - ITensorMPS.orthogonalize!(expanded_core, 1) - return MPS{Liouville}(expanded_core, state.combiners) -end - -function tdvp_trajectory(state0, operator, time_step, dt::Float64, nsteps::Int; nsite::Int, maxdim::Int, cutoff::Float64, label::AbstractString) - states = Vector{typeof(state0)}(undef, nsteps + 1) - states[1] = copy(state0) - current = copy(state0) - progress_stride = max(1, nsteps ÷ 20) - for step in 1:nsteps - current = tdvp(operator, time_step, current; time_step=time_step, nsite=nsite, maxdim=maxdim, cutoff=cutoff, outputlevel=0) - states[step + 1] = current - if step == 1 || step == nsteps || step % progress_stride == 0 - update_status(@sprintf(" %-18s step %4d / %4d t = %.3f bond = %d", label, step, nsteps, step * dt, maxlinkdim(current))) - end - end - finish_status(@sprintf(" %-18s complete (%d snapshots)", label, length(states))) - return states -end - -function tdvp_run_metrics(states, physical_sites, H_mpo, x_mpos, z_mpos, ρ_ed_trajectory) - energy, trace_err, herm, min_eig, sx, sz, bond_dims, rho_errs = - Float64[], Float64[], Float64[], Float64[], Float64[], Float64[], Int[], Float64[] - energy0 = state_energy(first(states), H_mpo) - for (ρ_ed, state) in zip(ρ_ed_trajectory, states) - ρ_dense = state_density_dense(state, physical_sites) - metrics = dense_density_metrics(ρ_dense) - push!(energy, state_energy(state, H_mpo)) - push!(trace_err, abs(metrics.trace - 1)) - push!(herm, metrics.hermiticity) - push!(min_eig, metrics.min_eig) - push!(sx, state_mean_pauli(state, x_mpos)) - push!(sz, state_mean_pauli(state, z_mpos)) - push!(bond_dims, maxlinkdim(state)) - push!(rho_errs, density_error(ρ_dense, ρ_ed)) - end - return ( - energy=energy, - energy_drift=abs.(energy .- energy0), - trace_err=trace_err, - hermiticity=herm, - min_eig=min_eig, - sx=sx, - sz=sz, - bond_dims=bond_dims, - rho_errs=rho_errs, - ) -end - -function tdvp1_gse_trajectory( - state0, - operator, - time_step, - dt::Float64, - nsteps::Int; - maxdim::Int, - cutoff::Float64, - krylovdim::Int, - gse_cutoff::Float64, - gse_maxdim::Int, - gse_every_steps::Int, - label::AbstractString, -) - states = Vector{typeof(state0)}(undef, nsteps + 1) - states[1] = copy(state0) - current = copy(state0) - progress_stride = max(1, nsteps ÷ 20) - for step in 1:nsteps - if step == 1 || (gse_every_steps > 0 && (step - 1) % gse_every_steps == 0) - current = gse_expand_state( - current, - operator; - krylovdim=krylovdim, - gse_cutoff=gse_cutoff, - gse_maxdim=gse_maxdim, - ) - end - current = tdvp( - operator, - time_step, - current; - time_step=time_step, - nsite=1, - maxdim=maxdim, - cutoff=cutoff, - outputlevel=0, - ) - states[step + 1] = current - if step == 1 || step == nsteps || step % progress_stride == 0 - update_status(@sprintf(" %-24s step %4d / %4d t = %.3f bond = %d", label, step, nsteps, step * dt, maxlinkdim(current))) - end - end - finish_status(@sprintf(" %-24s complete (%d snapshots)", label, length(states))) - return states -end - -function max_curve_error(exact::AbstractVector, approx::AbstractVector) - return maximum(abs.(exact .- approx); init=0.0) -end - -function print_series_diagnostics(label::AbstractString, exact, metrics) - err_x = max_curve_error(exact.sx, metrics.sx) - err_z = max_curve_error(exact.sz, metrics.sz) - max_drift = maximum(metrics.energy_drift; init=0.0) - println(label) - @printf(" max |⟨σ_x⟩−ED| = %.3e\n", err_x) - @printf(" max |⟨σ_z⟩−ED| = %.3e\n", err_z) - @printf(" max energy drift = %.3e\n", max_drift) - return (err_x, err_z, max_drift) -end - -const METHOD_COLORS = Dict( - :plain => :dodgerblue3, - :gse10 => :seagreen3, - :tdvp2 => :darkorange2, -) -const ED_LINEWIDTH = 3.6 -const TDVP2_LINEWIDTH = 2.0 -const TDVP1_LINEWIDTH = 2.4 - -function plot_observable_comparison(path, times, exact_curve, run_series, observable::Symbol; ylabel, title, space=nothing) - fig = Figure(size=(900, 520)) - ax = Axis(fig[1, 1]; xlabel=L"$t$", ylabel=ylabel, title=title) - handles, labels = AbstractPlot[], Any[] - h_ed = lines!(ax, times, exact_curve; color=:black, linewidth=ED_LINEWIDTH) - push!(handles, h_ed) - push!(labels, L"$\mathrm{ED}\,(e^{t L})$") - series_list = space === nothing ? run_series : filter(series -> series.space == space, run_series) - for series in series_list - h = lines!( - ax, - times, - getfield(series.metrics, observable); - color=METHOD_COLORS[series.method], - linestyle=series.space == :liouville ? :solid : :dot, - linewidth=series.method == :tdvp2 ? TDVP2_LINEWIDTH : TDVP1_LINEWIDTH, - ) - push!(handles, h) - push!(labels, series.label) - end - axislegend(ax, handles, labels; position=:rt, nbanks=2, fontsize=10) - save(path, fig) - return path -end - -function plot_rho_error_comparison(path, times, run_series; title, space) - fig = Figure(size=(900, 520)) - ax = Axis( - fig[1, 1]; - xlabel=L"$t$", - ylabel="||ρ - ρ_ED|| / ||ρ_ED||", - title=title, - yscale=log10, - ) - handles, labels = AbstractPlot[], Any[] - for series in filter(series -> series.space == space, run_series) - h = lines!( - ax, - times, - max.(series.metrics.rho_errs, eps(Float64)); - color=METHOD_COLORS[series.method], - linestyle=series.space == :liouville ? :solid : :dot, - linewidth=series.method == :tdvp2 ? TDVP2_LINEWIDTH : TDVP1_LINEWIDTH, - ) - push!(handles, h) - push!(labels, series.label) - end - axislegend(ax, handles, labels; position=:rt, nbanks=2, fontsize=10) - save(path, fig) - return path -end - -function plot_energy_drift_comparison(path, times, run_series; title, space) - fig = Figure(size=(900, 520)) - ax = Axis( - fig[1, 1]; - xlabel=L"$t$", - ylabel=L"$\langle H \rangle(t) - \langle H \rangle(0)$", - title=title, - ) - hlines!(ax, [0.0]; color=:black, linewidth=1.2, linestyle=:dash) - handles, labels = AbstractPlot[], Any[] - for series in filter(series -> series.space == space, run_series) - drift = series.metrics.energy .- series.metrics.energy[1] - h = lines!( - ax, - times, - drift; - color=METHOD_COLORS[series.method], - linestyle=series.space == :liouville ? :solid : :dot, - linewidth=series.method == :tdvp2 ? TDVP2_LINEWIDTH : TDVP1_LINEWIDTH, - ) - push!(handles, h) - push!(labels, series.label) - end - axislegend(ax, handles, labels; position=:rt, nbanks=2, fontsize=10) - save(path, fig) - return path -end - -function plot_unitary_conserved(path, times, exact_data, run_series; title) - fig = Figure(size=(1200, 700)) - ax_energy = Axis(fig[1, 1]; xlabel=L"$t$", ylabel=L"$\langle H \rangle (t)$", title=title) - ax_drift = Axis(fig[1, 2]; xlabel=L"$t$", ylabel="energy drift") - ax_trace = Axis(fig[1, 3]; xlabel=L"$t$", ylabel="trace error", yscale=log10) - ax_herm = Axis(fig[2, 1]; xlabel=L"$t$", ylabel="Hermiticity defect", yscale=log10) - ax_psd = Axis(fig[2, 2]; xlabel=L"$t$", ylabel="min eig") - ax_bond = Axis(fig[2, 3]; xlabel=L"$t$", ylabel="max bond dim") - - exact_energy = lines!(ax_energy, times, exact_data.energy; color=:black, linewidth=ED_LINEWIDTH) - lines!(ax_drift, times, exact_data.energy_drift; color=:black, linewidth=ED_LINEWIDTH) - lines!(ax_trace, times, max.(exact_data.trace_err, eps(Float64)); color=:black, linewidth=ED_LINEWIDTH) - lines!(ax_herm, times, max.(exact_data.hermiticity, eps(Float64)); color=:black, linewidth=ED_LINEWIDTH) - lines!(ax_psd, times, exact_data.min_eig; color=:black, linewidth=ED_LINEWIDTH) - - handles, labels = AbstractPlot[exact_energy], [L"$\mathrm{ED}$"] - for series in filter(series -> series.space == :liouville, run_series) - data = series.metrics - linewidth = series.method == :tdvp2 ? TDVP2_LINEWIDTH : TDVP1_LINEWIDTH - h = lines!(ax_energy, times, data.energy; color=METHOD_COLORS[series.method], linestyle=:solid, linewidth=linewidth) - lines!(ax_drift, times, max.(data.energy_drift, eps(Float64)); color=METHOD_COLORS[series.method], linestyle=:solid, linewidth=linewidth) - lines!(ax_trace, times, max.(data.trace_err, eps(Float64)); color=METHOD_COLORS[series.method], linestyle=:solid, linewidth=linewidth) - lines!(ax_herm, times, max.(data.hermiticity, eps(Float64)); color=METHOD_COLORS[series.method], linestyle=:solid, linewidth=linewidth) - lines!(ax_psd, times, data.min_eig; color=METHOD_COLORS[series.method], linestyle=:solid, linewidth=linewidth) - lines!(ax_bond, times, data.bond_dims; color=METHOD_COLORS[series.method], linestyle=:solid, linewidth=linewidth) - push!(handles, h) - push!(labels, series.label) - end - for series in filter(series -> series.space == :hilbert, run_series) - data = series.metrics - linewidth = series.method == :tdvp2 ? TDVP2_LINEWIDTH : TDVP1_LINEWIDTH - h = lines!(ax_energy, times, data.energy; color=METHOD_COLORS[series.method], linestyle=:dot, linewidth=linewidth) - lines!(ax_drift, times, max.(data.energy_drift, eps(Float64)); color=METHOD_COLORS[series.method], linestyle=:dot, linewidth=linewidth) - lines!(ax_trace, times, max.(data.trace_err, eps(Float64)); color=METHOD_COLORS[series.method], linestyle=:dot, linewidth=linewidth) - lines!(ax_herm, times, max.(data.hermiticity, eps(Float64)); color=METHOD_COLORS[series.method], linestyle=:dot, linewidth=linewidth) - lines!(ax_psd, times, data.min_eig; color=METHOD_COLORS[series.method], linestyle=:dot, linewidth=linewidth) - lines!(ax_bond, times, data.bond_dims; color=METHOD_COLORS[series.method], linestyle=:dot, linewidth=linewidth) - push!(handles, h) - push!(labels, series.label) - end - Legend(fig[3, 1:3], handles, labels; orientation=:horizontal, nbanks=2, tellwidth=false, tellheight=true) - save(path, fig) - return path -end - -# ------------------------------------------------------------------------------ -# 2. Script parameters -# ------------------------------------------------------------------------------ - -const N = 4 -const J = 1.0 -const h = 1.2 -const T_max = 4.0 -const dt = 0.05 -const nsteps = round(Int, T_max / dt) -const maxdim_1site = 50 -const maxdim_2site = 50 -const gse_every_steps = 10 -const krylovdim = 2 -const gse_cutoff = 1e-8 -const cutoff = 1e-10 - -output_dir = joinpath(@__DIR__, "figures") -mkpath(output_dir) - -# ------------------------------------------------------------------------------ -# 3. Define the physical problem -# ------------------------------------------------------------------------------ - -print_section("Problem setup") - -println("Unitary transverse-field Ising chain: Hilbert- and Liouville-space TDVP vs dense Schrödinger evolution.") -println() - -@printf("chain length N = %d\n", N) -@printf("J, h = %.3f, %.3f\n", J, h) -@printf("final time T_max = %.3f\n", T_max) -@printf("time step dt = %.3f\n", dt) -@printf("1-TDVP max bond dim = %d\n", maxdim_1site) -@printf("2-TDVP max bond dim = %d\n", maxdim_2site) -@printf("GSE every-n steps = %d\n", gse_every_steps) -@printf("GSE Krylov dim = %d\n", krylovdim) -@printf("GSE cutoff = %.1e\n", gse_cutoff) -@printf("SVD cutoff = %.1e\n", cutoff) - -physical_sites = siteinds("S=1/2", N) -liouv_sites_shared = liouv_sites(physical_sites) -os_H = tfim_hamiltonian(N; J=J, h=h) -H_mpo = MPO(os_H, physical_sites) -H_dense = dense_hamiltonian_matrix(os_H, physical_sites) - -ψ0_hilbert = MPS(physical_sites, fill("Up", N)) -ρ0 = to_dm(ψ0_hilbert) -ρ0_liouville = to_liouville(ρ0; sites=liouv_sites_shared) -ψ0_dense = hilbert_mps_to_dense(ψ0_hilbert, physical_sites) - -x_ops = [dense_one_site_operator("X", physical_sites, j) for j in 1:N] -z_ops = [dense_one_site_operator("Z", physical_sites, j) for j in 1:N] -x_mpos = single_site_pauli_mpos("X", physical_sites) -z_mpos = single_site_pauli_mpos("Z", physical_sites) -times = collect(range(0.0, step=dt, length=nsteps + 1)) - -# ------------------------------------------------------------------------------ -# 4. Main computation -# ------------------------------------------------------------------------------ - -print_section("Main computation") - -println("Building dense Hilbert-space reference...") -exact_densities = exact_density_trajectory(H_dense, ψ0_dense, times) -exact = exact_metrics(exact_densities, H_dense, x_ops, z_ops) - -println("Running Hilbert- and Liouville-space TDVP sweeps...") -run_series = NamedTuple{(:label, :method, :space, :metrics), Tuple{String, Symbol, Symbol, Any}}[] - -label_plain_h = "1-TDVP plain Hilbert" -states_plain_h = tdvp_trajectory( - ψ0_hilbert, - H_mpo, - -1im * dt, - dt, - nsteps; - nsite=1, - maxdim=maxdim_1site, - cutoff=cutoff, - label=label_plain_h, -) -metrics_plain_h = tdvp_run_metrics(states_plain_h, physical_sites, H_mpo, x_mpos, z_mpos, exact_densities) -push!(run_series, (; label=label_plain_h, method=:plain, space=:hilbert, metrics=metrics_plain_h)) - -label_gse_h = "1-TDVP+GSE Hilbert" -states_gse_h = tdvp1_gse_trajectory( - ψ0_hilbert, - H_mpo, - -1im * dt, - dt, - nsteps; - maxdim=maxdim_1site, - cutoff=cutoff, - krylovdim=krylovdim, - gse_cutoff=gse_cutoff, - gse_maxdim=maxdim_1site, - label=label_gse_h, - gse_every_steps=gse_every_steps, -) -metrics_gse_h = tdvp_run_metrics(states_gse_h, physical_sites, H_mpo, x_mpos, z_mpos, exact_densities) -push!(run_series, (; label=label_gse_h, method=:gse10, space=:hilbert, metrics=metrics_gse_h)) - -label_2site_h = "2-TDVP Hilbert" -states_2site_h = tdvp_trajectory( - ψ0_hilbert, - H_mpo, - -1im * dt, - dt, - nsteps; - nsite=2, - maxdim=maxdim_2site, - cutoff=cutoff, - label=label_2site_h, -) -metrics_2site_h = tdvp_run_metrics(states_2site_h, physical_sites, H_mpo, x_mpos, z_mpos, exact_densities) -push!(run_series, (; label=label_2site_h, method=:tdvp2, space=:hilbert, metrics=metrics_2site_h)) - -L_mpo = liouvillian_mpo(os_H, liouv_sites_shared; jump_ops=Tuple{Number, String, Int}[]) - -label_plain_l = "1-TDVP plain Liouville" -states_plain_l = tdvp_trajectory( - ρ0_liouville, - L_mpo, - dt, - dt, - nsteps; - nsite=1, - maxdim=maxdim_1site, - cutoff=cutoff, - label=label_plain_l, -) -metrics_plain_l = tdvp_run_metrics(states_plain_l, physical_sites, H_mpo, x_mpos, z_mpos, exact_densities) -push!(run_series, (; label=label_plain_l, method=:plain, space=:liouville, metrics=metrics_plain_l)) - -label_gse_l = "1-TDVP+GSE Liouville" -states_gse_l = tdvp1_gse_trajectory( - ρ0_liouville, - L_mpo, - dt, - dt, - nsteps; - maxdim=maxdim_1site, - cutoff=cutoff, - krylovdim=krylovdim, - gse_cutoff=gse_cutoff, - gse_maxdim=maxdim_1site, - label=label_gse_l, - gse_every_steps=gse_every_steps, -) -metrics_gse_l = tdvp_run_metrics(states_gse_l, physical_sites, H_mpo, x_mpos, z_mpos, exact_densities) -push!(run_series, (; label=label_gse_l, method=:gse10, space=:liouville, metrics=metrics_gse_l)) - -label_2site_l = "2-TDVP Liouville" -states_2site_l = tdvp_trajectory( - ρ0_liouville, - L_mpo, - dt, - dt, - nsteps; - nsite=2, - maxdim=maxdim_2site, - cutoff=cutoff, - label=label_2site_l, -) -metrics_2site_l = tdvp_run_metrics(states_2site_l, physical_sites, H_mpo, x_mpos, z_mpos, exact_densities) -push!(run_series, (; label=label_2site_l, method=:tdvp2, space=:liouville, metrics=metrics_2site_l)) - -# ------------------------------------------------------------------------------ -# 5. Diagnostics and sanity checks -# ------------------------------------------------------------------------------ - -print_section("Diagnostics") - -for series in run_series - print_series_diagnostics(series.label, exact, series.metrics) - println() -end - -@assert all(isfinite, exact.sx) && all(isfinite, exact.sz) - -# ------------------------------------------------------------------------------ -# 6. Plotting and saved outputs -# ------------------------------------------------------------------------------ - -print_section("Plotting") - -plot_title = "Unitary TFIM TDVP (N=$N, J=$J, h=$h, dt=$dt)" -fig_path_x = joinpath(output_dir, "tdvp_tfim_unitary_mx.png") -fig_path_z = joinpath(output_dir, "tdvp_tfim_unitary_mz.png") -fig_path_cons = joinpath(output_dir, "tdvp_tfim_unitary_conserved.png") - -plot_observable_comparison( - fig_path_x, times, exact.sx, run_series, :sx; - ylabel=L"$\langle \overline{\sigma}_x \rangle (t)$", title=plot_title, -) -plot_observable_comparison( - fig_path_z, times, exact.sz, run_series, :sz; - ylabel=L"$\langle \overline{\sigma}_z \rangle (t)$", title=plot_title, -) -plot_unitary_conserved(fig_path_cons, times, exact, run_series; title="Unitary TFIM conserved quantities") - -hilbert_title = "Hilbert TFIM TDVP (N=$N, J=$J, h=$h, dt=$dt)" -liouville_title = "Liouville TFIM TDVP (N=$N, J=$J, h=$h, dt=$dt)" -fig_path_h_x = joinpath(output_dir, "tdvp_tfim_unitary_hilbert_dynamics_mx.png") -fig_path_h_energy = joinpath(output_dir, "tdvp_tfim_unitary_hilbert_energy_drift.png") -fig_path_h_rho = joinpath(output_dir, "tdvp_tfim_unitary_hilbert_rho_error.png") -fig_path_l_x = joinpath(output_dir, "tdvp_tfim_unitary_liouville_dynamics_mx.png") -fig_path_l_energy = joinpath(output_dir, "tdvp_tfim_unitary_liouville_energy_drift.png") -fig_path_l_rho = joinpath(output_dir, "tdvp_tfim_unitary_liouville_rho_error.png") - -plot_observable_comparison( - fig_path_h_x, times, exact.sx, run_series, :sx; - ylabel=L"$\langle \overline{\sigma}_x \rangle (t)$", title=hilbert_title, space=:hilbert, -) -plot_energy_drift_comparison(fig_path_h_energy, times, run_series; title=hilbert_title, space=:hilbert) -plot_rho_error_comparison(fig_path_h_rho, times, run_series; title=hilbert_title, space=:hilbert) -plot_observable_comparison( - fig_path_l_x, times, exact.sx, run_series, :sx; - ylabel=L"$\langle \overline{\sigma}_x \rangle (t)$", title=liouville_title, space=:liouville, -) -plot_energy_drift_comparison(fig_path_l_energy, times, run_series; title=liouville_title, space=:liouville) -plot_rho_error_comparison(fig_path_l_rho, times, run_series; title=liouville_title, space=:liouville) - -println("Saved figures:") -println(" $fig_path_x") -println(" $fig_path_z") -println(" $fig_path_cons") -println(" $fig_path_h_x") -println(" $fig_path_h_energy") -println(" $fig_path_h_rho") -println(" $fig_path_l_x") -println(" $fig_path_l_energy") -println(" $fig_path_l_rho") - -# ------------------------------------------------------------------------------ -# 7. Final summary -# ------------------------------------------------------------------------------ - -print_section("Summary") - -println("Completed unitary TFIM TDVP benchmark.") -println() -println("Main outputs:") -println(" figure: $fig_path_x") -println(" figure: $fig_path_z") -println(" figure: $fig_path_cons") diff --git a/scripts/tebd_tfim_dissipative.jl b/scripts/tebd_tfim_dissipative.jl index 522f13a..593e64f 100644 --- a/scripts/tebd_tfim_dissipative.jl +++ b/scripts/tebd_tfim_dissipative.jl @@ -8,7 +8,7 @@ # against dense exp(tL) for excited-spin density and mean transverse magnetization. # # Run with: -# julia --project=. scripts/tebd_tfim_dissipative.jl +# julia --project=. -t auto scripts/tebd_tfim_dissipative.jl using Printf using ProcessTensors diff --git a/scripts/tebd_tfim_unitary.jl b/scripts/tebd_tfim_unitary.jl deleted file mode 100644 index b23d4d9..0000000 --- a/scripts/tebd_tfim_unitary.jl +++ /dev/null @@ -1,403 +0,0 @@ -# Copyright © 2026 Gauthameshwar and ProcessTensors.jl contributors -# SPDX-License-Identifier: MIT -# -# File: scripts/tebd_tfim_unitary.jl -# Contributor: Gauthameshwar S. -# -# Unitary TFIM (N=4): Hilbert- and Liouville-space TEBD vs dense exp(tL). -# -# Run with: -# julia --project=. scripts/tebd_tfim_unitary.jl - -using Printf -using ProcessTensors -using ITensors -using ITensors.Ops: Trotter -using LinearAlgebra -using CairoMakie -using LaTeXStrings - -# ------------------------------------------------------------------------------ -# 1. Small script utilities -# ------------------------------------------------------------------------------ - -const STATUS_WIDTH = 90 - -function print_section(title::AbstractString) - println() - println(title) - println("-" ^ length(title)) -end - -function update_status(message::AbstractString) - print("\r", rpad(message, STATUS_WIDTH)) - flush(stdout) -end - -function finish_status(message::AbstractString = "") - print("\r", " "^STATUS_WIDTH, "\r") - if !isempty(message) - println(message) - end - flush(stdout) -end - -function tfim_hamiltonian(N::Int; J::Float64=1.0, h::Float64=1.2) - os_H = OpSum() - for j in 1:(N - 1) - os_H += -J, "Z", j, "Z", j + 1 - end - for j in 1:N - os_H += -h, "X", j - end - return os_H -end - -function hilbert_mpo_to_dense(ρ::AbstractMPO{Hilbert}, physical_sites) - T = foldl(*, ρ) - A = Array(T, prime.(physical_sites)..., physical_sites...) - return reshape(ComplexF64.(A), prod(dim.(physical_sites)), prod(dim.(physical_sites))) -end - -function hilbert_matrix_to_mpo(M::AbstractMatrix{<:Number}, physical_sites) - dims = vcat(dim.(prime.(physical_sites)), dim.(physical_sites)) - T = ITensor(reshape(ComplexF64.(M), Tuple(dims)), prime.(physical_sites)..., physical_sites...) - return MPO(T, physical_sites) -end - -function dense_liouvillian_matrix(os_H::OpSum, jump_ops, physical_sites, liouv_sites_shared) - L_mpo = liouvillian_mpo(os_H, liouv_sites_shared; jump_ops=jump_ops) - d = prod(dim.(physical_sites)) - d2 = d * d - L_dense = zeros(ComplexF64, d2, d2) - for b in 1:d, a in 1:d - q = a + (b - 1) * d - E = zeros(ComplexF64, d, d) - E[a, b] = 1.0 - basis_q = to_liouville(hilbert_matrix_to_mpo(E, physical_sites); sites=liouv_sites_shared) - σ_q = apply(L_mpo, basis_q; cutoff=0.0, maxdim=typemax(Int)) - ρ_out = hilbert_mpo_to_dense(to_hilbert(σ_q), physical_sites) - L_dense[:, q] = vec(ρ_out) - end - return L_dense -end - -state_to_density_dense(state::AbstractMPS{Hilbert}, physical_sites) = - hilbert_mpo_to_dense(to_dm(state), physical_sites) -state_to_density_dense(state::AbstractMPS{Liouville}, physical_sites) = - hilbert_mpo_to_dense(to_hilbert(state), physical_sites) - -density_error(ρ::AbstractMatrix, ρ_ref::AbstractMatrix) = - norm(ρ - ρ_ref) / max(norm(ρ_ref), eps(Float64)) - -function dense_one_site_operator(op_name::AbstractString, physical_sites, site::Int) - local_ops = Matrix{ComplexF64}[] - for (j, s) in enumerate(physical_sites) - if j == site - push!(local_ops, Array(op(op_name, s), prime(s), s)) - else - push!(local_ops, Matrix{ComplexF64}(I, dim(s), dim(s))) - end - end - return foldl(kron, local_ops) -end - -function mean_sx_from_density(ρ::AbstractMatrix, x_ops) - return real(sum(tr(ρ * O) for O in x_ops) / length(x_ops)) -end - -function exact_sx_trajectory(L_dense, vec0, d, x_ops, times::AbstractVector) - sx = Float64[] - for t in times - push!(sx, mean_sx_from_density(exact_density_at(t, L_dense, vec0, d), x_ops)) - end - return sx -end - -function single_site_pauli_mpos(op::AbstractString, physical_sites) - N = length(physical_sites) - return MPO{Hilbert}[ - let os = OpSum() - os += 1.0, op, j - MPO(os, physical_sites) - end for j in 1:N - ] -end - -function mean_sx(state::AbstractMPS{Hilbert}, x_mpos) - return real(sum(inner(state', O, state) for O in x_mpos) / length(x_mpos)) -end - -function mean_sx(state::AbstractMPS{Liouville}, x_mpos) - ρ_h = to_hilbert(state) - s = 0.0 - for O in x_mpos - ρO = apply(O, ρ_h; alg="naive", truncate=false) - s += real(tr(ρO)) - end - return s / length(x_mpos) -end - -function exact_density_at(t::Real, L_dense::AbstractMatrix, vec0::AbstractVector, d::Int) - Lt = ComplexF64.(L_dense) - v0 = ComplexF64.(vec0) - vt = iszero(t) ? v0 : exp(t * Lt) * v0 - return reshape(vt, d, d) -end - -function run_hilbert_tebd(ψ0, os_H, physical_sites, x_mpos, L_dense, vec0, d, T_max, dt, alg; maxdim, cutoff) - ψ = copy(ψ0) - times = Float64[0.0] - rho_errs = Float64[density_error(state_to_density_dense(ψ, physical_sites), exact_density_at(0.0, L_dense, vec0, d))] - sx = Float64[mean_sx(ψ, x_mpos)] - t = 0.0 - elapsed = @elapsed begin - while t < T_max - 1e-12 - Δt = min(dt, T_max - t) - ψ = tebd(ψ, os_H, Δt, Δt; maxdim=maxdim, cutoff=cutoff, alg=alg) - t += Δt - push!(times, t) - ρ_ed = exact_density_at(t, L_dense, vec0, d) - push!(rho_errs, density_error(state_to_density_dense(ψ, physical_sites), ρ_ed)) - push!(sx, mean_sx(ψ, x_mpos)) - end - end - return (; times, rho_errs, sx, elapsed, max_bond=maxlinkdim(ψ)) -end - -function run_liouville_tebd(ρ0_vec, os_H, physical_sites, x_mpos, L_dense, vec0, d, T_max, dt, alg; maxdim, cutoff, jump_ops) - current = copy(ρ0_vec) - times = Float64[0.0] - rho_errs = Float64[density_error(state_to_density_dense(current, physical_sites), exact_density_at(0.0, L_dense, vec0, d))] - sx = Float64[mean_sx(current, x_mpos)] - t = 0.0 - elapsed = @elapsed begin - while t < T_max - 1e-12 - Δt = min(dt, T_max - t) - current = tebd( - current, - os_H, - Δt, - Δt; - jump_ops=jump_ops, - maxdim=maxdim, - cutoff=cutoff, - alg=alg, - ) - t += Δt - push!(times, t) - ρ_ed = exact_density_at(t, L_dense, vec0, d) - push!(rho_errs, density_error(state_to_density_dense(current, physical_sites), ρ_ed)) - push!(sx, mean_sx(current, x_mpos)) - end - end - return (; times, rho_errs, sx, elapsed, max_bond=maxlinkdim(current)) -end - -function print_tebd_summary(label::AbstractString, n::Int, dt::Real, result) - @printf("%s TEBD(%d) with dt=%.2f\n", label, n, dt) - @printf(" Total time taken: %.3f s\n", result.elapsed) - @printf(" |ρ - ρ_ED|: %.3e\n", result.rho_errs[end]) - @printf(" max bond dim: %d\n", result.max_bond) - println() -end - -function save_sx_plot(run_map, t_exact, sx_exact, output_path, representation::AbstractString, T_max, N, J, h) - tebd_lw, ed_lw = 2.5, 2.5 - fig = Figure(size=(900, 520)) - ax = Axis( - fig[1, 1]; - xlabel=L"$t$", - ylabel=L"$\langle \overline{\sigma}_x \rangle (t)$", - title="$representation TFIM (N=$N, J=$J, h=$h)", - ) - xlims!(ax, 0, T_max) - handles, labels = AbstractPlot[], Any[] - for dt in dt_list - t1, sx1 = run_map[(1, dt)].times, run_map[(1, dt)].sx - t2, sx2 = run_map[(2, dt)].times, run_map[(2, dt)].sx - h1 = lines!(ax, t1, sx1; linestyle=:dashdot, linewidth=tebd_lw) - h2 = lines!(ax, t2, sx2; linestyle=:solid, linewidth=tebd_lw) - push!(handles, h1, h2) - push!( - labels, - LaTeXString("\\mathrm{TEBD}(1),\\; dt = $(string(dt))"), - LaTeXString("\\mathrm{TEBD}(2),\\; dt = $(string(dt))"), - ) - end - h_ex = lines!(ax, t_exact, sx_exact; color=:black, linewidth=ed_lw) - push!(handles, h_ex) - push!(labels, L"$\mathrm{ED}\,(e^{t L})$") - axislegend(ax, handles, labels; position=:rt, nbanks=2, fontsize=10) - save(output_path, fig) -end - -function save_rho_error_plot(run_map, output_path, representation::AbstractString, T_max, N, J, h) - tebd_lw = 2.5 - fig = Figure(size=(900, 520)) - ax = Axis( - fig[1, 1]; - xlabel=L"$t$", - ylabel="||ρ - ρ_ED|| / ||ρ_ED||", - title="$representation TFIM (N=$N, J=$J, h=$h)", - ) - xlims!(ax, 0, T_max) - handles, labels = AbstractPlot[], Any[] - for dt in dt_list - t1, err1 = run_map[(1, dt)].times, run_map[(1, dt)].rho_errs - t2, err2 = run_map[(2, dt)].times, run_map[(2, dt)].rho_errs - h1 = lines!(ax, t1, err1; linestyle=:dashdot, linewidth=tebd_lw) - h2 = lines!(ax, t2, err2; linestyle=:solid, linewidth=tebd_lw) - push!(handles, h1, h2) - push!( - labels, - LaTeXString("\\mathrm{TEBD}(1),\\; dt = $(string(dt))"), - LaTeXString("\\mathrm{TEBD}(2),\\; dt = $(string(dt))"), - ) - end - axislegend(ax, handles, labels; position=:rt, nbanks=2, fontsize=10) - save(output_path, fig) -end - -# ------------------------------------------------------------------------------ -# 2. Script parameters -# ------------------------------------------------------------------------------ - -const N = 4 -const J = 1.0 -const h = 1.2 -const T_max = 9.0 -const dt_list = Float64[0.2, 0.1, 0.05] -const trotter_orders = (1, 2) -const maxdim = 128 -const cutoff = 1e-12 -const n_exact = 281 - -output_dir = joinpath(@__DIR__, "figures") -mkpath(output_dir) - -# ------------------------------------------------------------------------------ -# 3. Define the physical problem -# ------------------------------------------------------------------------------ - -print_section("Problem setup") - -println("Unitary transverse-field Ising chain: Hilbert- and Liouville-space TEBD vs dense exp(tL).") -println() - -@printf("chain length N = %d\n", N) -@printf("J, h = %.3f, %.3f\n", J, h) -@printf("final time T_max = %.3f\n", T_max) -@printf("TEBD dt values = %s\n", join(string.(dt_list), ", ")) -@printf("Trotter orders = %s\n", join(string.(trotter_orders), ", ")) -@printf("max bond dimension = %d\n", maxdim) -@printf("SVD cutoff = %.1e\n", cutoff) - -physical_sites = siteinds("S=1/2", N) -liouv_sites_shared = liouv_sites(physical_sites) -os_H = tfim_hamiltonian(N; J=J, h=h) -jump_ops = Tuple{Number, String, Int}[] - -ψ0 = MPS(physical_sites, fill("Up", N)) -ρ0 = to_dm(ψ0) -ρ0_vec = to_liouville(ρ0; sites=liouv_sites_shared) -x_mpos = single_site_pauli_mpos("X", physical_sites) - -# ------------------------------------------------------------------------------ -# 4. Main computation -# ------------------------------------------------------------------------------ - -print_section("Main computation") - -println("Building dense Liouvillian reference...") -d = prod(dim.(physical_sites)) -vec0 = vec(ComplexF64.(hilbert_mpo_to_dense(ρ0, physical_sites))) -L_dense = dense_liouvillian_matrix(os_H, jump_ops, physical_sites, liouv_sites_shared) -x_ops = [dense_one_site_operator("X", physical_sites, j) for j in 1:N] -t_exact = collect(range(0.0, T_max; length=n_exact)) -sx_exact = exact_sx_trajectory(L_dense, vec0, d, x_ops, t_exact) - -run_configs = [(n, dt) for n in trotter_orders for dt in dt_list] -n_runs = length(run_configs) - -println("Running Hilbert-space TEBD...") -hilbert_runs = Dict{Tuple{Int, Float64}, NamedTuple}() -for (run_idx, (n, dt)) in enumerate(run_configs) - alg = Trotter{n}() - update_status(@sprintf(" Hilbert TEBD(%d) dt=%.2f run %d/%d", n, dt, run_idx, n_runs)) - hilbert_runs[(n, dt)] = run_hilbert_tebd( - ψ0, os_H, physical_sites, x_mpos, L_dense, vec0, d, T_max, dt, alg; - maxdim=maxdim, cutoff=cutoff, - ) - finish_status(@sprintf(" Hilbert TEBD(%d) dt=%.2f complete", n, dt)) -end - -println("Running Liouville-space TEBD...") -liouville_runs = Dict{Tuple{Int, Float64}, NamedTuple}() -for (run_idx, (n, dt)) in enumerate(run_configs) - alg = Trotter{n}() - update_status(@sprintf(" Liouville TEBD(%d) dt=%.2f run %d/%d", n, dt, run_idx, n_runs)) - liouville_runs[(n, dt)] = run_liouville_tebd( - ρ0_vec, os_H, physical_sites, x_mpos, L_dense, vec0, d, T_max, dt, alg; - maxdim=maxdim, cutoff=cutoff, jump_ops=jump_ops, - ) - finish_status(@sprintf(" Liouville TEBD(%d) dt=%.2f complete", n, dt)) -end - -# ------------------------------------------------------------------------------ -# 5. Diagnostics and sanity checks -# ------------------------------------------------------------------------------ - -print_section("Diagnostics") - -println("Hilbert-space TEBD") -println("------------------") -for (n, dt) in run_configs - print_tebd_summary("Hilbert", n, dt, hilbert_runs[(n, dt)]) -end - -println("Liouville-space TEBD") -println("--------------------") -for (n, dt) in run_configs - print_tebd_summary("Liouville", n, dt, liouville_runs[(n, dt)]) -end - -@assert all(r -> all(isfinite, r.rho_errs), values(hilbert_runs)) -@assert all(r -> all(isfinite, r.rho_errs), values(liouville_runs)) - -# ------------------------------------------------------------------------------ -# 6. Plotting and saved outputs -# ------------------------------------------------------------------------------ - -print_section("Plotting") - -fig_paths = String[] -for (representation, run_map, tag) in ( - ("Hilbert", hilbert_runs, "hilbert"), - ("Liouville", liouville_runs, "liouville"), -) - sx_path = joinpath(output_dir, "tebd_tfim_unitary_$(tag)_dynamics_mx.png") - save_sx_plot(run_map, t_exact, sx_exact, sx_path, representation, T_max, N, J, h) - push!(fig_paths, sx_path) - rho_path = joinpath(output_dir, "tebd_tfim_unitary_$(tag)_rho_error.png") - save_rho_error_plot(run_map, rho_path, representation, T_max, N, J, h) - push!(fig_paths, rho_path) -end - -println("Saved figures:") -for fig_path in fig_paths - println(" $fig_path") -end - -# ------------------------------------------------------------------------------ -# 7. Final summary -# ------------------------------------------------------------------------------ - -print_section("Summary") - -println("Completed unitary TFIM TEBD benchmark.") -println() -println("Main outputs:") -for fig_path in fig_paths - println(" figure: $fig_path") -end diff --git a/scripts/terminal/README.md b/scripts/terminal/README.md index ec06111..4cc607c 100644 --- a/scripts/terminal/README.md +++ b/scripts/terminal/README.md @@ -3,20 +3,20 @@ Run these scripts from a visible terminal: ```bash -julia --project=. scripts/terminal/build_process_tensor.jl -julia --project=. scripts/terminal/create_instruments.jl -julia --project=. scripts/terminal/evaluate_process.jl -julia --project=. scripts/terminal/evolve.jl -julia --project=. scripts/terminal/tebd.jl -julia --project=. scripts/terminal/spinner_demo.jl +julia --project=. -t auto scripts/terminal/build_process_tensor.jl +julia --project=. -t auto scripts/terminal/create_instruments.jl +julia --project=. -t auto scripts/terminal/evaluate_process.jl +julia --project=. -t auto scripts/terminal/evolve.jl +julia --project=. -t auto scripts/terminal/tebd.jl +julia --project=. -t auto scripts/terminal/spinner_demo.jl ``` Each script sets `progress=true, verbose=true` so that transient ProgressMeter feedback and durable Julia `@info` records are visible together. `spinner_demo.jl` walks through the header-spinner + child-bar layout used by -`build_process_tensor`. Prefer `julia -t auto --project=. -scripts/terminal/spinner_demo.jl` so the glyph keeps rotating during CPU work. +`build_process_tensor`. The commands above pass `-t auto` so the glyph keeps +rotating during CPU work. To emulate a headless job, change the local settings to: @@ -27,5 +27,5 @@ verbose = true For a quiet run, use `progress=false, verbose=false`. Progress bars are transient and clear when their stage completes; verbose records remain suitable -for redirection, for example `julia --project=. scripts/terminal/evolve.jl 2>&1 +for redirection, for example `julia --project=. -t auto scripts/terminal/evolve.jl 2>&1 | tee run.log`. diff --git a/scripts/terminal/build_process_tensor.jl b/scripts/terminal/build_process_tensor.jl index c81e7e2..b4759b7 100644 --- a/scripts/terminal/build_process_tensor.jl +++ b/scripts/terminal/build_process_tensor.jl @@ -8,7 +8,7 @@ # verbose logging for a small single-mode spin bath. # # Run with: -# julia --project=. scripts/terminal/build_process_tensor.jl +# julia --project=. -t auto scripts/terminal/build_process_tensor.jl include("common.jl") @info "Loaded common.jl" diff --git a/scripts/terminal/create_instruments.jl b/scripts/terminal/create_instruments.jl index 818006a..4fa8f96 100644 --- a/scripts/terminal/create_instruments.jl +++ b/scripts/terminal/create_instruments.jl @@ -8,7 +8,7 @@ # progress and persistent verbose logging. # # Run with: -# julia --project=. scripts/terminal/create_instruments.jl +# julia --project=. -t auto scripts/terminal/create_instruments.jl include("common.jl") using ProcessTensors.Instruments: create_instruments diff --git a/scripts/terminal/evaluate_process.jl b/scripts/terminal/evaluate_process.jl index 4b59815..9bed368 100644 --- a/scripts/terminal/evaluate_process.jl +++ b/scripts/terminal/evaluate_process.jl @@ -8,7 +8,7 @@ # progress, and durable verbose open-leg diagnostics. # # Run with: -# julia --project=. scripts/terminal/evaluate_process.jl +# julia --project=. -t auto scripts/terminal/evaluate_process.jl include("common.jl") @info "Loaded common.jl" diff --git a/scripts/terminal/evolve.jl b/scripts/terminal/evolve.jl index c094a89..7975a34 100644 --- a/scripts/terminal/evolve.jl +++ b/scripts/terminal/evolve.jl @@ -8,7 +8,7 @@ # for a small single-mode spin-bath process tensor. # # Run with: -# julia --project=. scripts/terminal/evolve.jl +# julia --project=. -t auto scripts/terminal/evolve.jl include("common.jl") @info "Loaded common.jl" diff --git a/scripts/terminal/spinner_demo.jl b/scripts/terminal/spinner_demo.jl index 8d550d4..947bbcf 100644 --- a/scripts/terminal/spinner_demo.jl +++ b/scripts/terminal/spinner_demo.jl @@ -9,7 +9,7 @@ # visible; real package algorithms should report actual scientific work. # # Run with: -# julia --project=. scripts/terminal/spinner_demo.jl +# julia --project=. -t auto scripts/terminal/spinner_demo.jl using ProcessTensors diff --git a/scripts/terminal/tebd.jl b/scripts/terminal/tebd.jl index 2c10db7..590846d 100644 --- a/scripts/terminal/tebd.jl +++ b/scripts/terminal/tebd.jl @@ -8,7 +8,7 @@ # verbose logging on a short two-spin chain. # # Run with: -# julia --project=. scripts/terminal/tebd.jl +# julia --project=. -t auto scripts/terminal/tebd.jl using ProcessTensors using ITensors diff --git a/scripts/thermal_spinboson_ace.jl b/scripts/thermal_spinboson_ace.jl index 4bb48cc..7b23c3b 100644 --- a/scripts/thermal_spinboson_ace.jl +++ b/scripts/thermal_spinboson_ace.jl @@ -4,706 +4,227 @@ # File: scripts/thermal_spinboson_ace.jl # Contributor: Gauthameshwar S. # -# Reproduces the thermal spin-boson example of: -# M. Cygorek and E. M. Gauger, J. Chem. Phys. 161, 074111 (2024), Fig. 3(c,f). +# Driven thermal spin-boson model of Cygorek and Gauger, +# J. Chem. Phys. 161, 074111 (2024), Fig. 3(c,f), plus a hotter-bath comparison. # # Run with: -# julia --project=. scripts/thermal_spinboson_ace.jl -# -# Matching ACE caches in scripts/.cache skip process-tensor construction. -# Override with PT_THERMAL_CACHE or force a rebuild with PT_ACE_REBUILD=1. +# julia --project=. -t auto scripts/thermal_spinboson_ace.jl +# Place this file in the repository's scripts/ directory. +# PT_THERMAL_CACHE overrides the cache path; PT_ACE_REBUILD=1 forces rebuilding. +# Force rebuilding after changing model code or package versions. -import Pkg +# --- Parameters: physical frequencies in ps^-1, times in ps, ħ = 1 --- +const N_bath = 60 +const local_dim = 5 +const Ω = 3.0 +const ω_min, ω_max = 0.0, 30.0 +const spectral_strength, spectral_cutoff = 0.2, 3.0 +const thermal_frequency = 1.5 # θ = k_B T / ħ; not temperature in kelvin +const hot_thermal_frequency = 6.0 +const dt, final_time = 0.05, 5.0 +const nsteps = round(Int, final_time / dt) + 1 +const ace_cutoff, ace_maxdim = 1e-5, 512 +const ace_compression = :canonzip +const trace_warning_tolerance = 1e-5 +const population_tolerance = 2e-3 +@assert N_bath > 0 && local_dim >= 2 && dt > 0 && final_time > 0 +@assert 0 <= ω_min < ω_max && spectral_strength >= 0 && spectral_cutoff > 0 +@assert all(θ -> isfinite(θ) && θ >= 0, (thermal_frequency, hot_thermal_frequency)) +@assert isapprox((nsteps - 1) * dt, final_time; atol=100eps(Float64)) +# --- Plot environment (same automatic setup as the original script) --- +import Pkg const REPO_ROOT = dirname(@__DIR__) -const _PLOT_ENV = joinpath(@__DIR__, ".plot_examples_env") - -function activate_plot_examples_env!() - mkpath(_PLOT_ENV) - Pkg.activate(_PLOT_ENV) - manifest = joinpath(_PLOT_ENV, "Manifest.toml") - - if !isfile(manifest) - Pkg.develop(Pkg.PackageSpec(path=REPO_ROOT)) - Pkg.add([ - Pkg.PackageSpec(name="CairoMakie"), - Pkg.PackageSpec(name="LaTeXStrings"), - ]) - else - Pkg.resolve() - Pkg.instantiate() - end - return nothing +plot_env = joinpath(@__DIR__, ".plot_examples_env") +Pkg.activate(plot_env) +if !isfile(joinpath(plot_env, "Manifest.toml")) + Pkg.develop(Pkg.PackageSpec(path=REPO_ROOT)) + Pkg.add(["CairoMakie", "LaTeXStrings"]) +else + Pkg.resolve() + Pkg.instantiate() end -activate_plot_examples_env!() - using Logging -using Printf +using LinearAlgebra using Serialization using CairoMakie using ITensors using ITensors.Ops: Trotter -using LaTeXStrings using ProcessTensors - CairoMakie.activate!() -function slim_process_tensor(pt) - return ProcessTensor( - pt.core, - pt.system, - nothing, - pt.dt, - pt.nsteps, - pt.coupling_site, - ) -end - -function save_ace_cache(path, payload) - mkpath(dirname(path)) - open(path, "w") do io - serialize(io, payload) - end - return path -end - -function cache_parameter_mismatch(metadata, params, keys, format) - mismatches = String[] - hasproperty(metadata, :format) || return ["format: missing"] - metadata.format == format || return [ - "format: cache=$(metadata.format) script=$format", - ] - for key in keys - cached = getproperty(metadata, key) - current = getproperty(params, key) - agrees = cached isa Real && current isa Real && !(cached isa Integer && current isa Integer) ? - isapprox(cached, current; atol=0, rtol=1e-12) : - cached == current - agrees || push!(mismatches, "$key: cache=$cached script=$current") - end - return mismatches -end - -function load_or_build_ace_cache( - path, - params, - keys, - format, - builder; - label::AbstractString, -) - force_rebuild = get(ENV, "PT_ACE_REBUILD", "0") == "1" - if force_rebuild - println("PT_ACE_REBUILD=1; constructing $label.") - elseif isfile(path) - payload = try - open(deserialize, path) - catch err - @warn "Could not read the process-tensor cache; rebuilding." exception = ( - err, - catch_backtrace(), - ) - nothing - end - if payload !== nothing - mismatches = cache_parameter_mismatch(payload.metadata, params, keys, format) - if isempty(mismatches) - println("Found a matching ACE cache; skipping process-tensor construction.") - println(" cache file: $path") - if hasproperty(payload.metadata, :maxlinkdim) - @printf( - " maximum PT bond dimension: %d\n", - payload.metadata.maxlinkdim, - ) - end - return payload, 0.0 - end - println("Cached process tensor does not match the script parameters; rebuilding.") - for line in mismatches - println(" $line") - end - end - else - println("No process-tensor cache at $path; building.") - end - - build_seconds = @elapsed begin - payload = builder() - end - save_ace_cache(path, payload) - @printf(" ACE build time: %.3f s\n", build_seconds) - @printf(" wrote cache: %s\n", path) - if hasproperty(payload.metadata, :maxlinkdim) - @printf(" maximum PT bond dimension: %d\n", payload.metadata.maxlinkdim) - end - return payload, build_seconds -end - -# ------------------------------------------------------------------------------ -# 1. Small utilities -# ------------------------------------------------------------------------------ - -const STATUS_WIDTH = 100 - -function print_section(title::AbstractString) - println() - println(title) - println("-"^length(title)) -end - -function update_status(message::AbstractString) - print("\r", rpad(message, STATUS_WIDTH)) - flush(stdout) -end - -function finish_status(message::AbstractString="") - print("\r", " "^STATUS_WIDTH, "\r") - isempty(message) || println(message) - flush(stdout) -end - -ohmic_spectral_density(ω) = 0.2 * ω * exp(-ω / 3) - -ace_T_label(T) = @sprintf("T=%g (ACE)", T) - -function uniform_mode_grid(N_bath, ω_min, ω_max) - Δω = (ω_max - ω_min) / N_bath - frequencies = [ω_min + (k - 0.5) * Δω for k in 1:N_bath] - spacings = fill(Δω, N_bath) - couplings = sqrt.(ohmic_spectral_density.(frequencies) .* spacings) - return frequencies, spacings, couplings -end - -function thermal_spinboson_bath(frequencies, couplings, thermal_frequency) - N_modes = length(frequencies) - bath_sites = siteinds("Boson", N_modes; dim=local_dim) - bath_liouville_sites = liouv_sites(bath_sites) +output_dir = joinpath(@__DIR__, "figures") +figure_path = joinpath(output_dir, "thermal_spinboson_ace.png") +cache_path = get(ENV, "PT_THERMAL_CACHE", joinpath(@__DIR__, ".cache", "thermal_spinboson_ace.jls")) +mkpath(output_dir) +mkpath(dirname(cache_path)) + +# --- Midpoint quadrature: g_k² = J(ω_k) Δω --- +spectral_density(ω) = spectral_strength * ω * exp(-ω / spectral_cutoff) +Δω = (ω_max - ω_min) / N_bath +frequencies = [ω_min + (k - 0.5) * Δω for k in 1:N_bath] +couplings = sqrt.(spectral_density.(frequencies) .* Δω) +@assert all(>(0), frequencies) && all(isfinite, couplings) +temperatures = (thermal_frequency, hot_thermal_frequency) + +# These tails belong to the infinite free-oscillator Gibbs distribution. +# They diagnose initial Fock truncation, not the eventual system-observable error. +for θ in temperatures + tail = θ == 0 ? 0.0 : maximum(exp.(-local_dim .* frequencies ./ θ)) + println((thermal_frequency=θ, lowest_mode=first(frequencies), + largest_omitted_Gibbs_weight=tail)) + tail > 0.01 && @warn "Check local_dim convergence: appreciable initial Gibbs weight is omitted" θ tail +end +println((bath_modes=N_bath, local_dim=local_dim, spacing=Δω, + formal_bath_Hilbert_dimension=BigInt(local_dim)^N_bath, + formal_bath_Liouville_dimension=BigInt(local_dim)^(2N_bath))) + +# --- Three utilities: mode assembly, cache reading, population diagnostics --- +function thermal_bath(frequencies, couplings, θ, local_dim) + sites = siteinds("Boson", length(frequencies); dim=local_dim) + liouville_sites = liouv_sites(sites) modes = BosonicMode[] - - for k in 1:N_modes - update_status( - @sprintf( - " mode %2d / %d omega=%6.3f g=%8.5f", - k, - N_modes, - frequencies[k], - couplings[k], - ), - ) - - ωk = frequencies[k] - gk = couplings[k] - - mode_hamiltonian = OpSum() - mode_hamiltonian += ωk, "N", 1 - - mode_coupling = OpSum() - mode_coupling += gk, "A", 1, "ProjUp", 2 - mode_coupling += gk, "Adag", 1, "ProjUp", 2 - mode_coupling += gk^2 / ωk, "ProjUp", 2 - - initial_mode_density = thermal_boson_density( - bath_sites[k], - bath_liouville_sites[k], - ωk, - thermal_frequency, - local_dim, - ) - - push!( - modes, - bosonic_mode( - [bath_liouville_sites[k]], - mode_hamiltonian, - initial_mode_density; - coupling=mode_coupling, - ), - ) + for k in eachindex(frequencies) + ωk, gk = frequencies[k], couplings[k] + H_mode = OpSum() + H_mode += ωk, "N", 1 + coupling = OpSum() + coupling += gk, "A", 1, "ProjUp", 2 + coupling += gk, "Adag", 1, "ProjUp", 2 + coupling += gk^2 / ωk, "ProjUp", 2 # Counterterm g_k^2 A^2/ω_k; A^2=A. + push!(modes, thermal_mode([liouville_sites[k]], H_mode, θ; coupling=coupling)) end - - finish_status(" prepared $N_modes thermal oscillator modes") - return with_logger(NullLogger()) do - bosonic_bath(modes) + # Suppress only the bath-size warning for joint Dense construction. + return with_logger(() -> bosonic_bath(modes), NullLogger()) +end + +function read_cache(path, params) + get(ENV, "PT_ACE_REBUILD", "0") == "1" && return nothing + isfile(path) || return nothing + try + payload = deserialize(path) + payload.metadata.format == 2 || return nothing + matches = all(k -> hasproperty(payload.metadata, k) && + getproperty(payload.metadata, k) == getproperty(params, k), keys(params)) + matches || return nothing + all(k -> hasproperty(payload, k), (:process_tensor_T1, :process_tensor_T3, :system_sites)) || return nothing + return payload + catch err + err isa InterruptException && rethrow() + @warn "Unreadable cache; rebuilding" path exception=err + return nothing end end -function thermal_boson_density( - physical_site, - liouville_site, - ω, - thermal_frequency, - local_dim, -) - occupations = 0:(local_dim - 1) - weights = exp.(-ω .* occupations ./ thermal_frequency) - weights ./= sum(weights) - - number_states = [ - MPS([physical_site], [string(n)]) - for n in occupations - ] - density = to_dm(number_states; coeffs=weights) - - return to_liouville( - density; - sites=[liouville_site], - ) -end - -function one_site_density_matrix(ρ) - tensor = foldl(*, ρ) - site = only( - filter( - index -> plev(index) == 0 && hastags(index, "Site"), - inds(tensor), - ), - ) - return ComplexF64.(Array(tensor, prime(site), site)) -end - -function excited_population(trajectory, projector) - values = Float64[] - trace_errors = Float64[] - - for ρ in trajectory.states_hilbert - ρ_matrix = one_site_density_matrix(ρ) - trace_value = tr(ρ_matrix) - - push!( - values, - real(tr(projector * ρ_matrix) / trace_value), - ) - push!(trace_errors, abs(trace_value - 1)) +function population_diagnostics(trajectory) + population, trace_errors, hermiticity_errors = Float64[], Float64[], Float64[] + for state in trajectory.states_hilbert + tensor = foldl(*, state) + site = only(filter(i -> plev(i) == 0 && hastags(i, "Site"), inds(tensor))) + ρ = ComplexF64.(Array(tensor, prime(site), site)) + z = tr(ρ) + isfinite(z) && abs(z) > 1e-12 || error("Nonfinite or vanishing trace") + push!(population, real(ρ[1, 1] / z)) # Up=e; retain raw trace diagnostics. + push!(trace_errors, abs(z - 1)) + push!(hermiticity_errors, norm(ρ - ρ') / norm(ρ)) end - - return values, trace_errors + @assert all(p -> isfinite(p) && -population_tolerance <= p <= 1 + population_tolerance, population) + maximum(trace_errors) > trace_warning_tolerance && @warn "Trace drift exceeds tolerance" max_trace_error=maximum(trace_errors) + return (; times=trajectory.times, population, trace_errors, hermiticity_errors) end -# ------------------------------------------------------------------------------ -# 2. Published parameters -# ------------------------------------------------------------------------------ - -const N_bath = 60 -const local_dim = 5 - -const Ω = 3.0 -const ω_min = 0.0 -const ω_max = 30.0 -const thermal_frequency = 1.5 # k_B T / hbar, ps^-1 (published) -const hot_thermal_frequency = 6.0 # k_B T / hbar, ps^-1 - -const dt = 0.05 -const final_time = 5.0 -const nsteps = round(Int, final_time / dt) + 1 - -const ace_cutoff = 1e-5 -const ace_maxdim = 512 - -const trace_warning_tolerance = 1e-5 -const population_tolerance = 2e-3 - -output_dir = joinpath(@__DIR__, "figures") -mkpath(output_dir) -figure_path = joinpath(output_dir, "thermal_spinboson_ace.png") -cache_dir = joinpath(@__DIR__, ".cache") -cache_path = get( - ENV, - "PT_THERMAL_CACHE", - joinpath(cache_dir, "thermal_spinboson_ace.jls"), -) -const THERMAL_CACHE_FORMAT = 1 -const THERMAL_CACHE_KEYS = ( - :N_bath, - :local_dim, - :Ω, - :ω_min, - :ω_max, - :thermal_frequency, - :hot_thermal_frequency, - :dt, - :final_time, - :nsteps, - :ace_cutoff, - :ace_maxdim, -) - -frequencies, spacings, couplings = - uniform_mode_grid(N_bath, ω_min, ω_max) - -@assert all(>(0), frequencies) -@assert all(isfinite, couplings) -@assert isapprox((nsteps - 1) * dt, final_time; atol=100eps(Float64)) - -# ------------------------------------------------------------------------------ -# 3. Physical setup -# ------------------------------------------------------------------------------ - -print_section("Thermal spin-boson ACE benchmark") - -println("Reproducing the driven Ohmic-bath example of Cygorek & Gauger.") -@printf(" drive Omega: %.3f ps^-1\n", Ω) -@printf(" bath modes: %d\n", N_bath) -@printf(" local boson dimension M: %d\n", local_dim) -@printf(" omega window: [%.1f, %.1f] ps^-1\n", ω_min, ω_max) -@printf(" uniform spacing: %.3f ps^-1\n", first(spacings)) -@printf(" k_B T / hbar: %.3f and %.3f ps^-1\n", thermal_frequency, hot_thermal_frequency) -@printf(" dt: %.3f ps\n", dt) -@printf(" final time: %.1f ps\n", final_time) -@printf(" ACE threshold epsilon: %.1e\n", ace_cutoff) -@printf(" ACE maxdim safety cap: %d\n", ace_maxdim) -println(" spectral density: J(w) = 0.2 w exp(-w/3)") -println(" system-bath operator: |e> begin - print_section("Preparing 60-mode thermal baths") - bath_T1 = thermal_spinboson_bath(frequencies, couplings, thermal_frequency) - bath_T3 = thermal_spinboson_bath(frequencies, couplings, hot_thermal_frequency) - - print_section(@sprintf("Building ACE process tensor at T=%g", thermal_frequency)) - println("Live mode-join progress is reported by ProcessTensors.jl.") - process_tensor_T1 = build_process_tensor( - system; - method=ACE(cutoff=ace_cutoff, maxdim=ace_maxdim), - environment=bath_T1, - dt=dt, - nsteps=nsteps, - sys_alg=Trotter{2}(), - combine_alg=Trotter{2}(), - progress=true, - verbose=false, - ) - - print_section(@sprintf("Building ACE process tensor at T=%g", hot_thermal_frequency)) - println("Live mode-join progress is reported by ProcessTensors.jl.") - process_tensor_T3 = build_process_tensor( - system; - method=ACE(cutoff=ace_cutoff, maxdim=ace_maxdim), - environment=bath_T3, - dt=dt, - nsteps=nsteps, - sys_alg=Trotter{2}(), - combine_alg=Trotter{2}(), - progress=true, - verbose=false, - ) - - slim_T1 = slim_process_tensor(process_tensor_T1) - slim_T3 = slim_process_tensor(process_tensor_T3) - return (; - process_tensor_T1=slim_T1, - process_tensor_T3=slim_T3, - system_sites, - metadata=(; - format=THERMAL_CACHE_FORMAT, - N_bath, - local_dim, - Ω, - ω_min, - ω_max, - thermal_frequency, - hot_thermal_frequency, - dt, - final_time, - nsteps, - ace_cutoff, - ace_maxdim, - maxlinkdim=max(maxlinkdim(slim_T1), maxlinkdim(slim_T3)), - maxlinkdim_T1=maxlinkdim(slim_T1), - maxlinkdim_T3=maxlinkdim(slim_T3), - ), +closed_build_time = @elapsed closed_process = build_process_tensor( + system; dt=dt, nsteps=nsteps, sys_alg=Trotter{2}(), progress=false, +) +closed_evolution_time = @elapsed closed_trajectory = evolve(closed_process, initial_density) +closed = population_diagnostics(closed_trajectory) +closed_analytic_error = maximum(abs.(closed.population .- sin.(Ω .* closed.times ./ 2).^2)) +println((isolated_analytic_error=closed_analytic_error, + build_seconds=closed_build_time, evolution_seconds=closed_evolution_time)) + +# --- Build or reload both thermal process tensors --- +params = (; N_bath, local_dim, Ω, ω_min, ω_max, thermal_frequency, hot_thermal_frequency, + dt, final_time, nsteps, ace_cutoff, ace_maxdim, + spectral_strength, spectral_cutoff, ace_compression) +payload = read_cache(cache_path, params) +cache_hit = payload !== nothing +build_times = zeros(length(temperatures)) +if !cache_hit + processes = ProcessTensor[] + for (i, θ) in enumerate(temperatures) + println((building_thermal_frequency=θ,)) + started = time_ns() + bath = thermal_bath(frequencies, couplings, θ, local_dim) + pt = build_process_tensor( + system; environment=bath, dt=dt, nsteps=nsteps, + method=ACE(cutoff=ace_cutoff, maxdim=ace_maxdim, compression=ace_compression), + sys_alg=Trotter{2}(), combine_alg=Trotter{2}(), progress=true, verbose=false, ) - end; - label="thermal spin-boson ACE process tensors", -) - -process_tensor_T1 = payload.process_tensor_T1 -process_tensor_T3 = payload.process_tensor_T3 -open_system_sites = payload.system_sites -initial_density = to_dm(MPS(open_system_sites, ["Dn"])) -excited_projector = ComplexF64.( - Array( - op("ProjUp", open_system_sites[1]), - prime(open_system_sites[1]), - open_system_sites[1], - ), -) -max_pt_bond_T1 = hasproperty(payload.metadata, :maxlinkdim_T1) ? - payload.metadata.maxlinkdim_T1 : maxlinkdim(process_tensor_T1) -max_pt_bond_T3 = hasproperty(payload.metadata, :maxlinkdim_T3) ? - payload.metadata.maxlinkdim_T3 : maxlinkdim(process_tensor_T3) - -println() -println(@sprintf("ACE process tensors ready at T=%g and T=%g.", thermal_frequency, hot_thermal_frequency)) -@printf(" maximum retained PT bond, T=%g: %d\n", thermal_frequency, max_pt_bond_T1) -@printf(" maximum retained PT bond, T=%g: %d\n", hot_thermal_frequency, max_pt_bond_T3) - -if max(max_pt_bond_T1, max_pt_bond_T3) >= ace_maxdim - @warn( - "ACE reached the maxdim safety cap.", - max_pt_bond_T1=max_pt_bond_T1, - max_pt_bond_T3=max_pt_bond_T3, - ace_maxdim=ace_maxdim, - ) -end - -# ------------------------------------------------------------------------------ -# 7. Reduced-system dynamics -# ------------------------------------------------------------------------------ - -print_section("Evaluating open-system trajectories") - -update_status(@sprintf(" evolving ACE process tensor at T=%g", thermal_frequency)) -open_evolution_time_T1 = @elapsed begin - open_trajectory_T1 = evolve(process_tensor_T1, initial_density) -end -finish_status( - @sprintf( - " T=%g trajectory evolved in %.3f s", - thermal_frequency, - open_evolution_time_T1, - ), -) - -update_status(@sprintf(" evolving ACE process tensor at T=%g", hot_thermal_frequency)) -open_evolution_time_T3 = @elapsed begin - open_trajectory_T3 = evolve(process_tensor_T3, initial_density) -end -finish_status( - @sprintf( - " T=%g trajectory evolved in %.3f s", - hot_thermal_frequency, - open_evolution_time_T3, - ), -) - -open_population_T1, open_trace_errors_T1 = - excited_population(open_trajectory_T1, excited_projector) -open_population_T3, open_trace_errors_T3 = - excited_population(open_trajectory_T3, excited_projector) - -max_trace_error = maximum(( - maximum(closed_trace_errors), - maximum(open_trace_errors_T1), - maximum(open_trace_errors_T3), -)) - -@assert all(isfinite, open_population_T1) -@assert all(isfinite, open_population_T3) -@assert all(isfinite, closed_population) -@assert all( - value -> -population_tolerance <= value <= 1 + population_tolerance, - open_population_T1, -) -@assert all( - value -> -population_tolerance <= value <= 1 + population_tolerance, - open_population_T3, -) - -if max_trace_error > trace_warning_tolerance - @warn( - "Trace drift exceeds the preferred script tolerance.", - max_trace_error=max_trace_error, - trace_warning_tolerance=trace_warning_tolerance, - ) -end - -println("Trajectory diagnostics") -@printf(" max trace error: %.3e\n", max_trace_error) -@printf(" final P_e, isolated: %.6f\n", closed_population[end]) -@printf(" final P_e, %s: %.6f\n", ace_T_label(thermal_frequency), open_population_T1[end]) -@printf(" final P_e, %s: %.6f\n", ace_T_label(hot_thermal_frequency), open_population_T3[end]) -@printf(" closed evolution time: %.3f s\n", closed_evolution_time) -@printf(" %s evolution time: %.3f s\n", ace_T_label(thermal_frequency), open_evolution_time_T1) -@printf(" %s evolution time: %.3f s\n", ace_T_label(hot_thermal_frequency), open_evolution_time_T3) - -# ------------------------------------------------------------------------------ -# 8. Figure -# ------------------------------------------------------------------------------ - -print_section("Plotting") - -isolated_color = :gray35 -T1_color = :steelblue -T3_color = :darkorange -ace_fill = :lightskyblue - + build_times[i] = (time_ns() - started) / 1e9 + # Keep the temporal cores and system; omit the original bath from disk. + push!(processes, ProcessTensor(pt.core, pt.system, nothing, pt.dt, pt.nsteps, pt.coupling_site)) + println((thermal_frequency=θ, build_seconds=build_times[i], max_pt_bond=maxlinkdim(pt))) + end + # Legacy field names are retained for compatibility; T3 means the hotter case. + payload = (; process_tensor_T1=processes[1], process_tensor_T3=processes[2], system_sites, + metadata=(; format=2, params..., + maxlinkdim=maximum(maxlinkdim.(processes)), + maxlinkdim_T1=maxlinkdim(processes[1]), maxlinkdim_T3=maxlinkdim(processes[2]))) + serialize(cache_path * ".tmp", payload) + mv(cache_path * ".tmp", cache_path; force=true) +end + +# --- Evaluate both baths on their cached system indices --- +processes = (payload.process_tensor_T1, payload.process_tensor_T3) +initial_density = to_dm(MPS(payload.system_sites, ["Dn"])) +results = NamedTuple[] +for (θ, pt) in zip(temperatures, processes) + evolution_time = @elapsed trajectory = evolve(pt, initial_density) + data = population_diagnostics(trajectory) + max_bond = maxlinkdim(pt) + max_bond >= ace_maxdim && @warn "ACE reached the bond cap; check convergence" θ max_bond + push!(results, (; data..., thermal_frequency=θ, max_pt_bond=max_bond, evolution_time)) + println((thermal_frequency=θ, cache_hit=cache_hit, final_population=last(data.population), + max_trace_error=maximum(data.trace_errors), + max_hermiticity_error=maximum(data.hermiticity_errors), + max_pt_bond=max_bond, evolution_seconds=evolution_time)) +end + +# --- Figure: the shared spectral density and the three population curves --- figure = Figure(size=(1100, 850), fontsize=18) - -spectral_axis = Axis( - figure[1, 1]; - xlabel=L"\omega\;(\mathrm{ps}^{-1})", - ylabel=L"J(\omega)", - title="Ohmic environment and its 60-mode discretization", -) - +spectral_axis = Axis(figure[1, 1]; xlabel="ω (ps⁻¹)", ylabel="J(ω) (ps⁻¹)", + title="Ohmic environment and its $N_bath-mode discretisation") ω_plot = range(ω_min, ω_max; length=800) -lines!( - spectral_axis, - ω_plot, - ohmic_spectral_density.(ω_plot); - color=T1_color, - linewidth=2.6, - label=L"J(\omega)=0.2\,\omega e^{-\omega/3}", -) -scatter!( - spectral_axis, - frequencies, - ohmic_spectral_density.(frequencies); - marker=:circle, - markersize=12, - color=ace_fill, - strokecolor=:black, - strokewidth=2.4, - label="ACE modes", -) +lines!(spectral_axis, ω_plot, spectral_density.(ω_plot); color=:steelblue, + linewidth=2.6, label="J(ω) = $(spectral_strength) ω exp(−ω/$(spectral_cutoff))") +scatter!(spectral_axis, frequencies, spectral_density.(frequencies); marker=:circle, + markersize=12, color=:lightskyblue, strokecolor=:black, strokewidth=2.4, label="ACE modes") axislegend(spectral_axis; position=:rt) -population_axis = Axis( - figure[2, 1]; - xlabel=L"t\;(\mathrm{ps})", - ylabel=L"P_e(t)", - title="Thermal spin-boson damping (kᵦT/ℏ in ps⁻¹)", -) - -lines!( - population_axis, - closed_trajectory.times, - closed_population; - color=isolated_color, - linewidth=2.5, - linestyle=:dash, - label="isolated TLS", -) -lines!( - population_axis, - open_trajectory_T1.times, - open_population_T1; - color=T1_color, - linewidth=2.6, - label=ace_T_label(thermal_frequency), -) -lines!( - population_axis, - open_trajectory_T3.times, - open_population_T3; - color=T3_color, - linewidth=2.6, - label=ace_T_label(hot_thermal_frequency), -) - +population_axis = Axis(figure[2, 1]; xlabel="t (ps)", ylabel="Pe(t)", + title="Thermal damping of Rabi oscillations (M=$local_dim levels)") +lines!(population_axis, closed.times, closed.population; color=:gray35, + linewidth=2.5, linestyle=:dash, label="isolated TLS") +for (r, color) in zip(results, (:steelblue, :darkorange)) + lines!(population_axis, r.times, r.population; color=color, linewidth=2.6, + label="kᵦT/ħ = $(r.thermal_frequency) ps⁻¹ (ACE)") +end ylims!(population_axis, -0.03, 1.03) axislegend(population_axis; position=:rt) - rowgap!(figure.layout, 18) save(figure_path, figure) - -println("Saved figure:") -println(" $figure_path") - -# ------------------------------------------------------------------------------ -# 9. Final summary -# ------------------------------------------------------------------------------ - -print_section("Summary") - -println("Completed thermal spin-boson ACE example.") -println(" figure: $figure_path") -@printf(" bath modes: %d\n", N_bath) -@printf(" %s χmax: %d\n", ace_T_label(thermal_frequency), max_pt_bond_T1) -@printf(" %s χmax: %d\n", ace_T_label(hot_thermal_frequency), max_pt_bond_T3) -@printf(" maximum trace error: %.3e\n", max_trace_error) -@printf(" closed analytic error: %.3e\n", closed_analytic_error) -@printf(" ACE construction time: %.3f s\n", ace_build_elapsed) -println(" formal bath Liouville dim: $formal_liouville_dimension") +println("Saved figure: $figure_path") +println((ace_build_seconds=sum(build_times), + max_trace_error=max(maximum(closed.trace_errors), + maximum(maximum(r.trace_errors) for r in results)), + closed_analytic_error=closed_analytic_error)) + +# Loss of contrast and Pe≈1/2 alone do not establish a Gibbs or maximally mixed state. +# Converge local_dim (especially at high T), mode grid/window, dt, and ACE truncation. diff --git a/src/ProcessTensors.jl b/src/ProcessTensors.jl index e0ee7a7..4ebed64 100644 --- a/src/ProcessTensors.jl +++ b/src/ProcessTensors.jl @@ -56,7 +56,7 @@ using .Environments: BosonicMode, SpinMode using .Environments: BosonicBath, SpinBath using .Environments: bosonic_mode, spin_mode using .Environments: bosonic_bath, spin_bath -using .Environments: mode_initial_states +using .Environments: mode_initial_states, thermal_mode # Instruments @@ -144,7 +144,7 @@ export BosonicBath, SpinBath export bosonic_mode, spin_mode export bosonic_bath, spin_bath export AbstractSpectralDensity -export mode_initial_states +export mode_initial_states, thermal_mode # Instruments export AbstractInstrument, SingleLegInstrument, TwoLegInstrument diff --git a/src/environments/environments.jl b/src/environments/environments.jl index bd7473a..fb337bd 100644 --- a/src/environments/environments.jl +++ b/src/environments/environments.jl @@ -10,15 +10,17 @@ module Environments import ..ProcessTensors -using ..ProcessTensors: AbstractMPS, MPS, Hilbert, Liouville, OpSum, Index, siteinds, has_tag_token +using ..ProcessTensors: AbstractMPS, MPS, MPO, Hilbert, Liouville, OpSum, Index, siteinds, + has_tag_token, tag_tokens, to_liouville, _phys_site_from_liouv using ..Spectrals: AbstractSpectralDensity, ohmic_sd using ITensors using ITensors: dim, terms +using LinearAlgebra: Hermitian, I, eigen, exp, tr import Base: show export AbstractBathMode, AbstractBath, BosonicMode, SpinMode, BosonicBath, SpinBath, bosonic_mode, spin_mode, bosonic_bath, spin_bath, - mode_initial_states + mode_initial_states, thermal_mode """ AbstractBathMode @@ -354,6 +356,119 @@ Return the initial state of each bath mode in `bath`. """ mode_initial_states(bath::AbstractBath) = getfield.(bath.modes, :rho0) +""" + thermal_mode(mode::AbstractBathMode, T::Real) + thermal_mode(sites, H::OpSum, T::Real; coupling=OpSum(), n_max=dim(only(sites))-1) + +Return a bath mode whose initial state is thermal at temperature `T`. + +`T` is in the same energy units as the coefficients in the mode Hamiltonian +(`ħ = k_B = 1`). Finite `T > 0` gives the Gibbs state ``e^{-H/T}/Z``. `T = 0` +gives the ground state of `H`, or the projector onto a degenerate ground +space. `T = Inf` gives the maximally mixed state ``I/d``. Negative or `NaN` +temperatures throw `DomainError`. + +The replacement methods keep `H`, `sites`, `coupling`, and `n_max` and only +replace `rho0`. The constructor infers [`BosonicMode`](@ref) or +[`SpinMode`](@ref) from Liouville site-family tags and forwards to +[`bosonic_mode`](@ref) or [`spin_mode`](@ref). + +# Examples +```julia +mode = thermal_mode(liouv_sites, H, 1.0; coupling=coupling) +cold = thermal_mode(mode, 0.0) +``` +""" +function thermal_mode(mode::BosonicMode, T::Real) + rho0 = _thermal_liouville_state(mode.sites, mode.H, T) + return BosonicMode(mode.sites, mode.H, mode.n_max, rho0; coupling=mode.coupling) +end + +function thermal_mode(mode::SpinMode, T::Real) + rho0 = _thermal_liouville_state(mode.sites, mode.H, T) + return SpinMode(mode.sites, mode.H, rho0; coupling=mode.coupling) +end + +thermal_mode(mode::AbstractBathMode, T::Real) = throw( + ArgumentError( + "thermal_mode: unsupported bath mode type $(typeof(mode)). " * + "Expected BosonicMode or SpinMode.", + ), +) + +function thermal_mode( + sites::AbstractVector{<:Index}, + H::OpSum, + T::Real; + coupling::OpSum=OpSum(), + n_max::Int=dim(only(sites)) - 1, +) + length(sites) == 1 || throw( + ArgumentError( + "thermal_mode: a bath mode must have exactly one site index. Got $(length(sites)).", + ), + ) + rho0 = _thermal_liouville_state(sites, H, T) + site = only(sites) + tokens = tag_tokens(site) + if any(t -> occursin("Boson", t), tokens) + return bosonic_mode(sites, H, rho0; n_max=n_max, coupling=coupling) + elseif any(t -> occursin("S=", t), tokens) + return spin_mode(sites, H, rho0; coupling=coupling) + end + throw( + ArgumentError( + "thermal_mode: sites must be bosonic or spin Liouville indices. Got $(tag_tokens.(sites)).", + ), + ) +end + +# Local Gibbs / ground / infinite-T density on one Liouville bath site. +function _thermal_liouville_state(sites::AbstractVector{<:Index}, H::OpSum, T::Real) + isnan(T) && throw(DomainError(T, "thermal_mode: T must be 0, positive, or Inf.")) + T < 0 && throw(DomainError(T, "thermal_mode: T must be 0, positive, or Inf.")) + length(sites) == 1 || throw( + ArgumentError( + "thermal_mode: a bath mode must have exactly one site index. Got $(length(sites)).", + ), + ) + site = only(sites) + has_tag_token(site, "Liouv") || throw( + ArgumentError( + "thermal_mode: sites must be Liouville indices. Got $(tag_tokens(site)).", + ), + ) + + phys = _phys_site_from_liouv(site) + d = dim(phys) + # Empty OpSum MPOs are identity in ITensors, but an empty Hamiltonian is H = 0. + H_mat = if isempty(terms(H)) + zeros(ComplexF64, d, d) + else + H_tensor = foldl(*, MPO(H, [phys])) + ComplexF64.(Array(H_tensor, prime(phys), phys)) + end + H_h = Hermitian((H_mat + H_mat') / 2) + + ρ_mat = if isinf(T) + Matrix{ComplexF64}(I, d, d) / d + elseif T == 0 + vals, vecs = eigen(H_h) + E0 = real(vals[1]) + tol = 1e-10 * max(one(E0), abs(E0)) + n_gs = count(e -> real(e) <= E0 + tol, vals) + P = vecs[:, 1:n_gs] + ρ = P * P' + ρ ./ tr(ρ) + else + ρ = Matrix{ComplexF64}(exp(Hermitian(-Matrix(H_h) / T))) + ρ ./ tr(ρ) + end + + ρ_it = ITensor(Matrix{ComplexF64}(ρ_mat), prime(phys), phys) + return to_liouville(MPO(ρ_it, [phys]); sites=Index[sites...]) +end + function Base.show(io::IO, mode::BosonicMode) println(io, "ProcessTensors.BosonicMode") space = any(!has_tag_token(s, "Liouv") for s in mode.sites) ? "Hilbert" : "Liouville" diff --git a/src/instruments/testers.jl b/src/instruments/testers.jl index 5336e0f..70463d5 100644 --- a/src/instruments/testers.jl +++ b/src/instruments/testers.jl @@ -486,6 +486,11 @@ function add!(seq::TesterSeq, action::AbstractTesterAction, tstep::Int) return seq end +function Base.:+(seq::TesterSeq, entry::Tuple{AbstractTesterAction,Int}) + add!(seq, entry[1], entry[2]) + return seq +end + add!(::InstrumentSeq, ::Tester, ::Int) = throw( ArgumentError("add!: Tester objects belong in tester workflows, not InstrumentSeq."), ) diff --git a/test/environments/test_environments.jl b/test/environments/test_environments.jl index d290afc..fd1acee 100644 --- a/test/environments/test_environments.jl +++ b/test/environments/test_environments.jl @@ -12,6 +12,7 @@ using ProcessTensors using ProcessTensors.Spectrals: ohmic_sd using ITensors +using LinearAlgebra: I, Diagonal using Test @testset "API surface: bath names and fields" begin @@ -26,6 +27,7 @@ using Test end @test nameof(BosonicMode) == :BosonicMode @test :mode_initial_states ∈ names(ProcessTensors) + @test :thermal_mode ∈ names(ProcessTensors) b_sites = liouv_sites(siteinds("Boson", 1; dim=4)) s_sites = liouv_sites(siteinds("S=1/2", 1)) @@ -38,8 +40,13 @@ using Test mode_b = bosonic_mode(b_sites, H_b, rho_b; coupling=coupling_b) mode_s = spin_mode(s_sites, H_s, rho_s; coupling=coupling_s) + thermal_b = thermal_mode(b_sites, H_b, 1.0; coupling=coupling_b) + thermal_s = thermal_mode(s_sites, H_s, 1.0; coupling=coupling_s) @test nameof(typeof(mode_b)) == :BosonicMode @test nameof(typeof(mode_s)) == :SpinMode + @test nameof(typeof(thermal_b)) == :BosonicMode + @test nameof(typeof(thermal_s)) == :SpinMode + @test nameof(typeof(thermal_mode(mode_b, 1.0))) == :BosonicMode @test mode_b.sites == b_sites @test mode_b.H == H_b @test mode_b.coupling == coupling_b @@ -282,3 +289,69 @@ end @test occursin("[2] $mode_name", out) end end + +@testset "environments.jl: thermal_mode Gibbs and limits" begin + function dense_mode_density(mode) + ρh = to_hilbert(mode.rho0) + tensor = foldl(*, ρh) + phys = only(ProcessTensors._phys_sites_from_hilbert_mpo(ρh)) + return ComplexF64.(Array(tensor, prime(phys), phys)) + end + + ω = 0.7 + T = 1.3 + n_phys = 4 + b_sites = liouv_sites(siteinds("Boson", 1; dim=n_phys)) + s_sites = liouv_sites(siteinds("S=1/2", 1)) + H_b = OpSum() + (ω, "N", 1) + H_s = OpSum() + (ω, "Sz", 1) + coupling_b = OpSum() + (0.1, "N", 1, "Sz", 2) + coupling_s = OpSum() + (0.2, "Sz", 1, "Sz", 2) + + bm = thermal_mode(b_sites, H_b, T; coupling=coupling_b) + sm = thermal_mode(s_sites, H_s, T; coupling=coupling_s) + @test bm isa BosonicMode + @test sm isa SpinMode + @test bm.H == H_b + @test bm.coupling == coupling_b + @test bm.sites == b_sites + @test bm.n_max == dim(only(b_sites)) - 1 + @test sm.H == H_s + @test sm.coupling == coupling_s + @test sm.sites == s_sites + + occupations = 0:(n_phys - 1) + boson_weights = exp.(-ω .* occupations ./ T) + boson_weights ./= sum(boson_weights) + ρb = dense_mode_density(bm) + @test ρb ≈ Diagonal(boson_weights) atol=1e-10 + + spin_energies = [ω / 2, -ω / 2] + spin_weights = exp.(-spin_energies ./ T) + spin_weights ./= sum(spin_weights) + ρs = dense_mode_density(sm) + @test ρs ≈ Diagonal(spin_weights) atol=1e-10 + + seed = bosonic_mode(b_sites, H_b, random_mps(b_sites); coupling=coupling_b) + replaced = thermal_mode(seed, T) + @test replaced.H == seed.H + @test replaced.coupling == seed.coupling + @test replaced.sites == seed.sites + @test replaced.n_max == seed.n_max + @test dense_mode_density(replaced) ≈ ρb atol=1e-10 + + ρ0 = dense_mode_density(thermal_mode(b_sites, H_b, 0.0; coupling=coupling_b)) + vacuum = zeros(ComplexF64, n_phys, n_phys) + vacuum[1, 1] = 1 + @test ρ0 ≈ vacuum atol=1e-10 + + ρ∞ = dense_mode_density(thermal_mode(b_sites, H_b, Inf; coupling=coupling_b)) + @test ρ∞ ≈ Matrix{ComplexF64}(I, n_phys, n_phys) / n_phys atol=1e-12 + + mode_empty = @test_logs (:warn, r"BosonicMode:H is empty") thermal_mode(b_sites, OpSum(), 0.0) + ρ_empty = dense_mode_density(mode_empty) + @test ρ_empty ≈ Matrix{ComplexF64}(I, n_phys, n_phys) / n_phys atol=1e-12 + + @test_throws DomainError thermal_mode(b_sites, H_b, -1.0) + @test_throws DomainError thermal_mode(bm, NaN) +end diff --git a/test/systems/test_testers.jl b/test/systems/test_testers.jl index 0957b02..a55b2c8 100644 --- a/test/systems/test_testers.jl +++ b/test/systems/test_testers.jl @@ -175,6 +175,14 @@ end @test resolve_tester_action(seq, 1) === action_z @test length(seq.entries) == 2 + seq_plus = TesterSeq(default, 4) + @test (seq_plus + (action_x, 1)) === seq_plus + seq_plus += (action_u, 2) + @test resolve_tester_action(seq_plus, 1) === action_x + @test resolve_tester_action(seq_plus, 2) === action_u + @test resolve_tester_action(seq_plus, 0) === default + @test_throws ArgumentError seq_plus + (action_x, 5) + keyword_seq = TesterSeq(nsteps=2, entries=Dict(0 => action_x)) @test resolve_tester_action(keyword_seq, 0) === action_x @test resolve_tester_action(keyword_seq, 1) isa TesterIdentity