File size: 12,646 Bytes
921d377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
"""Scene-render target selection for the Interactive pre-render pass.

Additive, best-practice replacement for the naive
``[n for n in nodes if n.kind in ("scene","decision","assessment","remediation")]``
filter that ``generator_auto.py`` applies before calling ``render_scene_async``.

Why
---
The naive filter ignores two facts:

1. **Persona Live Play owns its own rendering pipeline.** It anchors on the
   persona portrait and renders edit-recipe variants per user action. The
   scene-graph ``asset_ids[]`` are never read by the Persona Live runtime
   (see ``interactive/routes/persona_live.py::_pick_anchor_image_ref``).
   Pre-rendering scene nodes for a ``persona_live_play`` project is pure
   wasted compute β€” one SDXL pass per node that nothing ever displays.

2. **Standard projects rarely need every node rendered before Play opens.**
   Only the start node and its first-decision layer need to be ready for
   the initial frames. Deeper nodes can be rendered on-demand as the
   user's cursor approaches them (industry prefetch pattern).

This module exposes two pure functions with zero I/O side effects:

- ``compute_reachable_nodes(nodes, edges, start_id, max_depth)`` β€” BFS over
  the graph bounded by ``max_depth``.
- ``filter_targets_for_play(targets, experience, edges)`` β€” takes the
  already-computed naive target list and the experience, returns the
  subset that actually needs pre-rendering.

Both are side-effect-free and typed so they're trivial to unit-test
without standing up a real experience.

Environment knobs
-----------------
- ``INTERACTIVE_PRERENDER_DEPTH`` (int, default 2) β€” BFS depth limit for
  Standard projects. Higher = more eager, more compute. Lower = lazier,
  faster "Rendering scenes" phase.
- ``INTERACTIVE_PRERENDER_PERSONA_LIVE`` (``true|false``, default ``false``)
  β€” emergency knob that restores the old behavior (pre-render everything
  for Persona Live too) if a regression is found.
"""
from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Iterable, List, Optional, Sequence, Set

# ── Configuration ────────────────────────────────────────────────────────────

# BFS depth used to decide how many layers of scene/decision nodes to
# pre-render for Standard projects. Default is a large number so existing
# flows pre-render everything (behavior-compatible). Operators opt into
# the depth-limited eager render by setting INTERACTIVE_PRERENDER_DEPTH to
# a small value (e.g. 2 = start + first decision + first choice layer).
DEFAULT_PRERENDER_DEPTH: int = int(os.getenv("INTERACTIVE_PRERENDER_DEPTH", "99") or 99)

# Emergency knob: pre-render everything for Persona Live (old behavior).
PRERENDER_PERSONA_LIVE: bool = (
    os.getenv("INTERACTIVE_PRERENDER_PERSONA_LIVE", "false").lower() == "true"
)

# Kinds that carry their own visual asset. Endings reuse the trailing frame.
RENDERABLE_KINDS: frozenset[str] = frozenset(
    {"scene", "decision", "assessment", "remediation"}
)


# ── Types ────────────────────────────────────────────────────────────────────

@dataclass(frozen=True)
class _NodeLike:
    """Minimal duck-typed view of an interactive Node.

    Kept deliberately narrow so this module doesn't depend on the concrete
    ORM/dataclass in ``branching/graph.py`` β€” callers can pass any object
    with ``id``, ``kind``, and ``asset_ids`` attributes.
    """
    id: str
    kind: str
    asset_ids: Sequence[str] = ()


@dataclass(frozen=True)
class _EdgeLike:
    """Duck-typed edge view.

    Production ``interactive.models.Edge`` uses ``from_node_id`` /
    ``to_node_id``. Legacy test doubles (and the shim below) use
    ``from_id`` / ``to_id``. ``_edge_endpoints()`` below accepts both β€”
    this dataclass just documents the "old" shape for reference.
    """
    from_id: str
    to_id: str


