Repository navigation
Signature binary function (e.g. comparison) design #50
Description
There's a recurring need for more control over how we compare and/or merge signatures.
These often involve some logic on parameter objects (with their name, kind, and optionally default and annotation), order, etc.
We'd like to have a clean framework that enables the user to easily and correctly define these functions, reusing much of the common logic.
Examples:
- comparison Boolean operators/functions such as:
sig1 == sig2: equality (more precisely "equivalence relation") of two signaturessig1 <= sig2: comparison (more precisely "total order relation" asissubset,issubclassetc.)
- operators/functions such as signature merging (
sig1 + sig2) - function that gives a score or more complex information about how appropriate it is to feed the function with signature
sig1to an input value of function with signaturesig2
Don't miss this comment that proposes a design.
Notes
Return type of a comparison
Must cases might just need a Boolean, like with x == y or x <= y (or issubset, issubclass, etc.).
But in some cases, such as with self-organizing code (or less crazily: validation scores), we'd like to have a numerical (or more generally, vectorial) score, or more generally, some "comparable" type that can be used to do things like "best match".
Use a key function or a binary comparison function as the specifier?
Might want to express comparisons based on key functions like builtin sorted, max etc. do. What are the pros/cons? It seems like a cleaner design enabling easier UX (and obviously, a big pro is the interface-consistency with familiar builtins). But, for example, is it complete? Are there things we cannot do with key functions that we can with the usual binary operator way (specifying comparison function via a two-element boolean function such as compare(x, y)).
For example, how would comparison scores be derived from key functions? Even if it's possible to "fit" any comparison function with vectorial key functions, would it be the most intuitive way of expressing it?
References
Related issues
- Dag.ch_func and signature comparison
- Enable code_to_dag to use wrapped function signature for the dag signature it produces, which could be a convenient interface for the user to get over the restrictive signature comparison when using
code_to_dag. But that's only forcode_to_dag. Still, the long issue has some reflections about signature comparison. - The right way to parametrize comparison operators, though the subject is about using operators to compare signatures, also has some relevant ideas.
- Need a more powerful and clean signature merging functionality: Indeed, we encounter the signature comparison concern again when we do signature merging (union). There are useful pieces of code around the problem that one could collect to get a better idea of the problem.
- Write a general parameters merging function: an older signature merging issue, possibly duplicate.
- Revise, refactor and robust(ize) function normalization tools: Because signature comparison comes up in the situations described there.
- Rethink what i2.Sig instances should do, or not do also contains some analysis around the subject of signature comparison
- The "Binding changes when using ch_funcs" was perceived as a bug, but wasn't. The clarification points yet once more to the need of proper signature comparison (and error messages for them).
Further notes
The definition of signature equality
Sig doesn't redefine it -- the builtin is used, which boils down to using the following (acts basically as a "key function"):
def _hash_basis(self):
params = tuple(param for param in self.parameters.values()
if param.kind != _KEYWORD_ONLY)
kwo_params = {param.name: param for param in self.parameters.values()
if param.kind == _KEYWORD_ONLY}
return params, kwo_params, self.return_annotation
Activity
sylvainbonnot commentedon Jan 5, 2023
About the question: "Are there things we cannot do with key functions that we can with the usual binary operator way (specifying comparison function via a two-element boolean function such as compare(x, y))?", the answer should be "No" because of the existence of
functools.cmp_to_key(func).Transform an old-style comparison function to a key function. See the doc: doc for cmp_to_key
A comparison function is any callable that accepts two arguments, compares them, and returns a negative number for less-than, zero for equality, or a positive number for greater-than. A key function is a callable that accepts one argument and returns another value to be used as the sort key.
sylvainbonnot commentedon Jan 5, 2023
The code of cmp_to_key is nothing more than:
thorwhalen commentedon Jan 5, 2023
Excellent! That's going to be a valuable thing for simplifying the design!
feat: Experimental work on issue #50
thorwhalen commentedon Jan 13, 2023
I'm not managing to come up with a design that I'm satisfied with.
I'll do some more work on this, but am thinking that, for now, I won't solve the issue as a mini-framework like I was hoping, but rather collect a few useful tools around the issue. (Perhaps, this is the right design I've been looking for.) Here are my thoughts about these.
At the very top level, some contexts will call for a signature binary function (SBF). That is, a
Callable[[Signature, Signature], Any](where;typing.Callableandinspect.Signature). The output is often abool(examplesignatures_are_compatible) or aSignature(example, merge or bust). The first thing we could do for this issue isTask: extract what ever logic is hardcoded in our code and percolate it to the surface (as a function's argument).
By doing this, we're keeping things as they are, while making them open-closed for this issue. A user can always change the behavior by making a different SBF.
From there, there's a temptation to go further, because we'll quickly realize that there's a lot of reuse potential when making these SBFs. I propose we don't go further (until the need calls for it), except perhaps documenting the problem patterns we see, and possibly making functions for these (but without trying to fit these in a framework).
Here are some problem patterns I see:
matching params
Essentially, adding a layer of information (or structuring, annotating) about the params of each of the signatures (and possibly the
return_annotations). For example,meshed.DAGs for example, whose bindings are based on param names, but not (once themeshed.FuncNodes are initialized) on positionkind,defaultandannotation). But here, we can have non-matched params on both sides, which will then (as a separate concern) be incorporated in the final aggregate signature.param binary functions
Several times in the above, we forwarded the problem down to the "pairs of params" level.
This means that there an implicit
Callable[[Parameter, Parameter], Any](param comparison, or more generally, param binary function) going on. The default one is usually strict: That is "values must be equal for each of the fourinspect.Parameterattributes:.name,.kind,.annotation,.default. But here again, this strictness might be in the way of expressivity.Here are some example situations:
kindbecause we're calling our functions withi2.call_forgivinglyanyway, as in aDAGannotations or twodefaults are non-empty (and conflicting) are problematic, because if only one of them is empty, our protocol is to take the value of the non-empty one.param attribute binary functions
Most of the time, our param binary function will really be just an aggregate (e.g.
allfor Booleans, orsumfor scores) of functions applied to each of the four attributes individually. Even here, when we said "equality", we differed to what ever that meant for each of the values of the attributes. There's definitely cases where we want to control that.In fact, probably most of the time it will be through this definition (plus an aggregate function) that we'll define our param binary functions. Here are some examples:
issubclassbe a better idea?The following code (meant to be used as a factory that makes param binary functions through
functool.partial) might do the trick for param binary functions through the definition of functions over their attributes:Note that the same
param_binary_funccan be used to produce precise error messages.The way to do this is to have the attribute binary functions produce some info dicts
that can then be aggregated in aggreg to produce a final error message (or even
a final error object, which can even be raised) if there is indeed a mismatch at all.
Further more, we might want to make a function that will take a parametrized
param_binary_func and produce such a error raising function from it, using the
specific functions (extracted by Sig) to produce the error message.
thorwhalen commentedon Jan 13, 2023
Though "binary function" is more precisely indicates the generality we need here, I'm wondering if we should sinfully just call it "comparator" since:
Cons:
Perhaps we can call it "collator" and "comparison->collation"?
I really insist on finding a non-composite word because things get out of hand when adding other words to it.
Any opinions?
thorwhalen commentedon Jan 20, 2023
Regarding using the parameter comparators to make signature comparators, one way is to do:
graph a(sig1) --> matcher b(sig2) --> matcher matcher --> matches(matches) matcher --> remainder(remainder) matches -- for each match --> param_comparator param_comparator -- collect --> comparison(comparison) comparison --> aggreg remainder --> aggreg aggreg --> output(final output or error)Or something like that.
But, it could also be:
graph sig1 --> cartesian_product sig2 --> cartesian_product cartesian_product -- for each pair --> param_comparator param_comparator --> comparisons_matrix(comparisons_matrix) comparisons_matrix --> reduction reduction --> result(result)The latter is cleaner, but quite wasteful, and maybe harder to express some simple cases (our most common cases).