Coverage for core/langgraph/_reducers.py: 100%
107 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-20 02:14 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-20 02:14 +0000
1"""
2_reducers.py — Core-owned reducer layer shared by channel reducers and the
3aggregate step mechanism (core/docs/builtins/aggregate.md).
5Two surfaces, one implementation:
7* binary primitives (``_reduce_*``) consumed by ``_channels.py``'s
8 ``select_reducer`` ``BinaryOperatorAggregate`` dispatch;
9* list folds (``_fold_*``) plus ``apply_op`` consumed by the aggregate builtin.
11Stdlib only. This module must not import ``runner`` or ``_builtins``: runner
12imports this module, never the reverse. Non-numeric input to a numeric
13operator always raises ``ValueError``; a later phase maps that to the engine's
14hard failure (ADR-0012, no silent fallbacks).
15"""
16from __future__ import annotations
18from typing import Any, Callable
20VALID_OPERATORS = frozenset({
21 "union",
22 "concat",
23 "merge",
24 "last",
25 "first",
26 "sum",
27 "avg",
28 "min",
29 "max",
30 "count",
31 "group",
32 "any",
33 "all",
34})
37# ---------------------------------------------------------------------------
38# Binary primitives (relocated from runner.py; behaviour unchanged)
39# ---------------------------------------------------------------------------
41def _reduce_last(left: Any, right: Any) -> Any:
42 """Replace: the newest write wins."""
43 return right
46def _reduce_add_or_extend(left: list, right: Any) -> list:
47 """Append: concatenate list writes, wrap a scalar write in a list."""
48 if isinstance(right, list):
49 return left + right
50 return left + [right]
53def _dedupe_first_occurrence(values: list, key: Callable[[Any], Any] | None = None) -> list:
54 """Drop later duplicates, preserving first-occurrence order.
56 ``key`` derives the identity of an item; without it the whole value is the
57 identity, so the channel path's behaviour is unchanged.
58 """
59 unique: list = []
60 seen: list = []
61 for value in values:
62 identity = value if key is None else key(value)
63 if identity in seen:
64 continue
65 seen.append(identity)
66 unique.append(value)
67 return unique
70def _reduce_union_by_key(left: dict, right: dict, key: Callable[[Any], Any] | None = None) -> dict:
71 """First-wins key union; list values concatenate and de-duplicate."""
72 merged = dict(left)
73 for right_key, right_value in right.items():
74 if right_key not in merged:
75 merged[right_key] = right_value
76 continue
77 left_value = merged[right_key]
78 if isinstance(left_value, list) and isinstance(right_value, list):
79 merged[right_key] = _dedupe_first_occurrence(left_value + right_value, key)
80 return merged
83def _reduce_dict_merge(left: dict, right: dict) -> dict:
84 """Shallow merge: later keys win."""
85 return {**left, **right}
88def _is_collection_map(value: Any) -> bool:
89 """True for a non-empty dict whose every value is a list or set."""
90 return (
91 isinstance(value, dict)
92 and bool(value)
93 and all(isinstance(item, (list, set)) for item in value.values())
94 )
97def _require_mapping(right: Any) -> None:
98 if not isinstance(right, dict):
99 raise ValueError(
100 "BinaryOperatorAggregate reducer selected a dict reducer but the "
101 f"incoming write is {type(right).__name__}, not a dict"
102 )
105# ---------------------------------------------------------------------------
106# List folds (aggregate operator catalogue)
107# ---------------------------------------------------------------------------
109def _require_numeric(value: Any) -> int | float:
110 """Numeric means int/float; bool is deliberately excluded."""
111 if isinstance(value, bool) or not isinstance(value, (int, float)):
112 raise ValueError(
113 "aggregate numeric operator received a non-numeric value: "
114 f"{type(value).__name__}"
115 )
116 return value
119def _as_list(value: Any) -> list:
120 return list(value) if value is not None else []
123def _fold_union(items: list, acc: Any, key: Callable[[Any], Any] | None = None) -> list:
124 return _dedupe_first_occurrence(_as_list(acc) + list(items), key)
127def _fold_concat(items: list, acc: Any, key: Callable[[Any], Any] | None = None) -> list:
128 return _reduce_add_or_extend(_as_list(acc), list(items))
131def _fold_merge(items: list, acc: Any, key: Callable[[Any], Any] | None = None) -> dict:
132 merged = dict(acc) if acc is not None else {}
133 for item in items:
134 merged = _reduce_dict_merge(merged, item)
135 return merged
138def _fold_last(items: list, acc: Any, key: Callable[[Any], Any] | None = None) -> Any:
139 return _reduce_last(acc, items[-1]) if items else acc
142def _fold_first(items: list, acc: Any, key: Callable[[Any], Any] | None = None) -> Any:
143 if acc is not None:
144 return acc
145 return items[0] if items else None
148def _fold_sum(items: list, acc: Any, key: Callable[[Any], Any] | None = None) -> int | float:
149 total = 0 if acc is None else acc
150 for item in items:
151 total += _require_numeric(item)
152 return total
155def _fold_avg(items: list, acc: Any, key: Callable[[Any], Any] | None = None) -> dict | None:
156 total, count = (acc["sum"], acc["count"]) if acc is not None else (0, 0)
157 for item in items:
158 total += _require_numeric(item)
159 count += 1
160 if count == 0:
161 return None
162 return {"sum": total, "count": count}
165def _fold_min(items: list, acc: Any, key: Callable[[Any], Any] | None = None) -> int | float | None:
166 minimum = acc
167 for item in items:
168 value = _require_numeric(item)
169 if minimum is None or value < minimum:
170 minimum = value
171 return minimum
174def _fold_max(items: list, acc: Any, key: Callable[[Any], Any] | None = None) -> int | float | None:
175 maximum = acc
176 for item in items:
177 value = _require_numeric(item)
178 if maximum is None or value > maximum:
179 maximum = value
180 return maximum
183def _fold_count(items: list, acc: Any, key: Callable[[Any], Any] | None = None) -> int:
184 return (0 if acc is None else acc) + len(items)
187def _fold_group(items: list, acc: Any, key: Callable[[Any], Any] | None = None) -> dict:
188 buckets = {bucket: list(values) for bucket, values in acc.items()} if acc else {}
189 for item in items:
190 bucket = item if key is None else key(item)
191 buckets.setdefault(bucket, []).append(item)
192 return buckets
195def _fold_any(items: list, acc: Any, key: Callable[[Any], Any] | None = None) -> bool:
196 if acc:
197 return True
198 return any(items)
201def _fold_all(items: list, acc: Any, key: Callable[[Any], Any] | None = None) -> bool:
202 if acc is not None and not acc:
203 return False
204 return all(items)
207_OP_REDUCERS: dict[str, Callable[..., Any]] = {
208 "union": _fold_union,
209 "concat": _fold_concat,
210 "merge": _fold_merge,
211 "last": _fold_last,
212 "first": _fold_first,
213 "sum": _fold_sum,
214 "avg": _fold_avg,
215 "min": _fold_min,
216 "max": _fold_max,
217 "count": _fold_count,
218 "group": _fold_group,
219 "any": _fold_any,
220 "all": _fold_all,
221}
224def apply_op(
225 op_name: str,
226 items: Any,
227 acc: Any = None,
228 key: Callable[[Any], Any] | None = None,
229) -> Any:
230 """Fold ``items`` into ``acc`` with the named aggregate operator.
232 The only dispatch point the aggregate builtin uses. ``key`` is the
233 already-resolved identity/grouping callable for ``union`` and ``group``;
234 path resolution is the caller's concern. ``acc`` is never mutated.
235 """
236 try:
237 reducer = _OP_REDUCERS[op_name]
238 except KeyError:
239 raise ValueError(f"unknown aggregate operator: {op_name!r}") from None
240 return reducer(_as_list(items), acc, key)