def _edge_endpoints(edge: object) -> tuple[str, str]:
    """Return ``(from, to)`` for an edge, tolerating both naming styles.

    The production ``Edge`` pydantic model exposes ``from_node_id`` /
    ``to_node_id``; the test doubles in ``test_interactive_render_set``
    use ``from_id`` / ``to_id``. Returning ``("", "")`` for unrecognised
    shapes lets the caller skip the edge without crashing.
    """
    fid = (
        getattr(edge, "from_node_id", None)
        or getattr(edge, "from_id", None)
        or ""
    )
    tid = (
        getattr(edge, "to_node_id", None)
        or getattr(edge, "to_id", None)
        or ""
    )
    return str(fid or ""), str(tid or "")


@dataclass(frozen=True)
class RenderDecision:
    """Why a node was / wasn't selected for pre-render.

    Useful for SSE events and debug logs β€” lets the UI (and operators)
    see at a glance why "Rendering scenes 0/6" is actually 0/2 or 0/0.
    """
    node_id: str
    selected: bool
    reason: str
    depth: Optional[int] = None


# ── Core functions ───────────────────────────────────────────────────────────

def compute_reachable_nodes(
    nodes: Iterable,
    edges: Iterable,
    *,
    start_id: str,
    max_depth: int = DEFAULT_PRERENDER_DEPTH,
) -> Set[str]:
    """BFS from *start_id* up to (inclusive) *max_depth* edges away.

    Returns the set of reachable node IDs. The start node itself is
    always included at depth 0. Non-existent edges / dangling IDs are
    skipped silently β€” validation is the planner's job, not ours.

    A ``max_depth`` of 0 returns only the start node. Negative depths
    are clamped to 0.
    """
    if not start_id:
        return set()
    max_depth = max(0, int(max_depth))

    node_ids = {str(getattr(n, "id", "") or "") for n in nodes}
    node_ids.discard("")
    if start_id not in node_ids:
        return set()

    # Forward adjacency only β€” render set cares about what the user
    # reaches *from* the start, not what reaches them.
    adj: dict[str, List[str]] = {}
    for e in edges:
        fid, tid = _edge_endpoints(e)
        if fid and tid and fid in node_ids and tid in node_ids:
            adj.setdefault(fid, []).append(tid)

    reachable: Set[str] = {start_id}
    frontier: List[tuple[str, int]] = [(start_id, 0)]
    while frontier:
        nid, depth = frontier.pop(0)
        if depth >= max_depth:
            continue
        for child in adj.get(nid, ()):
            if child not in reachable:
                reachable.add(child)
                frontier.append((child, depth + 1))
    return reachable


def _interaction_type(experience: object) -> str:
    """Resolve the interaction flavour, tolerating dict / pydantic shapes.

    Production stores the setting in two places depending on surface:
      * ``experience.audience_profile["interaction_type"]`` β€” canonical,
        set by the wizard and read by Persona Live routes.
      * ``experience.project_type`` β€” legacy field; a value of
        ``"persona_live"`` is the project-level marker.

    We also accept a top-level ``interaction_type`` attribute/key so the
    existing unit tests (which pass ``{"interaction_type": ...}``
    literals) keep working. Returned value is normalised to match the
    legacy ``"persona_live_play"`` sentinel used by the filter.
    """
    def _dict_or_none(obj):
        if isinstance(obj, dict):
            return obj
        return None

    # 1. Top-level attribute / key (test-friendly, legacy)
    if isinstance(experience, dict):
        top = str(experience.get("interaction_type") or "").strip()
    else:
        top = str(getattr(experience, "interaction_type", "") or "").strip()
    if top:
        return top

    # 2. audience_profile.interaction_type (canonical runtime location)
    ap = None
    if isinstance(experience, dict):
        ap = _dict_or_none(experience.get("audience_profile"))
    else:
        ap = _dict_or_none(getattr(experience, "audience_profile", None))
    if ap:
        val = str(ap.get("interaction_type") or "").strip()
        if val:
            return val

    # 3. project_type fallback β€” "persona_live" implies persona-live play
    if isinstance(experience, dict):
        pt = str(experience.get("project_type") or "").strip().lower()
    else:
        pt = str(getattr(experience, "project_type", "") or "").strip().lower()
    if pt == "persona_live":
        return "persona_live_play"
    return ""


