twanghcmut's picture
download
raw
25.9 kB
"""Tests for onf.graph.run.retrieve — the runtime demo-graph retriever.
No dataset dependency: every test builds a small, synthetic, fully in-memory graph (straight-line toy
demos through onf.graph.core.nodes.NodeTable.from_demos + onf.graph.core.edges.EdgeSet.build) and
a randomly-initialised onf.graph.net.gnn.GraphRetrieverNet — this subsystem is data-coupling-free
by design (see src/onf/graph/gnn.py's module docstring).
"""
from __future__ import annotations
import numpy as np
import pytest
import torch
from onf.config import GraphConfig
from onf.graph.core import schema
from onf.graph.core.edges import EdgeSet
from onf.graph.net.gnn import GraphRetrieverNet
from onf.graph.core.nodes import NodeTable
from onf.graph.run.retrieve import GraphRetriever, LaneVocabulary
from onf.graph.core.geometry import CleanlinessScorer, graph_hash
DIM = 4 # small synthetic joint dim -- any works, node_encode/edges don't care
# 31 raw frames, coarsen=3: the gripper-close split at n//2=15 leaves a 16-frame second stage, whose
# last coarse group is a single frame (16 % 3 == 1) landing exactly on the final raw frame -- required
# so the table-wide phase max hits >0.99 (NodeTable.validate's phase-convention invariant; an evenly
# divisible frame count would leave every node's phase < 1.0 and fail that check).
N_FRAMES = 31
COARSEN = 3
HIDDEN = 16
# ======================================================================================================
# synthetic graph fixture
# ======================================================================================================
def _make_demo(task_id: int, seed: int, n: int = N_FRAMES, dim: int = DIM) -> dict:
"""A straight-line synthetic demo: joint config drifts linearly along a random unit direction, the
gripper closes halfway through (one gripper-state change, exercising the node-splitting rule and
the grip-bucketed sibling/align relations without any real robot data)."""
rng = np.random.RandomState(seed)
start = rng.randn(dim) * 0.01
direction = rng.randn(dim)
direction /= np.linalg.norm(direction)
t = np.arange(n, dtype=np.float32)
q = (start[None, :] + 0.05 * t[:, None] * direction[None, :]).astype(np.float32)
qdot = np.zeros_like(q)
qdot[1:] = q[1:] - q[:-1]
grip = np.zeros(n, dtype=np.uint8)
grip[n // 2 :] = 1
stage = grip.astype(np.int16)
return {"q": q, "qdot": qdot, "grip": grip, "stage": stage, "task_id": task_id}
def _declare_ee_base(nodes: NodeTable) -> NodeTable:
"""Stand in for NodeTable.ee_base on a corpus that is not a 7-DoF Panda.
ee_base is normally forward-kinematicked from q_raw, which needs a real robot; these demos are
straight lines in a D=5 joint space. No test here reads an end-effector value, only the readout
path that gathers one, so the demos' own declared ee is the right stand-in."""
nodes.ee_base = nodes.ee_raw
return nodes
def _toy_nodes_edges(n_demos: int = 4, dim: int = DIM) -> tuple[NodeTable, EdgeSet]:
demos = [_make_demo(task_id=i % 2, seed=100 + i, dim=dim) for i in range(n_demos)]
nodes = NodeTable.from_demos(demos, task_names=["taskA", "taskB"], coarsen=COARSEN)
edges = EdgeSet.build(nodes, k_sib=4, k_align=2, device="cpu")
return nodes, edges
def _toy_net(nodes: NodeTable, hidden: int = HIDDEN, layers: int = 2) -> GraphRetrieverNet:
net = GraphRetrieverNet(dim=nodes.dim, hidden=hidden, layers=layers, n_rel=schema.N_RELATIONS, agg="sum")
mean, std = nodes.feature_stats()
net.set_stats(mean, std)
net.eval()
return net
def _write_graph_dir(tmp_path, nodes: NodeTable, edges: EdgeSet, net: GraphRetrieverNet, bad_hash: str | None = None):
d = tmp_path / "graph"
d.mkdir(exist_ok=True)
nodes.save(d)
edges.save(d / schema.EDGES_NPZ)
head = dict(net.state_npz_dict())
head["graph_hash"] = np.array(bad_hash if bad_hash is not None else graph_hash(nodes, edges))
np.savez(d / schema.HEAD_NPZ, **head)
return d
def _loaded_retriever(tmp_path, **cfg_kwargs) -> GraphRetriever:
"""readout defaults to "euc_raw" here (NOT GraphConfig's own bare-dataclass default,
which is still the string "argmax" -- that arm was deleted from onf.graph.run.readout's
registry along with the rest of the closed ablation, see that module's docstring) so every test in
this file that does not care which arm it exercises gets a REGISTERED one; pass readout=...
explicitly to override."""
nodes, edges = _toy_nodes_edges()
net = _toy_net(nodes)
d = _write_graph_dir(tmp_path, nodes, edges, net)
cfg_kwargs.setdefault("readout", "euc_raw")
retriever = GraphRetriever.load(d, cfg=GraphConfig(device="cpu", **cfg_kwargs))
_declare_ee_base(retriever.nodes)
return retriever
# ======================================================================================================
# 1. load round-trip
# ======================================================================================================
def test_load_round_trips_from_saved_artifacts(tmp_path):
nodes, edges = _toy_nodes_edges()
net = _toy_net(nodes)
d = _write_graph_dir(tmp_path, nodes, edges, net)
retriever = GraphRetriever.load(d, cfg=GraphConfig(device="cpu"))
assert len(retriever.nodes) == len(nodes)
assert retriever.net.dim == nodes.dim
assert retriever.graph_hash == graph_hash(nodes, edges)
# ======================================================================================================
# 2. graph_hash mismatch raises
# ======================================================================================================
def test_load_rejects_graph_hash_mismatch(tmp_path):
nodes, edges = _toy_nodes_edges()
net = _toy_net(nodes)
d = _write_graph_dir(tmp_path, nodes, edges, net, bad_hash="not-the-right-hash")
with pytest.raises(ValueError, match="graph_hash mismatch"):
GraphRetriever.load(d, cfg=GraphConfig(device="cpu"))
def test_load_rejects_missing_graph_hash(tmp_path):
"""A head with no graph_hash key at all (predates the stamp) must also be rejected, not silently
trusted -- see the module docstring on why this is the single worst failure mode to let slide."""
nodes, edges = _toy_nodes_edges()
net = _toy_net(nodes)
d = tmp_path / "graph"
d.mkdir()
nodes.save(d)
edges.save(d / schema.EDGES_NPZ)
np.savez(d / schema.HEAD_NPZ, **net.state_npz_dict()) # no graph_hash key
with pytest.raises(ValueError, match="graph_hash mismatch"):
GraphRetriever.load(d, cfg=GraphConfig(device="cpu"))
# ======================================================================================================
# 3. well-formed GraphRetrieval
# ======================================================================================================
def test_retrieve_returns_well_formed_result(tmp_path):
retriever = _loaded_retriever(tmp_path)
nodes = retriever.nodes
q0 = nodes.q[5].astype(np.float64)
result = retriever.retrieve(np.array([q0]))
assert result.p_node.shape == (len(nodes),)
assert np.isclose(result.p_node.sum(), 1.0)
assert not np.any(np.isnan(result.p_node))
assert np.all(result.p_node >= 0.0)
assert result.p_phase.shape == (schema.NBINS_ALIGN,)
assert np.isclose(result.p_phase.sum(), 1.0)
assert result.q_seg.shape == (retriever.cfg.seg_k, nodes.dim)
assert result.qdot_seg.shape == (retriever.cfg.seg_k, nodes.dim)
assert result.q_star.shape == (nodes.dim,)
assert 0.0 <= result.depth <= 1.0
assert np.isfinite(result.conf) and np.isfinite(result.entropy)
assert np.isfinite(result.off_manifold) and result.off_manifold >= 0.0
# euc_raw (this helper's default arm, see _loaded_retriever) is free-form: node == -1 always
# (module docstring, onf.graph.run.readout) -- so there is no single node to read an nll off of.
assert result.node == -1
assert np.isnan(result.nll)
# ======================================================================================================
# 4. determinism
# ======================================================================================================
def test_retrieve_deterministic_bit_identical(tmp_path):
retriever = _loaded_retriever(tmp_path)
q0 = retriever.nodes.q[3].astype(np.float64)
r1 = retriever.retrieve(np.array([q0]))
r2 = retriever.retrieve(np.array([q0]))
assert r1.node == r2.node
assert np.array_equal(r1.p_node, r2.p_node)
assert np.array_equal(r1.p_phase, r2.p_phase)
assert np.array_equal(r1.q_seg, r2.q_seg)
assert np.array_equal(r1.qdot_seg, r2.qdot_seg)
assert r1.entropy == r2.entropy
# euc_raw is free-form (node == -1 always), so nll is nan by construction -- nan != nan under
# plain ==, so this must be checked the same nan-safe way as every other nll comparison in this
# file (e.g. test_entry_grip_open_is_bit_identical_to_g_none).
assert r1.nll == r2.nll or (np.isnan(r1.nll) and np.isnan(r2.nll))
assert r1.off_manifold == r2.off_manifold
# ======================================================================================================
# 5. short / single-frame history is edge-padded
# ======================================================================================================
def test_retrieve_short_history_edge_padded(tmp_path):
retriever = _loaded_retriever(tmp_path)
nodes = retriever.nodes
q0 = nodes.q[0].astype(np.float64)
r_single = retriever.retrieve(np.array([q0])) # T=1: "no history yet" (episode start)
assert r_single.info["padded"] is True
assert r_single.info["hist_len"] == 1
assert not np.any(np.isnan(r_single.p_node))
q_short = nodes.q[:3].astype(np.float64) # 3 < cfg.hist (8)
r_short = retriever.retrieve(q_short)
assert r_short.info["padded"] is True
assert r_short.info["hist_len"] == 3
assert not np.any(np.isnan(r_short.p_node))
q_full = nodes.q[:retriever.cfg.hist].astype(np.float64)
r_full = retriever.retrieve(q_full)
assert r_full.info["padded"] is False
def test_retrieve_rejects_truly_empty_history(tmp_path):
retriever = _loaded_retriever(tmp_path)
with pytest.raises(ValueError, match="at least"):
retriever.retrieve(np.zeros((0, retriever.nodes.dim)))
# ======================================================================================================
# 6. exclude_owner is a genuine leave-one-demo-out exclusion
# ======================================================================================================
def test_exclude_owner_excludes_that_demos_nodes(tmp_path):
retriever = _loaded_retriever(tmp_path)
nodes = retriever.nodes
owner0_nodes = nodes.demo_nodes(0)
q0 = nodes.q[owner0_nodes[len(owner0_nodes) // 2]].astype(np.float64)
q_hist = np.repeat(q0[None, :], retriever.cfg.hist, axis=0)
excluded = retriever.retrieve(q_hist, exclude_owner=0)
assert np.all(excluded.p_node[owner0_nodes] == 0.0)
assert np.isclose(excluded.p_node.sum(), 1.0) # renormalized over the survivors
assert excluded.owner != 0 # can't have picked a node it has zero mass on
# ======================================================================================================
# 8. cleanliness
# ======================================================================================================
class _StubField:
"""Deterministic per-row f(q) values, keyed on call order -- lets a test dictate exactly which
window rows are "dirty" without needing a real ONFField."""
def __init__(self, f_by_row):
self._f = list(f_by_row)
self._i = 0
def f_value(self, q):
v = self._f[self._i]
self._i += 1
return v
def test_cleanliness_dirtier_frames_get_lower_weight():
Q = np.zeros((4, 3))
stub = _StubField([0.0, 5.0, 0.0, 5.0]) # rows 1 and 3 are "dirty" (large field distance)
w = CleanlinessScorer(stub).weights(Q)
assert w[0] > w[1]
assert w[2] > w[3]
assert np.all((w >= 0.0) & (w <= 1.0))
def test_cleanliness_uniform_fallback_when_no_field():
Q = np.zeros((4, 3))
CleanlinessScorer.reset_uniform_warning() # the warning is once-per-process
with pytest.warns(UserWarning, match="uniform"):
w = CleanlinessScorer(None).weights(Q)
assert np.allclose(w, 1.0)
# ======================================================================================================
# 9. caching: node_encode runs once across many retrieve() calls
# ======================================================================================================
def test_node_encode_cached_across_retrieves(tmp_path, monkeypatch):
nodes, edges = _toy_nodes_edges()
net = _toy_net(nodes)
d = _write_graph_dir(tmp_path, nodes, edges, net)
calls = {"n": 0}
orig = GraphRetrieverNet.node_encode
def counting(self, x):
calls["n"] += 1
return orig(self, x)
monkeypatch.setattr(GraphRetrieverNet, "node_encode", counting)
# readout="euc_raw": GraphConfig's own bare default ("argmax") is no longer a registered arm --
# see _loaded_retriever's docstring.
retriever = GraphRetriever.load(d, cfg=GraphConfig(device="cpu", readout="euc_raw"))
_declare_ee_base(retriever.nodes)
assert calls["n"] == 1 # exactly once, at load
q0 = nodes.q[1].astype(np.float64)
for _ in range(5):
retriever.retrieve(np.array([q0]))
assert calls["n"] == 1 # retrieve() must never rebuild it
# ======================================================================================================
# 13. real onf.graph.run.readout delegation
# ======================================================================================================
def test_readout_euc_raw_delegates_correctly(tmp_path):
"""'euc_raw' also needs no latent artifact and always reports node == -1 (a free-form blend, not a
single demo frame) -- exercises the node<0 branch of the delegation."""
retriever = _loaded_retriever(tmp_path, readout="euc_raw")
q0 = retriever.nodes.q[4].astype(np.float64)
result = retriever.retrieve(np.array([q0]))
assert result.node == -1
assert result.q_star.shape == (retriever.nodes.dim,)
assert result.q_seg.shape == (retriever.cfg.seg_k, retriever.nodes.dim)
assert "target_off_manifold" in result.info
# node == -1 is the free-form sentinel -- bookkeeping that would otherwise silently negative-index
# the LAST node (owner/task_id/stage/t_idx) must all report -1, and there is no single node to
# read an nll off of.
assert (result.owner, result.task_id, result.stage, result.t_idx) == (-1, -1, -1, -1)
assert np.isnan(result.nll)
# ======================================================================================================
# 14. move_cost (Divergence A) -- see onf.graph.run.retrieve's module docstring, "DIVERGENCE A"
# ======================================================================================================
# The empirical move_cost-invariance property this section used to pin (`test_nearest_arm_invariant_
# to_move_cost) was specific to the nearest` readout arm, which hand-implemented its own movement-cost
# argmin independent of net.out's move-cost-fed scores. nearest has been deleted along with the rest
# of the closed readout ablation (see onf.graph.run.readout's module docstring); neither surviving arm
# (euc_raw/basin) has an analogous hand-rolled invariance to move_cost, so there is no equivalent
# property left in this codebase to test here. Divergence A itself (the fix that threads
# GraphConfig.move_cost's channel into net.logits) is unaffected and still exercised implicitly by
# every other test in this file that calls retrieve() with the default move_cost=True.
# ======================================================================================================
# 15. observe() -- the PURE CODE MOVE extraction (see onf.graph.run.retrieve's module docstring)
# ======================================================================================================
def test_retrieve_matches_observe_plus_aggregation(tmp_path):
"""GraphRetriever.observe is retrieve()'s network forward pass, extracted verbatim; the
softmax / top-M truncation / readout / aggregation block below it is UNCHANGED. Rebuild retrieve()'s
result BY HAND from observe()'s onf.graph.run.retrieve.Observation and compare field-by-field
with np.testing.assert_array_equal (not allclose) -- a genuine behaviour change introduced
during the split would show up here as an exact mismatch, not just "close enough"."""
retriever = _loaded_retriever(tmp_path)
nodes = retriever.nodes
q_hist = nodes.q[2 : 2 + retriever.cfg.hist].astype(np.float64)
actual = retriever.retrieve(q_hist)
# ---- by-hand reconstruction, mirroring retrieve()'s own body exactly ----------------------------
obs = retriever.observe(q_hist)
q_last = obs.window.q_now # retrieve() reads it off the observation
with torch.no_grad():
logits64 = obs.logits64
p_full = torch.softmax(logits64, dim=0)
abstain_logit64 = obs.abstain_logit.double()
cat_logits = torch.cat([logits64, abstain_logit64.reshape(1)])
p_cat = torch.softmax(cat_logits, dim=0)
p_abstain = float(p_cat[-1].item())
is_abstain = bool(abstain_logit64.item() > obs.node_max)
finite = torch.isfinite(logits64)
topm = min(retriever.cfg.topm, int(finite.sum().item()))
topv, topidx = torch.topk(p_full, topm, largest=True, sorted=True)
p_node_t = torch.zeros_like(p_full)
p_node_t[topidx] = topv
p_node_t = p_node_t / p_node_t.sum().clamp_min(1e-300)
with torch.no_grad():
outcome = retriever.aggregator.readout(p_full, q_now=q_last)
node, q_star, q_seg = outcome.node, outcome.q_star, outcome.q_seg
qdot_seg, depth, conf = outcome.qdot_seg, outcome.depth, outcome.conf
p_node = p_node_t.detach().cpu().numpy()
p_full_np = p_full.detach().cpu().numpy()
if node >= 0:
owner, task_id = int(nodes.owner[node]), int(nodes.task_id[node])
stage, t_idx = int(nodes.stage[node]), int(nodes.t_idx[node])
nll = float(-np.log(max(p_full_np[node], 1e-300)))
else:
owner = task_id = stage = t_idx = -1
nll = float("nan")
p_phase = retriever.aggregator.phase_hist(p_node)
nz = p_node > 0
entropy = float(-(p_node[nz] * np.log(p_node[nz])).sum())
# ---- compare against the ACTUAL retrieve() output, field by field, EXACT equality ---------------
np.testing.assert_array_equal(actual.p_node, p_node)
np.testing.assert_array_equal(actual.p_phase, p_phase)
assert actual.node == node
assert (actual.owner, actual.task_id, actual.stage, actual.t_idx) == (owner, task_id, stage, t_idx)
np.testing.assert_array_equal(actual.q_star, q_star)
np.testing.assert_array_equal(actual.q_seg, q_seg)
np.testing.assert_array_equal(actual.qdot_seg, qdot_seg)
assert actual.depth == depth
assert actual.conf == conf
assert actual.entropy == entropy
assert actual.nll == nll or (np.isnan(actual.nll) and np.isnan(nll))
assert actual.off_manifold == obs.off_manifold
assert actual.abstain == is_abstain
assert actual.p_abstain == p_abstain
assert actual.info["hist_len"] == obs.hist_len
assert actual.info["padded"] == obs.padded
def test_retrieve_deterministic_bit_identical_still_passes_through_observe(tmp_path):
"""Non-regression pin: the pre-existing determinism guarantee
(test_retrieve_deterministic_bit_identical) must survive the observe() extraction
unchanged -- two calls through the now-split pipeline are still bit-identical."""
retriever = _loaded_retriever(tmp_path)
q0 = retriever.nodes.q[3].astype(np.float64)
r1 = retriever.retrieve(np.array([q0]))
r2 = retriever.retrieve(np.array([q0]))
assert r1.node == r2.node
assert np.array_equal(r1.p_node, r2.p_node)
assert np.array_equal(r1.p_phase, r2.p_phase)
assert np.array_equal(r1.q_seg, r2.q_seg)
assert np.array_equal(r1.qdot_seg, r2.qdot_seg)
assert r1.entropy == r2.entropy
# euc_raw is free-form (node == -1 always), so nll is nan by construction -- nan != nan under
# plain ==, so this must be checked the same nan-safe way as every other nll comparison in this
# file (e.g. test_entry_grip_open_is_bit_identical_to_g_none).
assert r1.nll == r2.nll or (np.isnan(r1.nll) and np.isnan(r2.nll))
assert r1.off_manifold == r2.off_manifold
# ======================================================================================================
# 16. divergence C -- the gripper channel (see onf.graph.run.retrieve's module docstring, "DIVERGENCE C")
# ======================================================================================================
def test_entry_grip_open_is_bit_identical_to_g_none(tmp_path):
"""g_hist=None defaults to an all-OPEN gripper channel — *correct* pre-grasp — so an explicit
all-open g_hist (a scalar 0.0, or an all-zero array) must be BIT-IDENTICAL to it. This is the
offline PROOF that the ENTRY path (the project's best-measured result, ~89%) is not disturbed by
threading g_hist through retrieve() -- see onf.graph.core.schema.grip_flag's docstring."""
retriever = _loaded_retriever(tmp_path)
q0 = retriever.nodes.q[0].astype(np.float64)
q_hist = np.repeat(q0[None, :], retriever.cfg.hist, axis=0)
r_none = retriever.retrieve(q_hist, g_hist=None)
r_scalar = retriever.retrieve(q_hist, g_hist=0.0)
r_array = retriever.retrieve(q_hist, g_hist=np.zeros(retriever.cfg.hist))
for r in (r_scalar, r_array):
assert r.node == r_none.node
assert np.array_equal(r.p_node, r_none.p_node)
assert np.array_equal(r.q_seg, r_none.q_seg)
assert np.array_equal(r.qdot_seg, r_none.qdot_seg)
assert r.entropy == r_none.entropy
assert r.nll == r_none.nll or (np.isnan(r.nll) and np.isnan(r_none.nll))
def test_grip_hist_is_constant_within_window(tmp_path):
"""QueryPreprocessor.build takes a SINGLE current gripper flag (a bare scalar, or an array's LAST entry) and
broadcasts it across the WHOLE padded window -- matching training's own convention exactly
(train.py's make_queries/_queries_from_drift, both np.repeat): the trained encoder
has never seen a window whose grip channel changes mid-window (see QueryPreprocessor.build)."""
retriever = _loaded_retriever(tmp_path)
q_hist = retriever.nodes.q[:3].astype(np.float64) # shorter than cfg.hist -> edge-padded too
g_scalar = retriever.preprocessor.build(q_hist, 1.0).grip
assert g_scalar.shape == (retriever.cfg.hist,)
assert np.all(g_scalar == 1.0)
# an array's LAST entry is "the current flag" -- mirrors q_hist's own row(-1)=current convention
g_array = retriever.preprocessor.build(q_hist, np.array([0.0, 0.0, 1.0])).grip
assert np.all(g_array == 1.0)
g_none = retriever.preprocessor.build(q_hist, None).grip
assert np.all(g_none == 0.0)
# ======================================================================================================
# 12. task lane — reworded harness instructions
# ======================================================================================================
def test_task_lane_survives_a_reworded_instruction():
"""LIBERO-Plus's Language_Instructions axis rewords the instruction, so the substring test in
CandidateMasker._resolve_lane matches nothing and the lane silently opens to every task in the
graph (measured: 345 of 383 long instances). LaneVocabulary is the content-word fallback; it must
stay silent rather than guess when two names are equally close."""
# the long suite's own ten task names, scene prefix stripped -- plain strings, no artifact needed
cores = [
"turn_on_the_stove_and_put_the_moka_pot_on_it",
"put_the_black_bowl_in_the_bottom_drawer_of_the_cabinet_and_close_it",
"put_the_yellow_and_white_mug_in_the_microwave_and_close_it",
"put_both_moka_pots_on_the_stove",
"put_both_the_alphabet_soup_and_the_cream_cheese_box_in_the_basket",
"put_both_the_alphabet_soup_and_the_tomato_sauce_in_the_basket",
"put_both_the_cream_cheese_box_and_the_butter_in_the_basket",
"put_the_white_mug_on_the_left_plate_and_put_the_yellow_and_white_mug_on_the_right_plate",
"put_the_white_mug_on_the_plate_and_put_the_chocolate_pudding_to_the_right_of_the_plate",
"pick_up_the_book_and_place_it_in_the_back_compartment_of_the_caddy",
]
vocabulary = LaneVocabulary(cores)
# verbatim: the substring test already handles this, so the fallback never sees it -- but it
# must not disagree when it does
assert vocabulary.best("turn on the stove and put the moka pot on it") == 0
# reworded harness strings, verbatim from the Language_Instructions axis, sharing no substring
# with any name
assert vocabulary.best(
"make sure the black bowl is inside the bottom drawer of the cabinet and that the drawer is closed"
) == 1
assert vocabulary.best("could you please put the book in the caddys back compartment") == 9
# nothing to go on -> no lane, never a guessed one
assert vocabulary.best("do the thing") is None
assert vocabulary.best("") is None
# every content word swapped out: below the margin, so no lane rather than a coin flip between
# the two basket tasks it half-matches
assert vocabulary.best("place both the letter soup and the marinara in the hamper") is None

Xet Storage Details

Size:
25.9 kB
·
Xet hash:
ee9d88292bae800acc969c2fb81ab447b6a62597b74a40e3225146bb46168252

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.