Skip to content

perf: eliminate duplicate full-DAG evaluation per server mutation#156

Merged
QMalcolm merged 2 commits into
mainfrom
qmalcolm--perf-single-dag-eval
Jul 21, 2026
Merged

perf: eliminate duplicate full-DAG evaluation per server mutation#156
QMalcolm merged 2 commits into
mainfrom
qmalcolm--perf-single-dag-eval

Conversation

@QMalcolm

@QMalcolm QMalcolm commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Closes #128.

What

Two commits:

  1. refactor(characters): extract resolved_state/2 — the active_effects + Evaluator.evaluate! idiom appeared verbatim at four sites in characters.ex (six at the time the issue was filed; refactor(characters): unify auto-resolve and tracked resolution appliers #127 already collapsed two of them into resolve_all). Characters.resolved_state/2 now owns the pipeline and returns {effects, resolved} — a tuple because compute_pending_choice_slots needs the effects list afterward for its per-level re-evaluation; the other sites discard it.

  2. perf(cli): evaluate the DAG once per server mutation request — every mutation request (award, resolve_choice, build_finish) evaluated the full DAG twice: the server's resolve_character/2 once for pending choices, then Serializer.serialize_character again internally. serialize_character/5 now takes an optional precomputed resolved map (falling back to resolved_state/2 when absent), the five handler sites that already hold one pass it through, and resolve_character/2 is deleted.

Notes

  • Handlers that never resolved separately (show, build_start, random_resolve) keep using the serializer's internal fallback — they already evaluated once, so threading state through them would be plumbing for no saved work.
  • The serializer's optional param is the resolved map, not the {effects, resolved} tuple, since it never uses the effects list.
  • Per-iteration re-evaluation inside resolve_all loops is inherent (effects change between iterations); centralizing in resolved_state/2 is what makes future memoization possible.

No behavior change; all 389 tests pass unchanged, credo and dialyzer clean.

Summary by Bito

  • Introduced `Characters.resolved_state/2` to centralize the computation of active effects and system evaluation.
  • Refactored character resolution logic across the codebase to use the new `resolved_state/2` function instead of manual evaluation.
  • Updated `Serializer.serialize_character/5` to accept an optional `resolved` state, reducing redundant re-evaluations.
  • Removed the private `resolve_character/2` helper function from `CLI.Server` in favor of the new centralized implementation.

QMalcolm added 2 commits July 20, 2026 19:54
…uation

Part of #128.

The two-line idiom "effects = active_effects(...); resolved =
Evaluator.evaluate!(...)" was repeated verbatim at four sites in
characters.ex (prep_context, concept_roll!, resolve_all,
compute_pending_choice_slots). Each repetition is a chance for the
pipeline to drift (e.g. one site forgetting inventory effects if the
effects composition ever changes), and having no single owner makes
future memoization of the full-DAG evaluation impossible.

resolved_state/2 now owns the pipeline and returns {effects, resolved}
as a tuple rather than just the resolved map because
compute_pending_choice_slots needs the effects list afterward (it
re-evaluates per level with XP substituted). The other three sites
discard the effects element.

The function is public rather than private because the CLI server
duplicates the same idiom (resolve_character/2 in server.ex) and will
be switched to this helper in the next commit.
Closes #128.

Every mutation request (award, resolve_choice, build_finish) evaluated
the full DAG twice on the same character state: the server's
resolve_character/2 ran active_effects + Evaluator.evaluate! to compute
pending choices, then Serializer.serialize_character independently ran
the identical two lines again. For read paths the duplication was
latent rather than active, but the idiom itself was copy-pasted in
three places across the two apps.

serialize_character/5 now accepts an optional precomputed resolved map
and falls back to Characters.resolved_state/2 (added in the previous
commit) when given none. Handlers that already hold a resolved map
(award x2, resolve_choice x2, build_finish) pass it through, so those
requests evaluate the DAG exactly once. Handlers that never resolved
separately (show, build_start, random_resolve) keep relying on the
serializer's internal fallback — they already evaluated only once, so
threading state through them would add plumbing for no saved work.

resolve_character/2 is deleted; both remaining evaluation entry points
in the CLI now go through the library helper, which is the single
place a future memoization would live.

The optional parameter is a plain resolved map rather than the full
{effects, resolved} tuple because the serializer never uses the
effects list; passing the tuple would couple its signature to state it
ignores.
@QMalcolm
QMalcolm merged commit bc2af11 into main Jul 21, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: eliminate duplicate full-DAG evaluation per server mutation

1 participant