def _entry_node_id(experience: object, nodes: Sequence) -> str:
    """Best-effort lookup of the start node.

    Prefers an explicit ``entry_node_id`` / ``start_node_id`` field on
    the experience; falls back to the first scene node.
    """
    eid = ""
    if isinstance(experience, dict):
        eid = str(
            experience.get("entry_node_id")
            or experience.get("start_node_id")
            or ""
        )
    else:
        eid = str(
            getattr(experience, "entry_node_id", "")
            or getattr(experience, "start_node_id", "")
            or ""
        )
    if eid:
        return eid
    for n in nodes:
        if str(getattr(n, "kind", "") or "") == "scene":
            return str(getattr(n, "id", "") or "")
    return ""


def filter_targets_for_play(
    naive_targets: Sequence,
    *,
    experience: object,
    edges: Iterable,
    nodes: Optional[Sequence] = None,
    max_depth: int = DEFAULT_PRERENDER_DEPTH,
) -> tuple[List, List[RenderDecision]]:
    """Scope the naive target list to what Play will actually display.

    Returns ``(targets, decisions)`` where:
      * ``targets`` is the filtered list (same objects as ``naive_targets``),
        preserving their original order.
      * ``decisions`` is one ``RenderDecision`` per *original* target so
        callers can emit SSE events for both selected and skipped nodes.

    Policy
    ------
    1. ``interaction_type == "persona_live_play"`` β†’ return ``[]``. Persona
       Live anchors on the persona portrait and renders edit-recipe variants
       on demand; scene-graph assets are never read by its runtime.
       Set ``INTERACTIVE_PRERENDER_PERSONA_LIVE=true`` to opt back in.
    2. Otherwise β†’ BFS reachable set from the entry node, bounded by
       ``max_depth``. Nodes outside the set are marked ``deferred``; the
       on-demand render pass can fill them in when the user cursor lands.
    3. Nodes that already carry ``asset_ids`` are always kept β€” idempotency
       matters more than scope. (The existing generator_auto.py also
       skips these during the render loop.)
    """
    itype = _interaction_type(experience)

    # Persona Live: hand back zero targets.
    if itype == "persona_live_play" and not PRERENDER_PERSONA_LIVE:
        decisions = [
            RenderDecision(
                node_id=str(getattr(n, "id", "") or ""),
                selected=False,
                reason="persona_live_own_pipeline",
            )
            for n in naive_targets
        ]
        return [], decisions

    # Standard: BFS-bounded reachable set.
    node_list: Sequence = list(nodes) if nodes is not None else list(naive_targets)
    start_id = _entry_node_id(experience, node_list)
    reachable: Set[str] = set()
    if start_id:
        reachable = compute_reachable_nodes(
            node_list, edges, start_id=start_id, max_depth=max_depth
        )

    selected: List = []
    decisions: List[RenderDecision] = []
    for n in naive_targets:
        nid = str(getattr(n, "id", "") or "")
        has_assets = bool(getattr(n, "asset_ids", None))
        if has_assets:
            selected.append(n)
            decisions.append(RenderDecision(nid, True, "already_rendered"))
            continue
        if not reachable:
            # No entry node resolved β€” fall back to the original behavior.
            selected.append(n)
            decisions.append(RenderDecision(nid, True, "reachable_unknown"))
            continue
        if nid in reachable:
            selected.append(n)
            decisions.append(RenderDecision(nid, True, "reachable"))
        else:
            decisions.append(
                RenderDecision(nid, False, "deferred_unreachable_at_depth")
            )
    return selected, decisions


__all__ = [
    "DEFAULT_PRERENDER_DEPTH",
    "PRERENDER_PERSONA_LIVE",
    "RENDERABLE_KINDS",
    "RenderDecision",
    "compute_reachable_nodes",
    "filter_targets_for_play",
]