Configuration

Phasic exposes a single configure() entry point for tuning the library’s behaviour: which compute backend to use, how many CPU threads OpenMP should spawn, when to engage high-precision arithmetic, whether to parallelise Gaussian elimination across strongly-connected components, where to put the on-disk cache, and so on. Every public field describes a user-visible outcome — never which third-party library or internal mechanism realises it.

configure() can be called persistently (the settings stay applied until further changes) or used as a context manager (with configure(...): ...) for a single block of code. Each field is also reachable via a PHASIC_* / OMP_NUM_THREADS environment variable, so SLURM job scripts, CI pipelines, and shell aliases can set the same knobs without touching Python. The two interfaces are kept consistent: configure(...) writes to the env var, and get_config() seeds itself from the env var on first call. If a shell-set env var and an explicit configure() argument disagree, the library raises PTDConfigError rather than silently choosing one — every conflict surfaces loudly.

from phasic import (
    configure, get_config, get_available_options, reset_config,
    PhasicConfig, PTDConfigError,
)
import os
import sys
from vscodenb import set_vscode_theme
set_vscode_theme()

The defaults

configure() with no arguments is a no-op; the defaults are already in effect. Inspect them via get_config():

cfg = get_config()
print(cfg)
PhasicConfig:
{
  "fields": {
    "compute": "auto",
    "cpu_threads": 8,
    "parallel_elimination": false,
    "parallel_elimination_min_subgraph": null,
    "parallel_elimination_max_concurrent": null,
    "svgd_strategy": "auto",
    "high_precision_mode": "auto",
    "high_precision_bits": null,
    "ill_condition_threshold": 1000000000000.0,
    "warn_on_ill_conditioning": true,
    "reward_compute_cache": false,
    "graph_cache": true,
    "cache_dir": null,
    "strict": true,
    "verbose": false
  },
  "environment": {
    "OMP_NUM_THREADS": "8",
    "PHASIC_HIERAR_ELIMINATION": null,
    "PHASIC_MIN_SCC_SIZE_TO_CACHE": null,
    "PHASIC_MAX_PARALLEL_SCCS": null,
    "PHASIC_FORCE_MPFR": null,
    "PHASIC_MPFR_BITS": null,
    "PHASIC_CONDITION_THRESHOLD": null,
    "PHASIC_DISABLE_CONDITION_WARNINGS": null,
    "PHASIC_REWARD_COMPUTE_CACHE": null,
    "PHASIC_DISABLE_GRAPH_CACHE": null,
    "PHASIC_CACHE_DIR": null
  },
  "derived": {
    "jax_active": false,
    "mpfr_active": false,
    "cpu_threads_source": "configure()",
    "cache_dir_resolved": "/Users/kmt/.phasic_cache",
    "compute_resolved": "jax-cpu"
  }
}

Every default is chosen so the library “just works” out of the box on a single laptop:

  • compute='auto' — JAX path if installable, else pure C++.
  • cpu_threads=None — phasic auto-detects on import from SLURM_CPUS_PER_TASK, SLURM_CPUS_ON_NODE, os.sched_getaffinity(0), then os.cpu_count().
  • parallel_elimination=False — the monolithic Gaussian elimination is fine for small to medium graphs.
  • high_precision_mode='auto' — MPFR engages automatically when the condition number exceeds ill_condition_threshold (default 1e12).
  • cache_enabled=True, cache_dir=None — caches live under ~/.phasic_cache/.
  • strict=True — incompatible requests raise instead of warning.
  • verbose=False — only warnings and errors are logged.

Available options

The 13 public fields are organised by user intent:

Compute backend

Field Type Default Effect
compute 'auto', 'cpu', 'jax-cpu', 'jax-gpu' 'auto' Single knob for the compute backend. 'auto' picks JAX if importable, else falls back to pure C++. 'cpu' disables JAX/JIT/FFI entirely. 'jax-gpu' raises if no GPU device is visible.

Parallel execution

Field Type Default Effect Env var
cpu_threads int or None None (auto) OpenMP thread count. Backs OMP_NUM_THREADS. OMP_NUM_THREADS
parallel_elimination bool False Opt into the hierarchical SCC composer: graph is decomposed into strongly-connected components, sibling SCCs at each level eliminated concurrently under OpenMP. PHASIC_HIERAR_ELIMINATION
parallel_elimination_min_subgraph int or None None (4) SCCs whose synthetic graph has fewer vertices skip the cache; below this size the elimination is too cheap to be worth a disk write. PHASIC_MIN_SCC_SIZE_TO_CACHE
parallel_elimination_max_concurrent int or None None (no cap) Cap on simultaneous per-SCC computes within one level. Lets you keep OpenMP threads for nested work while limiting SCC-level fan-out. PHASIC_MAX_PARALLEL_SCCS

