coltrace 0.1.0.dev4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
coltrace/callbacks.py ADDED
@@ -0,0 +1,579 @@
1
+ """Bounded value-dependency summaries for explicitly resolved scalar callbacks.
2
+
3
+ This module inspects syntax only. Text and regex methods are an *inferred*
4
+ scalar-text contract, not a proof about arbitrary Python objects or monkey
5
+ patching. Calls outside that contract are rejected. Callers must supply resolved
6
+ import names in ``symbols``; spelling a global ``re`` does not establish its API.
7
+ General control flow is rejected. A constant isinstance fallback is accepted
8
+ only when every returned component also has an explicit input value dependency;
9
+ otherwise omitting the condition could hide an actual dependency.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass
17
+ from typing import Literal
18
+
19
+ from .lineage_models import Completeness, Confidence
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class CallbackComponent:
24
+ depends_on_input: bool
25
+ completeness: Completeness = "complete"
26
+ evidence: Confidence = "confirmed"
27
+ steps: tuple[str, ...] = ()
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CallbackStep:
32
+ """A callback step with a semantic display role, separate from column edges."""
33
+
34
+ id: str
35
+ line: int
36
+ column: int
37
+ end_line: int | None
38
+ end_column: int | None
39
+ label: str
40
+ expression: str
41
+ parents: tuple[str, ...] = ()
42
+ depends_on_input: bool = False
43
+ role: Literal["value", "control", "configuration", "context"] = "value"
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class CallbackSummary:
48
+ components: tuple[CallbackComponent, ...] | None
49
+ supported: bool
50
+ may_mutate: bool
51
+ reason: str
52
+ steps: tuple[CallbackStep, ...] = ()
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class _HistoryValue:
57
+ parents: tuple[str, ...] = ()
58
+ direct: bool = False
59
+ items: tuple[_HistoryValue, ...] | None = None
60
+ depends: bool = False
61
+ known: bool = False
62
+
63
+
64
+ class _CallbackHistory:
65
+ """Record references and semantic roles after the bounded summary accepts it.
66
+
67
+ Control parents describe why a step may run. They do not turn a known,
68
+ input-independent preparation into a value transformation.
69
+ """
70
+
71
+ def __init__(self, parameter: str, symbols: Mapping[str, str], local_names: set[str]) -> None:
72
+ self.parameter = parameter
73
+ self.env: dict[str, _HistoryValue] = {
74
+ parameter: _HistoryValue(direct=True, depends=True, known=True)
75
+ }
76
+ self.semantic = _Summarizer(parameter, symbols)
77
+ self.semantic.local_names.update(local_names)
78
+ self.steps: list[CallbackStep] = []
79
+ self.guards: tuple[str, ...] = ()
80
+
81
+ def _references(self, node: ast.AST) -> _HistoryValue:
82
+ if isinstance(node, ast.Name):
83
+ return self.env.get(node.id, _HistoryValue())
84
+ if isinstance(node, ast.Subscript):
85
+ value = self._references(node.value)
86
+ if value.items is not None and isinstance(node.slice, ast.Constant):
87
+ index = node.slice.value
88
+ if type(index) is int and -len(value.items) <= index < len(value.items):
89
+ return value.items[index]
90
+ children = tuple(self._references(child) for child in ast.iter_child_nodes(node))
91
+ return _HistoryValue(
92
+ tuple(dict.fromkeys(parent for child in children for parent in child.parents)),
93
+ any(child.direct for child in children),
94
+ tuple(self._references(item) for item in node.elts)
95
+ if isinstance(node, ast.Tuple)
96
+ else None,
97
+ )
98
+
99
+ def _semantic_value(self, value: _HistoryValue, semantic: _Value) -> _HistoryValue:
100
+ items = (
101
+ tuple(
102
+ self._semantic_value(item, component)
103
+ for item, component in zip(value.items, semantic.items, strict=True)
104
+ )
105
+ if value.items is not None and semantic.kind == "tuple"
106
+ else value.items
107
+ )
108
+ return _HistoryValue(
109
+ value.parents,
110
+ value.direct,
111
+ items,
112
+ semantic.depends,
113
+ semantic.completeness == "complete" and semantic.evidence != "unresolved",
114
+ )
115
+
116
+ def expression(self, node: ast.AST) -> _HistoryValue:
117
+ return self._semantic_value(self._references(node), self.semantic.expression(node))
118
+
119
+ def record(
120
+ self,
121
+ node: ast.AST,
122
+ label: str,
123
+ value: _HistoryValue,
124
+ *,
125
+ role: Literal["value", "control", "configuration", "context"] | None = None,
126
+ ) -> _HistoryValue:
127
+ line, column = getattr(node, "lineno", 1), getattr(node, "col_offset", 0)
128
+ identifier = f"callback:{line}:{column}:{len(self.steps)}"
129
+ self.steps.append(
130
+ CallbackStep(
131
+ identifier,
132
+ line,
133
+ column,
134
+ getattr(node, "end_lineno", None),
135
+ getattr(node, "end_col_offset", None),
136
+ label,
137
+ ast.unparse(node),
138
+ tuple(dict.fromkeys(self.guards + value.parents)),
139
+ value.direct,
140
+ role
141
+ or ("value" if value.depends else "configuration" if value.known else "context"),
142
+ )
143
+ )
144
+ return _HistoryValue(
145
+ (identifier,), items=value.items, depends=value.depends, known=value.known
146
+ )
147
+
148
+ def assign(self, target: ast.AST, value: _HistoryValue, expression: ast.AST) -> None:
149
+ if isinstance(target, ast.Name):
150
+ if value.items is not None:
151
+ parts = tuple(
152
+ self.record(
153
+ expression.elts[index] if isinstance(expression, ast.Tuple) else expression,
154
+ f"{target.id}[{index}] = tuple component {index + 1}",
155
+ item,
156
+ )
157
+ for index, item in enumerate(value.items)
158
+ )
159
+ self.env[target.id] = _HistoryValue(
160
+ tuple(parent for part in parts for parent in part.parents),
161
+ items=parts,
162
+ depends=any(part.depends for part in parts),
163
+ known=all(part.known for part in parts),
164
+ )
165
+ return
166
+ self.env[target.id] = self.record(
167
+ expression, f"{target.id} = {ast.unparse(expression)}", value
168
+ )
169
+ elif isinstance(target, (ast.Tuple, ast.List)) and value.items is not None:
170
+ for item, component in zip(target.elts, value.items, strict=True):
171
+ self.assign(item, component, expression)
172
+
173
+ def body(self, statements: list[ast.stmt]) -> tuple[tuple[str, ...], ...]:
174
+ for statement in statements:
175
+ if isinstance(statement, ast.Assign):
176
+ semantic = self.semantic.expression(statement.value)
177
+ value = self.expression(statement.value)
178
+ for target in statement.targets:
179
+ self.assign(target, value, statement.value)
180
+ self.semantic.assign(target, semantic)
181
+ elif isinstance(statement, ast.AnnAssign) and statement.value is not None:
182
+ self.assign(statement.target, self.expression(statement.value), statement.value)
183
+ self.semantic.assign(statement.target, self.semantic.expression(statement.value))
184
+ elif isinstance(statement, ast.If):
185
+ # The semantic summary already proved this exact type guard.
186
+ # Its dependency controls execution rather than transforming values.
187
+ guard = self.record(
188
+ statement,
189
+ "Type guard; non-string input returns fallback constants",
190
+ self._references(statement.test),
191
+ role="control",
192
+ )
193
+ self.guards += guard.parents
194
+ self.semantic.env[self.parameter] = _Value(depends=True, kind="string")
195
+ elif isinstance(statement, ast.Return):
196
+ if statement.value is None:
197
+ return (
198
+ self.record(
199
+ statement, "Return None", _HistoryValue(known=True), role="value"
200
+ ).parents,
201
+ )
202
+ value = self.expression(statement.value)
203
+ parts = value.items if value.items is not None else (value,)
204
+ expressions = (
205
+ statement.value.elts
206
+ if isinstance(statement.value, ast.Tuple)
207
+ else [statement.value] * len(parts)
208
+ )
209
+ return tuple(
210
+ self.record(
211
+ expression, f"Return component {index + 1}", part, role="value"
212
+ ).parents
213
+ for index, (part, expression) in enumerate(zip(parts, expressions, strict=True))
214
+ )
215
+ return ()
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class _Value:
220
+ depends: bool = False
221
+ completeness: Completeness = "complete"
222
+ evidence: Confidence = "confirmed"
223
+ kind: Literal["scalar", "string", "collection", "tuple", "regex", "unknown"] = "scalar"
224
+ items: tuple[_Value, ...] = ()
225
+
226
+
227
+ class _Unsupported(Exception):
228
+ def __init__(self, reason: str, *, may_mutate: bool = False) -> None:
229
+ super().__init__(reason)
230
+ self.may_mutate = may_mutate
231
+
232
+
233
+ def _combine(values: tuple[_Value, ...], *, inferred: bool = False) -> _Value:
234
+ complete: Completeness = "complete"
235
+ if any(value.completeness != "complete" for value in values):
236
+ complete = "partial" if any(value.depends for value in values) else "unresolved"
237
+ evidence: Confidence = "inferred" if inferred else "confirmed"
238
+ if any(value.evidence == "unresolved" for value in values):
239
+ evidence = "inferred" if any(value.depends for value in values) else "unresolved"
240
+ elif any(value.evidence == "inferred" for value in values):
241
+ evidence = "inferred"
242
+ return _Value(any(value.depends for value in values), complete, evidence)
243
+
244
+
245
+ class _Summarizer:
246
+ TEXT_METHODS = frozenset(
247
+ {
248
+ "lower",
249
+ "upper",
250
+ "casefold",
251
+ "strip",
252
+ "lstrip",
253
+ "rstrip",
254
+ "replace",
255
+ "removeprefix",
256
+ "removesuffix",
257
+ "capitalize",
258
+ "title",
259
+ "swapcase",
260
+ "split",
261
+ "rsplit",
262
+ "splitlines",
263
+ "join",
264
+ "startswith",
265
+ "endswith",
266
+ "find",
267
+ "rfind",
268
+ "count",
269
+ "isalnum",
270
+ "isalpha",
271
+ "isdigit",
272
+ "isspace",
273
+ }
274
+ )
275
+ REGEX_CALLS = frozenset({"sub", "subn", "findall", "split", "escape", "compile"})
276
+ REGEX_METHODS = frozenset({"sub", "subn", "findall", "split"})
277
+
278
+ def __init__(self, parameter: str, symbols: Mapping[str, str]) -> None:
279
+ self.parameter = parameter
280
+ self.symbols = symbols
281
+ self.env = {parameter: _Value(depends=True)}
282
+ self.local_names: set[str] = {parameter}
283
+ self.inferred = False
284
+
285
+ def _builtin(self, name: str) -> bool:
286
+ return name not in self.local_names and self.symbols.get(name, f"builtins.{name}") == (
287
+ f"builtins.{name}"
288
+ )
289
+
290
+ def _qualified(self, node: ast.AST) -> str | None:
291
+ if isinstance(node, ast.Name) and node.id not in self.local_names:
292
+ return self.symbols.get(node.id)
293
+ if isinstance(node, ast.Attribute):
294
+ base = self._qualified(node.value)
295
+ return f"{base}.{node.attr}" if base else None
296
+ return None
297
+
298
+ def expression(self, node: ast.AST) -> _Value:
299
+ if isinstance(node, ast.Constant):
300
+ return _Value(kind="string" if isinstance(node.value, str) else "scalar")
301
+ if isinstance(node, ast.Name):
302
+ return self.env.get(
303
+ node.id, _Value(completeness="unresolved", evidence="unresolved", kind="unknown")
304
+ )
305
+ if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
306
+ values = tuple(self.expression(item) for item in node.elts)
307
+ combined = _combine(values)
308
+ return _Value(
309
+ combined.depends,
310
+ combined.completeness,
311
+ combined.evidence,
312
+ "tuple" if isinstance(node, ast.Tuple) else "collection",
313
+ values,
314
+ )
315
+ if isinstance(node, ast.Dict):
316
+ values = tuple(
317
+ self.expression(item) for item in (*node.keys, *node.values) if item is not None
318
+ )
319
+ combined = _combine(values)
320
+ return _Value(combined.depends, combined.completeness, combined.evidence, "collection")
321
+ if isinstance(node, ast.BinOp):
322
+ values = (self.expression(node.left), self.expression(node.right))
323
+ combined = _combine(values)
324
+ if any(value.kind in {"collection", "tuple"} for value in values):
325
+ return _Value(combined.depends, combined.completeness, combined.evidence, "unknown")
326
+ return combined
327
+ if isinstance(node, ast.UnaryOp):
328
+ return self.expression(node.operand)
329
+ if isinstance(node, ast.Subscript):
330
+ value = self.expression(node.value)
331
+ index = self.expression(node.slice)
332
+ if value.kind == "tuple" and isinstance(node.slice, ast.Constant):
333
+ position = node.slice.value
334
+ if isinstance(position, int) and -len(value.items) <= position < len(value.items):
335
+ return value.items[position]
336
+ combined = _combine((value, index), inferred=True)
337
+ self.inferred = True
338
+ return combined
339
+ if isinstance(node, ast.Slice):
340
+ return _combine(
341
+ tuple(
342
+ self.expression(item)
343
+ for item in (node.lower, node.upper, node.step)
344
+ if item is not None
345
+ )
346
+ )
347
+ if isinstance(node, ast.JoinedStr):
348
+ combined = _combine(tuple(self.expression(item) for item in node.values), inferred=True)
349
+ self.inferred = True
350
+ return _Value(combined.depends, combined.completeness, combined.evidence, "string")
351
+ if isinstance(node, ast.FormattedValue):
352
+ value = self.expression(node.value)
353
+ if node.format_spec is None:
354
+ return value
355
+ # A format spec is itself an f-string: nested replacement fields
356
+ # may execute calls and must not disappear from the summary.
357
+ return _combine((value, self.expression(node.format_spec)), inferred=True)
358
+ if isinstance(node, ast.Call):
359
+ return self.call(node)
360
+ raise _Unsupported(f"Unsupported callback expression: {type(node).__name__}.")
361
+
362
+ def call(self, node: ast.Call) -> _Value:
363
+ if any(isinstance(arg, ast.Starred) for arg in node.args) or any(
364
+ keyword.arg is None for keyword in node.keywords
365
+ ):
366
+ raise _Unsupported("Dynamic callback arguments are not summarized.", may_mutate=True)
367
+ qualified = self._qualified(node.func)
368
+ method: str | None = None
369
+ receiver: _Value | None = None
370
+ if qualified and (
371
+ qualified in {f"re.{name}" for name in self.REGEX_CALLS}
372
+ or qualified in {"emoji.replace_emoji", "emoji.demojize"}
373
+ ):
374
+ method = qualified
375
+ elif isinstance(node.func, ast.Attribute):
376
+ receiver = self.expression(node.func.value)
377
+ if receiver.kind == "regex" and node.func.attr in self.REGEX_METHODS:
378
+ method = f"regex.{node.func.attr}"
379
+ elif receiver.kind in {"scalar", "string"} and node.func.attr in self.TEXT_METHODS:
380
+ method = f"str.{node.func.attr}"
381
+ elif isinstance(node.func, ast.Name) and node.func.id in {"len", "str"}:
382
+ if self._builtin(node.func.id):
383
+ method = f"builtins.{node.func.id}"
384
+ if method is None:
385
+ raise _Unsupported(
386
+ "Callback calls an unresolved or unsupported callable.", may_mutate=True
387
+ )
388
+ arguments = tuple(self.expression(arg) for arg in node.args) + tuple(
389
+ self.expression(keyword.value) for keyword in node.keywords
390
+ )
391
+ # re.sub and emoji.replace_emoji explicitly accept callable replacements.
392
+ # A trusted library identity does not establish that argument's purity.
393
+ if method in {"re.sub", "re.subn", "regex.sub", "regex.subn", "emoji.replace_emoji"}:
394
+ position = 1 if method.startswith("re.") else 0
395
+ if method == "emoji.replace_emoji":
396
+ position = 1
397
+ replacement = None
398
+ if len(node.args) > position:
399
+ replacement = self.expression(node.args[position])
400
+ keyword_name = "replace" if method.startswith("emoji.") else "repl"
401
+ for keyword in node.keywords:
402
+ if keyword.arg == keyword_name:
403
+ replacement = self.expression(keyword.value)
404
+ if replacement is not None and replacement.kind == "unknown":
405
+ raise _Unsupported(
406
+ "Callback replacement may invoke an unresolved callable.", may_mutate=True
407
+ )
408
+ values = arguments if receiver is None else (receiver, *arguments)
409
+ combined = _combine(values, inferred=True)
410
+ self.inferred = True
411
+ kind: Literal["scalar", "string", "collection", "tuple", "regex", "unknown"] = "string"
412
+ if method == "re.compile":
413
+ kind = "regex"
414
+ elif method.rsplit(".", 1)[-1] in {"findall", "split", "rsplit", "splitlines"}:
415
+ kind = "collection"
416
+ elif method.endswith(".subn"):
417
+ # Its result is a tuple, but its component contracts need a separate rule.
418
+ kind = "unknown"
419
+ return _Value(combined.depends, combined.completeness, combined.evidence, kind)
420
+
421
+ def assign(self, target: ast.AST, value: _Value) -> None:
422
+ if isinstance(target, ast.Name):
423
+ self.env[target.id] = value
424
+ return
425
+ if isinstance(target, (ast.Tuple, ast.List)):
426
+ if value.kind != "tuple" or len(target.elts) != len(value.items):
427
+ raise _Unsupported("Callback unpacking has an unresolved shape.")
428
+ for child, item in zip(target.elts, value.items, strict=True):
429
+ self.assign(child, item)
430
+ return
431
+ raise _Unsupported("Callback writes through an object or subscript.", may_mutate=True)
432
+
433
+ def _guard(self, node: ast.If) -> bool:
434
+ test = node.test
435
+ if not (
436
+ isinstance(test, ast.UnaryOp)
437
+ and isinstance(test.op, ast.Not)
438
+ and isinstance(test.operand, ast.Call)
439
+ ):
440
+ return False
441
+ call = test.operand
442
+ return (
443
+ isinstance(call.func, ast.Name)
444
+ and call.func.id == "isinstance"
445
+ and self._builtin("isinstance")
446
+ and self._builtin("str")
447
+ and len(call.args) == 2
448
+ and not call.keywords
449
+ and isinstance(call.args[0], ast.Name)
450
+ and call.args[0].id == self.parameter
451
+ and isinstance(call.args[1], ast.Name)
452
+ and call.args[1].id == "str"
453
+ and len(node.body) == 1
454
+ and isinstance(node.body[0], ast.Return)
455
+ and not node.orelse
456
+ )
457
+
458
+ def body(self, statements: list[ast.stmt]) -> tuple[_Value, ...]:
459
+ returns: list[_Value] = []
460
+ for statement in statements:
461
+ if isinstance(statement, ast.Assign):
462
+ value = self.expression(statement.value)
463
+ for target in statement.targets:
464
+ self.assign(target, value)
465
+ elif isinstance(statement, ast.AnnAssign):
466
+ if statement.value is not None:
467
+ self.assign(statement.target, self.expression(statement.value))
468
+ elif isinstance(statement, ast.Return):
469
+ returns.append(self.expression(statement.value) if statement.value else _Value())
470
+ break
471
+ elif isinstance(statement, ast.Expr):
472
+ self.expression(statement.value)
473
+ elif isinstance(statement, ast.Pass):
474
+ continue
475
+ elif isinstance(statement, ast.If) and self._guard(statement):
476
+ early = statement.body[0]
477
+ assert isinstance(early, ast.Return)
478
+ result = self.expression(early.value) if early.value else _Value()
479
+ if result.depends or result.completeness != "complete":
480
+ raise _Unsupported("The isinstance fallback must return only local constants.")
481
+ returns.append(result)
482
+ self.env[self.parameter] = _Value(depends=True, kind="string")
483
+ else:
484
+ raise _Unsupported(
485
+ f"Unsupported callback statement: {type(statement).__name__}.",
486
+ may_mutate=True,
487
+ )
488
+ else:
489
+ returns.append(_Value())
490
+ return tuple(returns)
491
+
492
+
493
+ def summarize_callback(
494
+ node: ast.FunctionDef,
495
+ *,
496
+ input_parameter: str | None = None,
497
+ symbols: Mapping[str, str] | None = None,
498
+ ) -> CallbackSummary:
499
+ """Summarize a direct, scalar callback without evaluating any project code.
500
+
501
+ Tuple return components are positionally distinct. Scalar returns have one
502
+ component; dynamic or inconsistent return arity is rejected. ``symbols``
503
+ maps names to statically resolved imports, not guessed module spellings.
504
+ """
505
+ parameters = (*node.args.posonlyargs, *node.args.args)
506
+ if (
507
+ len(parameters) != 1
508
+ or node.args.vararg
509
+ or node.args.kwarg
510
+ or node.args.kwonlyargs
511
+ or node.args.defaults
512
+ or node.decorator_list
513
+ ):
514
+ return CallbackSummary(None, False, False, "Callback requires one plain scalar parameter.")
515
+ parameter = parameters[0].arg
516
+ if input_parameter is not None and parameter != input_parameter:
517
+ return CallbackSummary(None, False, False, "Callback parameter could not be bound.")
518
+ nodes = list(ast.walk(node))
519
+ if len(nodes) > 1000:
520
+ return CallbackSummary(None, False, False, "Callback exceeds the static summary budget.")
521
+ if any(isinstance(item, (ast.Global, ast.Nonlocal)) for item in nodes):
522
+ return CallbackSummary(None, False, True, "Callback declares global or nonlocal state.")
523
+ if any(
524
+ isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef))
525
+ for item in nodes
526
+ if item is not node
527
+ ):
528
+ return CallbackSummary(None, False, False, "Nested callback scopes are not summarized.")
529
+ summarizer = _Summarizer(parameter, symbols or {})
530
+ summarizer.local_names.update(
531
+ item.id for item in nodes if isinstance(item, ast.Name) and isinstance(item.ctx, ast.Store)
532
+ )
533
+ try:
534
+ returned = summarizer.body(node.body)
535
+ except (_Unsupported, RecursionError) as error:
536
+ return CallbackSummary(
537
+ None,
538
+ False,
539
+ error.may_mutate if isinstance(error, _Unsupported) else False,
540
+ str(error)
541
+ if isinstance(error, _Unsupported)
542
+ else "Callback nesting exceeds the budget.",
543
+ )
544
+ shapes = {len(value.items) if value.kind == "tuple" else 1 for value in returned}
545
+ if len(shapes) != 1 or any(value.kind in {"collection", "unknown"} for value in returned):
546
+ return CallbackSummary(
547
+ None, False, False, "Callback return shape is not statically stable."
548
+ )
549
+ arity = shapes.pop()
550
+ rows = tuple(value.items if value.kind == "tuple" else (value,) for value in returned)
551
+ components = tuple(
552
+ CallbackComponent(merged.depends, merged.completeness, merged.evidence)
553
+ for index in range(arity)
554
+ for merged in (_combine(tuple(row[index] for row in rows)),)
555
+ )
556
+ if len(returned) > 1 and any(not component.depends_on_input for component in components):
557
+ return CallbackSummary(
558
+ None,
559
+ False,
560
+ False,
561
+ "Conditional callback components require unsupported control dependencies.",
562
+ )
563
+ reason = (
564
+ "Inferred value dependencies under the bounded scalar-text API contract."
565
+ if summarizer.inferred
566
+ else "Explicit callback return value dependencies."
567
+ )
568
+ history = _CallbackHistory(parameter, symbols or {}, summarizer.local_names)
569
+ outputs = history.body(node.body)
570
+ components = tuple(
571
+ CallbackComponent(
572
+ component.depends_on_input,
573
+ component.completeness,
574
+ component.evidence,
575
+ outputs[index] if index < len(outputs) else (),
576
+ )
577
+ for index, component in enumerate(components)
578
+ )
579
+ return CallbackSummary(components, True, False, reason, tuple(history.steps))