Skip to content

fix(hashing): stable canonical names for orcapod types in function-pod signature hashing (ITL-638) - #262

Merged
eywalker merged 23 commits into
mainfrom
eywalker/itl-638-function-pod-signature-hashing-uses-full-type-import-paths
Sep 3, 2026
Merged

fix(hashing): stable canonical names for orcapod types in function-pod signature hashing (ITL-638)#262
eywalker merged 23 commits into
mainfrom
eywalker/itl-638-function-pod-signature-hashing-uses-full-type-import-paths

Conversation

@kurodo3

@kurodo3 kurodo3 Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes ITL-638: function pod signature hashing encoded orcapod logical types by their fully-qualified import path (type:orcapod.logical_types.file_type.File), so any internal module reorganisation silently invalidated every cached result for functions annotated with op.File, op.Directory, etc.

Root causes (two surfaces)

  1. TypeObjectHandler serialised type objects as "type:{module}.{qualname}" with no registry lookup.
  2. FunctionSignatureExtractor had two asymmetric problems:
    • Params: annotation strings were baked in via str(param)inspect.formatannotation, embedding the full import path.
    • Returns: the raw type object was stored as-is, then serialised by TypeObjectHandler — full path again.

Fix (Approach B)

  • canonical_annotation_str in hash_utils.py — extended with an optional registry parameter. Registered types resolve to their logical_type_name (e.g. "orcapod.file"); generic aliases and unions recurse; everything else falls back to inspect.formatannotation, preserving existing behaviour exactly.

  • TypeObjectHandler in builtin_handlers.py — accepts an optional logical_type_registry; lazy-falls-back to get_default_context().type_converter._logical_type_registry (safe getattr access). Registered types now emit "type:orcapod.file" instead of the full path.

  • FunctionSignatureExtractor in function_info_extractors.py — accepts the same optional logical_type_registry. Params: all annotation substrings are now replaced with canonical_annotation_str(annotation, registry) (not just unions). Returns: stores a canonical string only when the return type contains a registered orcapod type; keeps the raw type object for builtins and user types to avoid invalidating existing hashes.

  • register_builtin_python_type_handlers — new logical_type_registry parameter forwarded to both TypeObjectHandler and FunctionSignatureExtractor. Callers that omit it get the same lazy-fallback behaviour as before.

Regression tests

  • tests/test_hashing/test_type_annotation_golden.py — pre-fix golden values captured in hash_samples/type_annotation_golden.json; diff assertions verify builtins are unchanged and orcapod types changed to canonical names.
  • tests/test_hashing/test_hash_utils.py — 12 new tests for canonical_annotation_str with registry.
  • tests/test_hashing/test_semantic_hasher.py — 6 new tests for TypeObjectHandler with registry including a relocation-stability test.
  • tests/test_hashing/test_function_info_extractors.py — 8 new tests in TestFunctionSignatureExtractorWithRegistry covering params, returns, generics, unions, relocation stability.

Backward compatibility

One-time cache invalidation for any cached function pod signature that contains op.File, op.Directory, op.Path, or op.UUID. Pre-v0.1.0 — no deprecation wrappers per CLAUDE.md.

Test plan

  • uv run pytest tests/test_hashing/ — 666 passed, 31 skipped
  • uv run pytest --ignore=tests/test_hashing/ — 4141 passed, 62 skipped, 2 xfailed

Closes ITL-638

kurodo3 Bot and others added 12 commits September 2, 2026 17:07
…hing (ITL-638)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…anonicalization (ITL-638)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ule (ITL-638)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…logical types (ITL-638)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ignatureExtractor (ITL-638)

- Add optional logical_type_registry constructor arg with lazy fallback
  to default context (same pattern as TypeObjectHandler)
- Extend param annotation replacement to cover all annotation types
  (not just unions): uses canonical_annotation_str(annotation, registry)
  and replaces only when the canonical form differs from formatannotation
- For return annotations: store canonical string only when the annotation
  contains a registered logical type; otherwise keep raw type object to
  avoid invalidating existing cached hashes for builtin return types