Numerical precision

Field Type Default Effect Env var
high_precision_mode 'auto', 'always', 'never' 'auto' When to engage MPFR arithmetic. 'auto': when the condition number exceeds ill_condition_threshold. 'always': every moment computation. 'never': never. PHASIC_FORCE_MPFR
high_precision_bits int or None None (auto from condition) MPFR precision in bits. Must be ≥ 53 if set. PHASIC_MPFR_BITS
ill_condition_threshold float 1e12 Condition number above which 'auto' mode engages MPFR. PHASIC_CONDITION_THRESHOLD
warn_on_ill_conditioning bool True Emit log warnings when condition number is high. PHASIC_DISABLE_CONDITION_WARNINGS (inverted)

Caching

Field Type Default Effect Env var
cache_enabled bool True Use the on-disk caches under cache_dir. PHASIC_DISABLE_CACHE (inverted)
cache_dir str or None None ($HOME/.phasic_cache) Cache root. On SLURM clusters, point this at a shared filesystem so workers share cache entries. PHASIC_CACHE_DIR

Runtime behaviour

Field Type Default Effect
strict bool True Raise on missing-feature warnings (e.g. JAX requested but not installed). Does NOT affect conflict checking — conflicts always raise.
verbose bool False Print configuration details on validate().

Setting options

Call configure() with any subset of the fields above. All updates are validated together and only take effect if no conflicts are detected. Unrecognised kwargs raise PTDConfigError with the full list of valid names.

# Force MPFR for every moment computation, at 256-bit precision.
configure(high_precision_mode='always', high_precision_bits=256)

# Reset to defaults.
reset_config()
# Enable parallel SCC elimination on a multi-core machine
# and put the cache on a shared filesystem.
import tempfile
shared_cache = tempfile.mkdtemp(prefix='phasic_cache_')
configure(
    parallel_elimination=True,
    parallel_elimination_max_concurrent=4,
    cache_dir=shared_cache,
)

# Re-read to confirm the env vars were written.
import os
print('PHASIC_HIERAR_ELIMINATION =', os.environ.get('PHASIC_HIERAR_ELIMINATION'))
print('PHASIC_MAX_PARALLEL_SCCS   =', os.environ.get('PHASIC_MAX_PARALLEL_SCCS'))
print('PHASIC_CACHE_DIR           =', os.environ.get('PHASIC_CACHE_DIR'))

reset_config()
import shutil; shutil.rmtree(shared_cache)
# Restore env vars to a clean state for the next cells.
for v in ('PHASIC_HIERAR_ELIMINATION', 'PHASIC_MAX_PARALLEL_SCCS', 'PHASIC_CACHE_DIR'):
    os.environ.pop(v, None)
PHASIC_HIERAR_ELIMINATION = 1
PHASIC_MAX_PARALLEL_SCCS   = 4
PHASIC_CACHE_DIR           = /var/folders/s6/srs8qkh52w1_h32d65z95tth0000gn/T/phasic_cache_z4oeior5
Tip

When cpu_threads is left at None, phasic does NOT write OMP_NUM_THREADS itself — it only sets it once at import time, when the var is unset, to a sensible auto-detected value. After that, the env var is owned by whoever set it last. Setting cpu_threads=N via configure(...) writes the env var to N and marks it as “phasic-assigned” so future configure() calls can update it without raising.

Temporary settings with context manager

configure(...) returns a context-manager-shaped object. Using it with a with block applies the settings on entry and rolls them back at the end of the block — useful when you want to switch behavior for a single computation without leaking the change to the rest of the script.

The rollback is comprehensive: every field touched by the configure() call, and every phasic-tracked environment variable, is restored to whatever value it held just before the with statement. If the block raises, the rollback still happens. Conflict checking fires at the with statement, not inside the body. If the settings disagree with the shell environment, configure() raises before the block runs and no rollback is needed.

# Default: parallel_elimination is False.
print('outside:', get_config().parallel_elimination)

with configure(parallel_elimination=True, parallel_elimination_max_concurrent=4):
    # Inside the block, the field is set and the env var is written.
    print('inside :', get_config().parallel_elimination,
          'env =', os.environ.get('PHASIC_HIERAR_ELIMINATION'))

# Outside the block, everything is rolled back.
print('outside:', get_config().parallel_elimination,
      'env =', os.environ.get('PHASIC_HIERAR_ELIMINATION'))
