Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a freeze primop and stdlib wrapper #2113

Merged
merged 1 commit into from
Dec 6, 2024
Merged

Conversation

yannham
Copy link
Member

@yannham yannham commented Nov 29, 2024

Following a confusing behavior around the interaction of recursive fields and dictionary operations reported in #1866 (insert, remove, update), it was proposed in #1877 to freeze recursive records before applying dictionary operations.

This PR adds a primitive operation to do so, which applies pending contracts, flushes them, and remove any dependency data, effectively freezing recursive fields to their current value.

A flag is also added to record attributes, in order to remember that some record has already been frozen. Indeed, freezing is linear in the number of fields and pending contracts: freezing at every dictionary operation thus could be costly. Instead, as for closurization, we do it once and then remember that it's been done in flag. It helps dictionary operations preserve frozenness. Only merging or lazy contract applications might require freezing again.

What's missing to close #1877 is to just add a call to %record/freeze% in preamble of every dictionary operations. This will be addressed in a follow-up PR.

@yannham yannham requested a review from jneem November 29, 2024 11:47
Copy link
Contributor

github-actions bot commented Nov 29, 2024

Following a confusing behavior around the interaction of recursive
fields and dictionary operations (insert, remove, update), it was
proposed in #1877 to freeze
recursive records before applying dictionary operations.

This commit adds a primitive operation to do so, which applies pending
contracts, flushes them, and remove any dependency data, effectively
freezing recursive fields to their current value.

A flag is also added to record attributes, in order to remember that
some record has already been frozen. Indeed, freezing is linear in the
number of fields and pending contracts: freezing at every dictionary
operation thus could be costly. Instead, as for closurization, we do it
once and then remember that it's been done in flag. It helps dictionary
operations preserve frozenness. Only merging or lazy contract
applications might require freezing again.
@yannham yannham force-pushed the feat/record-freezing branch from c66126d to f441c48 Compare December 4, 2024 23:12
// It's not clear what the semantics of freezing a record with a sealed tail
// would be, as their might be dependencies between the sealed part and the
// unsealed part. Merging is disallowed on records with tail, so we disallow
// freezing as well.
Copy link
Member

Choose a reason for hiding this comment

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

Could this raise a back-compat issue once the std.record functions are updated to freeze? I didn't succeed to construct an example that calls std.record.remove on a record with a sealed tail, but maybe it's possible?

Copy link
Member Author

Choose a reason for hiding this comment

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

I think it's hard to build without referring to primops directly. On paper, you might say that stuff like insert_foo | forall r. {; r} -> { foo : Number; r } or remove_bar | forall a r. {bar: a; r} -> {; r} looks reasonable, but to write those using the stdlib you'd need to cast back the result of dictionary operations - since they operate on dictionary types - but you can't name r, so you can't fill the blank in std.record.insert "foo" 5 record | {foo : Number; ????}.

Granted, it's entirely accidental (due to the absence of something like scoped type variables), but the consequence is that you can't write today without resorting to %record/insert%, and the idea is to not touch the latter, just add freezing in the stdlib wrappers.

Even if we had scoped type variables, a second issue is that stdlib dictionary functions use dictionary types for their input and output. The corresponding contract maps over its argument, but mapping isn't permitted either on a record with a sealed tail:

nickel> let f | forall r. {foo : Number; r} -> {_ : Number} = fun x => x
nickel> f {foo = 1, bar = 2}
error: contract broken by a function
       : cannot map over a record sealed by a polymorphic contract
  ┌─ <repl-input-0>:1:19
  │
1 │ let f | forall r. {foo : Number; r} -> {_ : Number} = fun x => x
  │                   ----------------- expected type of an argument of an inner call
  │
  ┌─ <repl-input-2>:1:3
  │
1 │ f {foo = 1, bar = 2}
  │   ------------------ evaluated to this expression

So right now, it seems that dictionaries are pretty incompatible with polymorphic tails anyway. That being said, we could ask if those maybe should be well-typed / possible to write. Let's say we have scoped type variables or whatnot to make that possible. I think the question of the semantics remain: what should we do upon freezing a record with a sealed tail?

  1. We only freeze the non-sealed part. For the caller, it means that you call to some polymorphic function on records, and what you get back is that some of your recursive dependencies are erased, but not all. I think that's really hard to reason about and error-prone.
  2. We look under the tail and remove dependencies there as well. We might need to do that recursively, as tails can be nested. This is a bit more reasonable for the caller, but it means that the sacred tail isn't sacred anymore, as it can be touched indirectly by freezing.
  3. We refuse to freeze polymorphic tails, as in the current PR.

Another idea that came from a discussion with @sjoerdvisscher is that maybe this idea of either being in dictionary mode or not should be embodied by types/contracts, and not chosen by functions individually. This would have the merit of making it clear from the interface of a function if freezing happens, and freezing would be exerted by the dictionary type {_ : T}. I haven't yet thought about this alternative enough to be sure, but in any case this concerns a follow-up PR. The present PR is about introducing the freezing operation itself; it's not yet used anywhere.

@yannham yannham added this pull request to the merge queue Dec 6, 2024
Merged via the queue into master with commit 17686e0 Dec 6, 2024
5 checks passed
@yannham yannham deleted the feat/record-freezing branch December 6, 2024 16:26
@solson
Copy link

solson commented Dec 6, 2024

What's missing to close #1877 is to [...]

@yannham I think GitHub saw close #1877 and ironically took that as an instruction to auto-close it, which I think was not intended.

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.

Proposal: freeze recursive records upon insert/remove/update/map
3 participants