- Add _annotation_contains_registered_type helper for the return-type
  guard, recursing through unions and generic aliases
- All 28 TestFunctionSignatureExtractor and TestFunctionSignatureExtractorWithRegistry
  tests pass; golden-diff tests confirm builtins unchanged and orcapod
  types now use stable logical_type_name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…python_type_handlers (ITL-638)

Add optional logical_type_registry parameter to
register_builtin_python_type_handlers and forward it to both
TypeObjectHandler and FunctionSignatureExtractor at construction time.
When None (the default), both handlers retain their existing lazy
fallback to get_default_context() — no behaviour change for callers
that don't pass the argument. Explicit injection is now available for
test isolation and future custom-context wiring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.00000% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/orcapod/hashing/hash_utils.py 93.33% 1 Missing ⚠️
...shing/semantic_hashing/function_info_extractors.py 96.42% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

following the same pattern as ``ArrowTableHandler``.
"""

def __init__(self, logical_type_registry: Any = None) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do not use Any here -- specifically type it to the type registry or rather type converter as logical type registry is something that's hidden within the type converter

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Changed the parameter in TypeObjectHandler.__init__ to type_converter: TypeConverterProtocol | None. We also added get_logical_type(python_type: type) -> LogicalTypeProtocol | None to TypeConverterProtocol (mirroring the method that already existed on UniversalTypeConverter) so the handler can look up the canonical name without ever touching the registry object directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The parameter was changed from logical_type_registry: Any to type_converter: TypeConverterProtocol | None — the registry is an implementation detail hidden inside TypeConverter, so the handler accepts the converter itself and calls type_converter.get_logical_type(obj) to resolve canonical names.

def _get_registry(self) -> Any:
if self._logical_type_registry is not None:
return self._logical_type_registry
from orcapod.contexts import get_default_context

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use proper typing and do not default to grabbing default context. Aside from directly user-facing code, get_default_context should never be invoked and rather, one should be passed an explicit context object

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed entirely. The lazy get_default_context() fallback has been deleted from both TypeObjectHandler and FunctionSignatureExtractor. Both now require explicit injection — TypeObjectHandler(type_converter=...) and FunctionSignatureExtractor(type_converter=...). The v0.1.json context spec now wires the type_converter ref directly into both handlers, so the default context wiring is explicit rather than implicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The lazy _get_registry() fallback that called get_default_context().logical_type_registry has been deleted entirely. TypeObjectHandler now requires explicit injection: TypeObjectHandler(type_converter=tc). The default context wires it in via v0.1.json, so user-facing code remains unchanged.

return self._logical_type_registry
from orcapod.contexts import get_default_context
ctx = get_default_context()
return getattr(ctx.type_converter, "_logical_type_registry", None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do not every use private property _logical_type_registry of type_converter. If there is any property/method that needs to access knowlege of logical type registry, this must be exposed via type_converter

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. get_logical_type(python_type: type) -> LogicalTypeProtocol | None was already implemented on UniversalTypeConverter and is now added to TypeConverterProtocol as a proper public method. TypeObjectHandler calls type_converter.get_logical_type(obj) directly; canonical_annotation_str and _annotation_contains_registered_type also use it via the converter rather than touching the registry. No code outside of UniversalTypeConverter itself accesses _logical_type_registry anymore.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. get_logical_type(python_type: type) -> LogicalTypeProtocol | None was added to TypeConverterProtocol (the method already existed on UniversalTypeConverter). All logical-type lookups in the hashing layer go through this public method — _logical_type_registry is never accessed directly by callers.

kurodo3 Bot added 2 commits September 2, 2026 20:02
…ical values (ITL-638)

Adds TestSchemaHashStability to test_type_annotation_golden.py alongside a new
hash_samples/schema_hash_golden.json fixture. Every schema hash — including schemas
containing op.File, op.Directory, op.Path — must remain stable after future refactors.
…remove direct registry access (ITL-638)

Address PR review feedback:
- Add get_logical_type(python_type) -> LogicalTypeProtocol | None to TypeConverterProtocol,
  mirroring the already-existing UniversalTypeConverter method.
- TypeObjectHandler and FunctionSignatureExtractor now accept type_converter: TypeConverterProtocol | None
  instead of Any; no lazy get_default_context() fallback; no private _logical_type_registry access.
- canonical_annotation_str and _annotation_contains_registered_type take TypeConverterProtocol | None
  instead of LogicalTypeRegistry, resolving types via get_logical_type().
- v0.1.json wires type_converter ref into TypeObjectHandler and FunctionSignatureExtractor configs.
- Tests updated to use type_converter fixture / get_logical_type() throughout.
@kurodo3

kurodo3 Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Review round 2 — changes summary

All three comments addressed in commit b2becea.

No more Any typingTypeObjectHandler and FunctionSignatureExtractor now take type_converter: TypeConverterProtocol | None instead of logical_type_registry: Any. register_builtin_python_type_handlers likewise takes type_converter: TypeConverterProtocol | None.

No more get_default_context() in internals — The lazy-fallback _get_registry() methods that called get_default_context() have been deleted from both handlers. Explicit injection is now the only path. The v0.1.json spec wires {"_ref": "type_converter"} into both TypeObjectHandler and FunctionSignatureExtractor configs.

No more private _logical_type_registry accessget_logical_type(python_type: type) -> LogicalTypeProtocol | None has been added to TypeConverterProtocol (the method already existed on UniversalTypeConverter). All logical-type lookups in the hashing layer go through this public method. canonical_annotation_str in hash_utils.py and _annotation_contains_registered_type in function_info_extractors.py both accept TypeConverterProtocol | None and call get_logical_type() directly — the registry object is never exposed to callers.

All 677 tests pass (0 failures, 31 skipped).

args = getattr(annotation, "__args__", ()) or ()
return any(_annotation_contains_registered_type(a, type_converter) for a in args)
# Generic aliases (list[X], dict[K, V], etc.): check args
origin = getattr(annotation, "__origin__", None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't this implicitly assuming that the origin part cannot be the type that is "registered type"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. The generic-alias branch was only iterating over __args__ and skipping __origin__ entirely — so if a registered type appeared as the origin of a generic alias, the function would silently return False. Fixed by also recursing into origin before checking args, consistent with how canonical_annotation_str already handles it. Added test_registered_type_as_generic_origin_detected to exercise this path directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: after fixing the origin check, we re-examined whether _annotation_contains_registered_type was the right approach at all. It wasn't — the whole string-conversion branch for return annotations was unnecessary and created an inconsistency: registered return types were stored as plain strings (e.g. 'orcapod.file') losing the 'type:' prefix, while unregistered types remained as type objects and went through TypeObjectHandler (producing 'type:builtins.float').

The fix: _annotation_contains_registered_type has been deleted entirely. FunctionSignatureExtractor now always stores parts['returns'] = ret_ann (the raw annotation object), exactly as it did originally. TypeObjectHandler — already updated with type_converter — handles canonicalisation at hash time uniformly for all return types, registered or not. No special casing in the extractor.

…r generic aliases (ITL-638)

The generic-alias branch previously only checked __args__, silently skipping
__origin__. If a registered type appeared as the origin of a generic alias,
_annotation_contains_registered_type would incorrectly return False and the
return annotation would not be stored as a canonical string.

Now mirrors canonical_annotation_str which already recurses into origin.
Added test_registered_type_as_generic_origin_detected to exercise this path.
@kurodo3

kurodo3 Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Review round 3 — changes summary

Single fix in commit 2088e70.

Bug: _annotation_contains_registered_type was only checking __args__ in the generic-alias branch, silently skipping __origin__. If a registered type appeared as the origin of a generic alias, the function would return False and the return annotation would be kept as a raw type object rather than canonicalized — a correctness gap.

Fix: Now also recurses into origin before checking args, making the logic consistent with canonical_annotation_str which already called canonical_annotation_str(origin, type_converter).

Test added: test_registered_type_as_generic_origin_detected in test_function_info_extractors.py constructs a fake generic alias with a registered orcapod type as __origin__ and unregistered builtins as __args__, asserting the function returns True.

…ler canonicalises at hash time

Remove `_annotation_contains_registered_type` and the branch that stored
registered return types as plain strings.  `parts["returns"]` now always
holds the raw annotation object, regardless of whether it is an orcapod
logical type.  `TypeObjectHandler` (already updated with `type_converter`)
canonicalises it to `"type:orcapod.file"` etc. at hash time, so the
`"type:"` prefix is consistently present for all return annotations.

This fixes an inconsistency introduced during the ITL-638 fix where
registered types in return position were serialised as `"orcapod.file"`
(no `"type:"` prefix) while unregistered types went through
TypeObjectHandler and produced `"type:builtins.float"`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@kurodo3

kurodo3 Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Summary of latest changes (e4189ba)

This commit removes the _annotation_contains_registered_type helper and simplifies return-annotation handling in FunctionSignatureExtractor.

What changed and why:

During review of the round-3 fix (checking __origin__ in _annotation_contains_registered_type), we noticed a deeper inconsistency: the branch that detected registered types in return annotations was storing them as plain strings (e.g. "orcapod.file"), bypassing TypeObjectHandler and losing the "type:" prefix. Meanwhile, unregistered return types (e.g. float) still went through TypeObjectHandler, producing "type:builtins.float". The two code paths were inconsistent.

The correct approach (now implemented):

  • FunctionSignatureExtractor always stores parts["returns"] = ret_ann — the raw annotation object, exactly as the original code did.
  • TypeObjectHandler (already updated with type_converter in previous commits) canonicalises it at hash time, uniformly for all return types.
  • _annotation_contains_registered_type is deleted; it had no other callers.

What is unchanged:

  • Parameter annotations still go through canonical_annotation_str(annotation, type_converter) to replace the annotation substring in str(param) — this is correct because param annotations are embedded in strings, not handled by the type-handler registry.
  • All 668 tests pass.

…er a string

Add test_return_annotation_is_never_a_string covering 8 annotation shapes
(bare registered type, union with None, Optional, multi-type union, generic
alias, and builtins) — each explicitly asserts parts["returns"] is not a
str.  This catches the specific regression where registered return types
were stored as plain strings (e.g. "orcapod.file"), bypassing TypeObjectHandler
and losing the "type:" prefix.

Also strengthen the two existing single-case return tests with explicit
not-isinstance-str guards, and import types/typing at the module level
so they are available throughout the test file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are confirmed correctness/maintainability issues in the diff (unused import likely to break lint, and multiple docstrings/spec sections referencing non-existent APIs/behavior) that should be fixed before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR fixes ITL-638 by making semantic hashing of function-pod type annotations stable across internal module relocations, using canonical logical type names (via the type converter’s logical-type registry) instead of fully-qualified import paths.

Changes:

  • Extend canonical_annotation_str to canonicalize registered logical types (and recurse through unions/generic aliases) using TypeConverterProtocol.get_logical_type(...).
  • Update TypeObjectHandler and FunctionSignatureExtractor to use the type converter for canonical type identity during hashing (params canonicalized at extraction; returns remain raw and are canonicalized by TypeObjectHandler at hash time).
  • Add golden/regression tests and committed golden fixtures to ensure builtins remain unchanged while registered orcapod logical types switch to canonical names.
File summaries
File Description
tests/test_hashing/test_type_annotation_golden.py Adds golden-diff regression tests ensuring only orcapod logical types change hashing behavior.
tests/test_hashing/test_semantic_hasher.py Adds coverage for registry-aware TypeObjectHandler, including simulated module relocation stability.
tests/test_hashing/test_hash_utils.py Adds tests for canonical_annotation_str(..., type_converter) covering registry resolution + recursion through unions/generics.
tests/test_hashing/test_function_info_extractors.py Adds coverage that params are canonicalized while returns remain raw objects (hashed via TypeObjectHandler).
tests/test_hashing/hash_samples/type_annotation_golden.json Stores pre-fix golden hashes for annotations/functions to detect unintended changes.
tests/test_hashing/hash_samples/schema_hash_golden.json Stores post-fix stable schema hashes to lock in canonical behavior going forward.
tests/test_hashing/generate_type_annotation_golden.py Adds a script to generate the pre-fix golden fixture.
superpowers/specs/2026-09-02-itl-638-stable-type-annotation-hashing-design.md Design doc for the change (currently contains return-annotation details that conflict with implementation).
superpowers/plans/2026-09-02-itl-638-stable-type-annotation-hashing.md Implementation plan artifact for ITL-638.
src/orcapod/protocols/semantic_types_protocols.py Extends TypeConverterProtocol with get_logical_type(...) for registry-backed canonicalization.
src/orcapod/hashing/semantic_hashing/function_info_extractors.py Canonicalizes parameter annotation substrings using canonical_annotation_str(..., type_converter); keeps return annotations as raw objects.
src/orcapod/hashing/semantic_hashing/builtin_handlers.py Updates TypeObjectHandler to emit type:<logical_type_name> when resolvable via the type converter; threads type_converter through registration/default context wiring.
src/orcapod/hashing/hash_utils.py Extends canonical_annotation_str with type_converter support + recursion through unions/generic aliases.
src/orcapod/contexts/data/v0.1.json Wires type_converter into the configured FunctionSignatureExtractor and TypeObjectHandler in the default context.
Review details

Suppressed comments (2)

src/orcapod/hashing/semantic_hashing/builtin_handlers.py:475

  • register_builtin_python_type_handlers claims canonical type-name resolution happens via get_logical_type_registry(), but the code path uses TypeConverterProtocol.get_logical_type(...). Adjust the docstring to reflect the actual API so downstream implementers of TypeConverterProtocol know which method is required.
        type_converter: Optional ``TypeConverterProtocol`` forwarded to
            ``TypeObjectHandler`` and ``FunctionSignatureExtractor`` for stable
            canonical type-name resolution via ``get_logical_type_registry()``.
            When ``None`` (the default), both handlers fall back to the raw
            ``"type:<module>.<qualname>"`` serialisation.

superpowers/specs/2026-09-02-itl-638-stable-type-annotation-hashing-design.md:165

  • The concrete "After" example shows "returns": "orcapod.directory", but the implementation in this PR keeps return annotations as raw objects (op.Directory) and leaves canonicalisation to TypeObjectHandler at hash time. Update the example to avoid documenting behavior that doesn't exist.
# After
{
    "module": "mymodule",
    "name": "fn",
    "params": "f: orcapod.file, n: int",   # canonical name; int unchanged
    "returns": "orcapod.directory",         # canonical string, same path as params
}
  • Files reviewed: 14/14 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +93 to +98
Args:
type_converter: Optional ``TypeConverterProtocol``. When provided,
``type_converter.get_logical_type_registry()`` is called to resolve
registered logical types to their stable canonical name. When
``None`` (the default), the fallback ``"type:<module>.<qualname>"``
serialisation is always used.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in both locations. TypeObjectHandler docstring now says get_logical_type(obj) and register_builtin_python_type_handlers docstring says get_logical_type() — matching the actual call in handle().

Comment thread src/orcapod/hashing/semantic_hashing/function_info_extractors.py Outdated
Comment on lines +127 to +141
**Return annotation** — was stored as raw type object (`parts["returns"] = sig.return_annotation`),
then handled by `TypeObjectHandler` at hash time. Now stored as canonical string upfront,
consistent with how params are handled:

```python
# Before
parts["returns"] = sig.return_annotation # raw type object

# After
parts["returns"] = canonical_annotation_str(sig.return_annotation, registry) # canonical string
```

This eliminates the asymmetry and means `TypeObjectHandler` no longer participates in the
return annotation path (params and returns are both plain strings by the time the hasher sees
them).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed throughout the spec. The return-annotation section now describes the actual behavior: parts['returns'] stays as a raw type object, and TypeObjectHandler canonicalises it at hash time via type_converter.get_logical_type(). The 'After' concrete example, the scope description, the goals statement, and the Dependencies & Risks note about 'returns becoming a string' are all updated to match the implementation.

…o match implementation

- Remove unused `is_union_annotation` import from function_info_extractors.py
  (the simplification that dropped _annotation_contains_registered_type left it
  behind; Ruff F401 would flag it)
- Fix TypeObjectHandler docstring: says get_logical_type_registry() but the
  actual call is type_converter.get_logical_type(obj)
- Fix register_builtin_python_type_handlers docstring: same stale method name
- Update spec to match shipped behavior: return annotations remain raw type
  objects (not canonical strings); TypeObjectHandler canonicalises at hash time.
  Corrects the "After" code example, scope description, goals statement, and
  the Dependencies & Risks note that said "returns" becomes a string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@kurodo3

kurodo3 Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Review round summary (795cfe9)

Three issues raised by Copilot, all fixed in a single commit:

Issue Fix
Unused is_union_annotation import in function_info_extractors.py (F401) Removed from import line
TypeObjectHandler docstring references non-existent get_logical_type_registry() Updated to get_logical_type(obj)
register_builtin_python_type_handlers docstring same stale method name Updated to get_logical_type()
Spec describes return annotations becoming strings; implementation keeps them as raw objects Updated 5 sections of the spec: goals, scope, return-annotation code block, concrete 'After' example, and the Dependencies & Risks note

No logic changes — docs and imports only. All 669 tests still pass.

# only). The ": " prefix avoids accidentally replacing the
# annotation string inside a default value.
param_str = param_str.replace(f": {old_ann}", f": {new_ann}", 1)
if not self.include_defaults and "=" in param_str:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than replacing string, wouldn't it better for the annotation per parameter to be replaced cleanly and then reconstruct the full function string rather than doing substitution? Technically string substitution can accidentally replace part of the function string that it's not meant to replace

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Replaced the str(param).replace() / split('=') approach with _format_param(), a module-level helper that reconstructs each parameter string directly from inspect.Parameter's structured attributes: name, kind, canonical annotation, and default.

This eliminates both failure modes you identified:

  1. Annotations containing '=' (e.g. Literal["a=b"]) were being corrupted by split('=')[0] when include_defaults=False — now we check param.default is not inspect.Parameter.empty directly.
  2. A default value whose repr contained ': ' as a substring could have caused unexpected matches — now annotation and default are assembled independently, never searched/replaced.

Output format is identical to str(inspect.Parameter) for all normal inputs, so no hash change for existing signatures. Two regression tests added to verify both failure modes are caught.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — done. Replaced the and approach with a new helper that reconstructs each parameter string from structured inspect.Parameter attributes. No string search/replace anywhere in the param building path.

The helper uses param.kind, param.name, param.default, and the pre-computed canonical_annotation directly, so:

  • The annotation and default value are assembled as separate parts — a default whose repr contains ': <annotation>' cannot accidentally trigger substitution.
  • Stripping defaults uses param.default is not inspect.Parameter.empty, not split('='), so annotations containing = (e.g. Literal["a=b"]) are preserved correctly.

Output is identical to str(inspect.Parameter) for all real-world inputs, so no existing hash values change.

…of string substitution

Replace the str(param).replace(':old_ann', ':new_ann') approach in
FunctionSignatureExtractor with _format_param(), a helper that builds each
parameter string directly from inspect.Parameter's structured attributes
(name, kind, canonical annotation, default).

This eliminates two failure modes of the substitution approach:
1. Annotations that contain '=' (e.g. Literal["a=b"]) were silently
   truncated by the split("=")[0] used when include_defaults=False.
2. If a default value's repr contained ': <annotation>' as a substring,
   the replace() could produce unexpected results.

Output format is identical to str(inspect.Parameter) for all normal inputs,
so there is no hash change for existing function signatures.

Two regression tests added to TestFunctionSignatureExtractor:
- test_annotation_containing_equals_preserved_when_defaults_stripped
- test_default_value_repr_containing_annotation_substring_not_duplicated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@kurodo3

kurodo3 Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Review round summary (e3c0c14)

Comment: String substitution in param building can accidentally replace the wrong text.

Fix: Replaced str(param).replace(':old_ann', ':new_ann', 1) and split('=')[0] with a new _format_param() helper that reconstructs each parameter string from structured inspect.Parameter attributes — no string search/replace anywhere in the param building path.

Two actual bugs fixed in the process:

Bug Old behavior New behavior
Annotation containing = (e.g. Literal["a=b"]) with include_defaults=False split('=')[0] truncated annotation to Literal["a Correctly strips only param.default, leaving annotation intact
Default value whose repr contains ': <annotation>' Could produce unexpected substitution Annotation and default assembled from separate attributes, never searched

Output format is identical to str(inspect.Parameter) for all real-world inputs — no hash change for existing function signatures.

Two regression tests added to TestFunctionSignatureExtractor:

  • test_annotation_containing_equals_preserved_when_defaults_stripped
  • test_default_value_repr_containing_annotation_substring_not_duplicated

kurodo3 Bot and others added 2 commits September 2, 2026 21:53
…rgs and keyword-only tests

The previous test_default_value_repr_containing_annotation_substring_not_duplicated
claimed to catch a bug with old string-substitution but actually passed with both
old and new code — the count=1 guard in replace() was sufficient for that case.
Replace it with two tests that exercise real _format_param code paths:

- test_varargs_and_kwargs_with_annotations: verifies VAR_POSITIONAL and
  VAR_KEYWORD parameters get the correct '*' / '**' prefix
- test_keyword_only_param_formatted_without_star_prefix: verifies KEYWORD_ONLY
  params do not get a '*' prefix (the bare '*' separator is a signature-level
  token, not a parameter attribute)

Also tighten the docstring on test_annotation_containing_equals_preserved_when_
defaults_stripped to explicitly state it fails with the old implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ality

Replace loose `in`/`not in` substring checks with exact `==` assertions
throughout TestFunctionSignatureExtractor and TestFunctionSignatureExtractorWithRegistry.
The one exception is `test_annotation_containing_equals_preserved_when_defaults_stripped`,
which must stay compound because `from __future__ import annotations` causes
`eval_str=True` to fall back when `Literal` is not in the function's global scope,
wrapping the annotation string in an extra layer of quotes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@kurodo3

kurodo3 Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Review round summary (bca94e0)

Tightened test assertions to exact equality per review feedback that checking string phrases with in is too loose.

What changed

All in/not in substring checks in TestFunctionSignatureExtractor and TestFunctionSignatureExtractorWithRegistry have been replaced with exact == assertions against the full expected params string:

Test Before After
test_include_defaults_false_strips_defaults "x: int" in result["params"] etc. result["params"] == "x: int, y: str"
test_include_defaults_true_keeps_defaults substring check result["params"] == "x: int = 42"
test_varargs_and_kwargs_with_annotations substring check result["params"] == "*args: int, **kwargs: str"
test_keyword_only_param_formatted_without_star_prefix substring check result["params"] == "a: int, b: str"
test_union_annotation_canonicalized substring check `result["params"] == "x: pathlib.Path
test_param_annotation_is_canonical_string substring check info["params"] == "f: orcapod.file"
test_generic_param_annotation_canonical substring check info["params"] == "files: list[orcapod.file]"
test_builtin_annotations_unchanged substring check info["params"] == "x: int, y: str"
test_param_canonical_string_and_return_type_object substring check info_param["params"] == "f: orcapod.file"

One deliberate exception

test_annotation_containing_equals_preserved_when_defaults_stripped remains a compound check rather than exact equality. This is because from __future__ import annotations at the top of the test file causes inspect.signature(fn, eval_str=True) to try resolving Literal["a=b"] in the function's __globals__ — but Literal is only imported inside the test method's local scope, so the fallback is triggered and formatannotation wraps the annotation string in an extra layer of quotes. An exact assertion would be asserting a CPython fallback artifact rather than the intended behavior. The compound check still verifies the two invariants that matter: the full annotation is present and the default value is absent.

All 32 tests pass.

pathlib.Path and uuid.UUID are registered orcapod logical types (op.Path and
op.UUID respectively), so they should be canonicalized to 'orcapod.path' and
'orcapod.uuid' in param annotations when the registry is wired in. Add three
tests to TestFunctionSignatureExtractorWithRegistry covering:
- bare pathlib.Path param → 'orcapod.path'
- bare uuid.UUID param → 'orcapod.uuid'
- str | Path union param → 'orcapod.path | str' (sorting preserved post-canonicalization)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@kurodo3

kurodo3 Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Review round summary (6ec4aad)

Added missing test coverage for and canonicalization.

is and is — both registered orcapod logical types. With the registry wired in, param annotations using these types should be canonicalized to their logical type names, not their module paths. This was working correctly (the implementation already handled it via ) but was never explicitly tested.

Three new tests in :

Test Assertion
p: pathlib.Path"p: orcapod.path"
u: uuid.UUID"u: orcapod.uuid"
str | Path"x: orcapod.path | str" (sorting preserved post-canonicalization)

Note: the existing test_union_annotation_canonicalized (no registry) correctly shows "pathlib.Path | str" — that's the expected output when no type_converter is wired in.

…ition

Add two groups of tests:

1. test_function_info_extractors.py — test_path_return_annotation_type_object_hashes_to_canonical:
   Two-step round-trip: extractor stores raw pathlib.Path type object in
   parts["returns"], then TypeObjectHandler.handle() produces "type:orcapod.path"
   (not the old "type:pathlib.Path" module-path form).

2. test_semantic_hasher.py — TestTypeObjectHandlerWithRegistry:
   Add test_registered_path_returns_canonical_name and
   test_registered_uuid_returns_canonical_name alongside the existing File/Directory
   cases, covering all four orcapod logical types registered over stdlib classes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@kurodo3

kurodo3 Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Review round summary (c043922)

Added pathlib.Path and uuid.UUID in the return position.

Two groups of new tests:

test_function_info_extractors.pytest_path_return_annotation_type_object_hashes_to_canonical

Two-step round-trip covering the full chain for pathlib.Path in return position:

  1. Extractor stores the raw pathlib.Path type object in parts["returns"] (not a string — verified with is Path)
  2. TypeObjectHandler(type_converter=tc).handle(returns_val, hasher) produces "type:orcapod.path" — the canonical form — not "type:pathlib.Path" (the old module-path form)

test_semantic_hasher.pyTestTypeObjectHandlerWithRegistry

Two new cases alongside the existing op.File / op.Directory coverage, completing all four orcapod logical types that are registered over stdlib classes:

  • pathlib.Path"type:orcapod.path"
  • uuid.UUID"type:orcapod.uuid"

@kurodo3

kurodo3 Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Review round — thread reply catch-up

Three eywalker comment threads from the first CHANGES_REQUESTED review (2026-09-02) had not received individual replies, only coverage in the batch summary. Replied to all three now:

Thread Comment Reply summary
builtin_handlers.py:96 Do not use Any Parameter renamed to type_converter: TypeConverterProtocol | None; registry stays hidden inside the converter
builtin_handlers.py:102 Do not grab default context Lazy _get_registry() fallback deleted; explicit injection required
builtin_handlers.py:104 Do not access _logical_type_registry get_logical_type() added to TypeConverterProtocol; no private access anywhere

All code changes were already in place from commit b2beceaa (round 2). This round is thread-reply only — no code changes.

@eywalker
eywalker merged commit 7dbd0b6 into main Sep 3, 2026
11 checks passed
@eywalker
eywalker deleted the eywalker/itl-638-function-pod-signature-hashing-uses-full-type-import-paths branch September 3, 2026 01:26
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.

2 participants