outside: False
inside : True env = 1
outside: False env = None

You can also bind the live PhasicConfig to inspect or read from inside the block:

with configure(high_precision_mode='always', high_precision_bits=256) as cfg:
    print(f"inside the block: mode={cfg.high_precision_mode!r}, "
          f"bits={cfg.high_precision_bits}")
inside the block: mode='always', bits=256
with configure(parallel_elimination=True):
    with configure(cpu_threads=4):
        cfg = get_config()
        print('inner:', cfg.parallel_elimination, cfg.cpu_threads)
    cfg = get_config()
    print('outer:', cfg.parallel_elimination, cfg.cpu_threads)
cfg = get_config()
print('after:', cfg.parallel_elimination, cfg.cpu_threads)
inner: True 4
outer: True None
after: False None
Tip

The SVGD-particle parallelism strategy is part of PhasicConfig (the svgd_strategy field), so with configure(svgd_strategy='none'): rolls it back just like any other setting. See the Distributed computing tutorial for SVGD-specific examples.

Inspecting the effective configuration

get_config().effective() returns a dict with three sections: the current public field values, the corresponding environment variables, and a derived section that answers practical questions (“is JAX active right now?”, “where did cpu_threads come from?”, “what does compute='auto' resolve to on this system?”). repr(cfg) pretty-prints the same information.

configure(parallel_elimination=True, cpu_threads=4)

eff = get_config().effective()

print('Fields:')
for k, v in eff['fields'].items():
    print(f'  {k:38s} = {v}')

print()
print('Environment:')
for k, v in eff['environment'].items():
    print(f'  {k:38s} = {v}')

print()
print('Derived:')
for k, v in eff['derived'].items():
    print(f'  {k:38s} = {v}')

reset_config()
Fields:
  compute                                = auto
  cpu_threads                            = 4
  parallel_elimination                   = True
  parallel_elimination_min_subgraph      = None
  parallel_elimination_max_concurrent    = None
  svgd_strategy                          = auto
  high_precision_mode                    = auto
  high_precision_bits                    = None
  ill_condition_threshold                = 1000000000000.0
  warn_on_ill_conditioning               = True
  reward_compute_cache                   = False
  graph_cache                            = True
  cache_dir                              = None
  strict                                 = True
  verbose                                = False

Environment:
  OMP_NUM_THREADS                        = 4
  PHASIC_HIERAR_ELIMINATION              = 1
  PHASIC_MIN_SCC_SIZE_TO_CACHE           = None
  PHASIC_MAX_PARALLEL_SCCS               = None
  PHASIC_FORCE_MPFR                      = None
  PHASIC_MPFR_BITS                       = None
  PHASIC_CONDITION_THRESHOLD             = None
  PHASIC_DISABLE_CONDITION_WARNINGS      = None
  PHASIC_REWARD_COMPUTE_CACHE            = None
  PHASIC_DISABLE_GRAPH_CACHE             = None
  PHASIC_CACHE_DIR                       = None

Derived:
  jax_active                             = False
  mpfr_active                            = False
  cpu_threads_source                     = configure()
  cache_dir_resolved                     = /Users/kmt/.phasic_cache
  compute_resolved                       = jax-cpu

Profiling SCC decomposability

Before turning parallel_elimination=True on, it’s useful to know whether a graph actually decomposes into many small SCCs (where parallel elimination helps) or into a few large ones (where it doesn’t). Graph.plot_scc_decomp() draws a level-wise treemap that answers this at a glance:

  • Each row is a level of the condensation. The source side of the chain (the start vertex’s SCC) is drawn at the top; the sink side (absorbing state) is at the bottom. Time flows downward.
  • Each tile within a row is one SCC; its width is proportional to the SCC’s vertex count, on an absolute scale shared across all rows. A narrow row genuinely has fewer total vertices than a wide one.
  • The label on the left of each row, e.g. L5 (23), tells you how many parallel-eliminable SCCs that level contains.

Wide rows mean good parallelism; rows with a single small tile are elimination bottlenecks that the SCC composer can’t speed up.

from phasic import Graph, with_ipv

# A coalescent with 6 lineages — enough to show several levels.
@with_ipv([6] + [0] * 5)
def coalescent(state):
    transitions = []
    for i in range(state.size):
        for j in range(i, state.size):
            same = int(i == j)
            if same and state[i] < 2: continue
            if not same and (state[i] < 1 or state[j] < 1): continue
            new = state.copy()
            new[i] -= 1
            new[j] -= 1
            new[i + j + 1] += 1
            transitions.append((new, state[i] * (state[j] - same) / (1 + same)))
    return transitions

