Normalizes map keys at the tool boundary.
Two related concerns live here:
Hyphen → underscore key normalization (
normalize_keys/1,normalize_key/1). PTC-Lisp uses Clojure conventions where LLMs naturally write hyphenated keywords (e.g.,:was-improved). Elixir/JSON conventions use underscores. This is a one-way conversion applied at the LLM-output boundary.Canonical cache key construction (
canonical_cache_key/2). A deterministic, layer-agnostic cache key so native app-tool calls and PTC-Lisp(tool/...)calls share cache entries regardless of how the args arrived. See the function docstring for the full rule list.
Summary
Functions
Build a deterministic cache key shared between native app-tool calls and
PTC-Lisp (tool/...) calls.
Normalize a single key from hyphen to underscore format.
Recursively normalize map keys from hyphens to underscores.
Functions
Build a deterministic cache key shared between native app-tool calls and
PTC-Lisp (tool/...) calls.
Returns {tool_name, normalized_args} where normalized_args is the
recursive canonical form of args. Two semantically equivalent inputs
(different insertion order, atom vs string keys, integer-equal floats vs
integers) produce keys that compare equal with ==.
This function intentionally widens equivalence classes vs naive
{tool_name, args} keying. It is the single source of truth for cache
identity across Tier 2b native calls and PTC-Lisp's (tool/...) cache
path; both layers reach the same cache entry whenever the call is
semantically identical.
Normalization rules
Applied recursively to every value in args:
- Map keys — converted to strings AND hyphens normalized to
underscores (
:foo→"foo",:"was-improved"→"was_improved"). Atom keys and string keys collapse to the same canonical form, and hyphenated and underscored keys collapse together — matching the PTC-Lispstringify_key/1boundary normalization ineval.ex. - Maps — Elixir maps are structurally compared regardless of
insertion order, so two maps with the same string-keyed entries
produced from differently-ordered inputs are
==. - Numbers — integer-equal floats collapse to integers
(
1.0→1,2.0e0→2,0.0→0). Non-integer floats stay floats (1.5stays1.5). NaN and infinity are out of scope: they pass through unchanged becausetrunc/1raises on them; do not pass them in via tool args. - Lists — recurse into elements; order is preserved.
- Tuples — converted to lists for parity with PTC-Lisp, where the
vector literal
[1 2]evaluates to a list. A native cache write using a tuple{1, 2}and a PTC-Lisp lookup using[1 2]collapse to the same key. (Spec previously said "preserve tuples"; PTC-Lisp parity wins.) - Strings, booleans,
nil, atoms (other than nil/true/false) — unchanged for values. Atom-keyed maps are converted by rule 1; atom values stay atoms.
Non-map args (Tier 3.5 Fix 3b)
When args is not a map (e.g., a list or scalar from a misbehaving
tool plumbing path), the result is {tool_name, {:non_map, args}}.
This is chaos-resilient: rather than crash with FunctionClauseError
the cache layer produces a deterministic key. Two equal non-map args
share the same key; cache hits remain possible even on the off-spec
shape.
Examples
iex> PtcRunner.SubAgent.KeyNormalizer.canonical_cache_key("search", %{q: "x"})
{"search", %{"q" => "x"}}
# Atom and string keys converge.
iex> a = PtcRunner.SubAgent.KeyNormalizer.canonical_cache_key("t", %{foo: 1})
iex> b = PtcRunner.SubAgent.KeyNormalizer.canonical_cache_key("t", %{"foo" => 1})
iex> a == b
true
# Hyphenated and underscored keys converge (PTC-Lisp parity).
iex> a = PtcRunner.SubAgent.KeyNormalizer.canonical_cache_key("t", %{"was-improved" => true})
iex> b = PtcRunner.SubAgent.KeyNormalizer.canonical_cache_key("t", %{"was_improved" => true})
iex> a == b
true
# Integer-equal floats collapse to integers.
iex> PtcRunner.SubAgent.KeyNormalizer.canonical_cache_key("t", %{n: 1.0})
{"t", %{"n" => 1}}
# Non-integer floats stay floats.
iex> PtcRunner.SubAgent.KeyNormalizer.canonical_cache_key("t", %{n: 1.5})
{"t", %{"n" => 1.5}}
# Nested maps and lists recurse.
iex> PtcRunner.SubAgent.KeyNormalizer.canonical_cache_key("t", %{xs: [%{a: 1.0}, %{a: 2.0}]})
{"t", %{"xs" => [%{"a" => 1}, %{"a" => 2}]}}
# Tuples canonicalize to lists for PTC-Lisp parity.
iex> PtcRunner.SubAgent.KeyNormalizer.canonical_cache_key("t", %{p: {1, 2}})
{"t", %{"p" => [1, 2]}}
# Non-map args wrap in a `{:non_map, args}` sentinel rather than crash.
iex> PtcRunner.SubAgent.KeyNormalizer.canonical_cache_key("t", [1, 2, 3])
{"t", {:non_map, [1, 2, 3]}}
Normalize a single key from hyphen to underscore format.
Examples
iex> PtcRunner.SubAgent.KeyNormalizer.normalize_key(:"was-improved")
"was_improved"
iex> PtcRunner.SubAgent.KeyNormalizer.normalize_key("foo-bar")
"foo_bar"
iex> PtcRunner.SubAgent.KeyNormalizer.normalize_key(:no_hyphens)
"no_hyphens"
Recursively normalize map keys from hyphens to underscores.
Converts Clojure-style :was-improved to Elixir-style "was_improved".
Works recursively on nested maps and lists.
Examples
iex> PtcRunner.SubAgent.KeyNormalizer.normalize_keys(%{"was-improved" => true})
%{"was_improved" => true}
iex> PtcRunner.SubAgent.KeyNormalizer.normalize_keys(%{nested: %{"foo-bar" => 1}})
%{"nested" => %{"foo_bar" => 1}}
iex> PtcRunner.SubAgent.KeyNormalizer.normalize_keys([%{"list-item" => 1}])
[%{"list_item" => 1}]