g = Graph(coalescent)
g.plot_scc_decomp(figsize=(10, 4));

The plot is structural: it depends only on the graph topology, not on any runtime telemetry. For aggregate cache hit/miss counters after a hierarchical compose, use phasic.cache.scc_compose_stats() (see the Multi-level caching tutorial).

The SCC decomposition tutorial walks through four worked examples — a hand-built cycle, a coalescent, a two-locus ARG, and a migration chain — explaining what shapes of treemap indicate good or bad parallelism potential.

Conflict policy: nothing silently overrides anything

If an environment variable was set in the shell (i.e. before phasic was imported) and a later configure() call requests a different value for the matching field, the library raises rather than silently overwriting. This rule applies symmetrically: configure() cannot silently win over the shell, and the shell cannot silently win over configure().

# Pretend OMP_NUM_THREADS was set by the shell (i.e. before
# phasic was imported). The bookkeeping helper below clears
# the "phasic-assigned" marker so the conflict checker treats
# the env var as user-set; in a real shell session this is
# automatic.
from phasic.config import _phasic_assigned_env
_phasic_assigned_env.discard('OMP_NUM_THREADS')

os.environ['OMP_NUM_THREADS'] = '2'
reset_config()

try:
    configure(cpu_threads=8)
except PTDConfigError as e:
    print('Caught conflict:')
    print(' ', str(e)[:200])

# Resolve by aligning the values, or by unsetting the env var.
configure(cpu_threads=2)  # agrees with shell — no conflict
print()
print('After agreement: OMP_NUM_THREADS =', os.environ['OMP_NUM_THREADS'])

# Clean up.
del os.environ['OMP_NUM_THREADS']
reset_config()
Caught conflict:
  Conflicting configuration for cpu_threads: shell sets OMP_NUM_THREADS='2' but configure(cpu_threads=8) would write OMP_NUM_THREADS='8'. Unset OMP_NUM_THREADS before launch, or align the two values.

After agreement: OMP_NUM_THREADS = 2

The same policy applies to validation: configure(high_precision_mode='always') on a build without MPFR raises, configure(compute='jax-gpu') on a CPU-only machine raises, configure(parallel_elimination=True, cpu_threads=1) raises (parallelism with one thread is meaningless), and so on. strict=False only downgrades availability warnings (e.g. “no GPU detected”); conflicts and contradictions always raise.

Probing what’s available

Use get_available_options() to branch on build-time features before configuring:

opts = get_available_options()
for k, v in opts.items():
    print(f'  {k:14s} = {v}')
  jax            = True
  cpp            = True
  mpfr           = True
  platforms      = ['cpu']
# Example: enable MPFR-always only when MPFR is built.
if opts['mpfr']:
    configure(high_precision_mode='always')
else:
    configure(high_precision_mode='auto')

reset_config()

Environment variables

All public fields except compute, strict, and verbose are backed by a PHASIC_* or OMP_NUM_THREADS environment variable. Setting them in the shell has the same effect as calling configure():

# Equivalent to phasic.configure(parallel_elimination=True,
#                                parallel_elimination_max_concurrent=8,
#                                cache_dir='/scratch/phasic_cache')
export PHASIC_HIERAR_ELIMINATION=1
export PHASIC_MAX_PARALLEL_SCCS=8
export PHASIC_CACHE_DIR=/scratch/phasic_cache
python my_script.py

When phasic is imported, get_config() reads each of these env vars and seeds the corresponding field, so the dataclass and the C runtime always agree on what’s enabled. On SLURM clusters this is the recommended path: the job script sets the env vars and the Python code never has to touch configure(...).

Tip

The reverse mapping is asymmetric on purpose: some env vars carry inverted polarity (PHASIC_DISABLE_CACHE=1 ↔︎ cache_enabled=False), and some fields don’t have an env var (compute, strict, verbose). The full mapping is documented in the “Available options” tables above.

Resetting

reset_config() discards the cached global config object so the next get_config() rebuilds it fresh from the current environment. Useful in tests, less so in production code. It does not roll back env vars that previous configure(...) calls wrote — if you need a fully clean environment, unset the relevant PHASIC_* / OMP_NUM_THREADS variables manually in os.environ first.

# Inspect what's currently in the environment, reset, and confirm.
print('Before reset:', list(k for k in os.environ if k.startswith('PHASIC_')))
reset_config()
print('After reset: ', list(k for k in os.environ if k.startswith('PHASIC_')))
Before reset: ['PHASIC_FORCE_MPFR']
After reset:  ['PHASIC_FORCE_MPFR']