swarm-game / swarm

Resource gathering + programming game
Other
834 stars 53 forks source link

Handle requirements properly as part of the type system #231

Open byorgey opened 2 years ago

byorgey commented 2 years ago

Describe the bug It seems that programs run by base are only checked dynamically for capabilities. This means that both

  1. The base can get quite a ways through a program before crashing due to a missing capability
  2. Some capabilities sneak by since they don't correspond directly to a command and are thus never dynamically checked. For example, recursion is such a capability.

To Reproduce

xsebek commented 2 years ago

Yup, I was wondering if this was intended, but didn't ask, since it makes getting the first lambda harder :sweat_smile:

byorgey commented 2 years ago

Why does it make getting the first lambda harder?

xsebek commented 2 years ago

It can be hidden behind a lake/mountain and you have to make a program that goes around. :mountain:

Most cases can be solved with "move in one direction, move in other direction and then go back", but that "go back" can be error prone so I used the fetchNW&friends functions. I will just have to write out the first fetch, I guess, no big deal :wink:

byorgey commented 2 years ago

So I was trying to work on this today, and ran into what seems like a difficult issue. I put a basic check in place and it immediately started rejecting things like build "x" {move}, because the base does not have treads. Of course this is nonsense: it is x that will be needing treads, not base. But right now capability checking does not treat build specially at all: if it sees an application it simply returns the union of the capabilities required by the left- and right-hand sides of the application.

Clearly, capability checking does need to treat build specially, but I am unsure how to do this in a principled way. We could specially check things which are syntactically arguments to build, but that wouldn't be compositional, i.e. it wouldn't properly deal with custom variants of build. For example, we might define

build2 : string -> {cmd ()} -> cmd ()
build2 name p = build name p; build name p

and now base should be able to execute build2 "x" {move}.

Note higher-order functions make this even trickier. For example, consider a generalization of the above example:

twice : cmd () -> cmd ()
twice c = c ; c

thrice : cmd () -> cmd ()
thrice c = c ; c ; c

buildN : (cmd () -> cmd ()) -> string -> {cmd ()} -> cmd ()
buildN ice name p = ice (build name p)

build2 : string -> {cmd ()} -> cmd ()
build2 = buildN twice

Base should still be able to execute build2 "x" {move} but it now seems harder to figure this out. This example is overly convoluted, of course, but I think it is very realistic to expect that people will write this kind of code.

I'm guessing this means we have to bite the bullet and make capability checking more principled / fine-grained. I will put some thought into what might be required, but happy to hear any ideas and/or pointers to literature (I'm quite sure others have already done similar things).

byorgey commented 2 years ago

Hmm, what if it's as simple as having capability checking return a stack of capability sets instead of a single set? The top of the stack represents capabilities required to evaluate it now. The next thing in the stack represents capabilities needed for things buried inside one level of delay, which would become required after application of force. The next thing down represents capabilities needed for things nested two levels deep, and so on.

I wanted to write this down since it seems interesting, but I am actually not at all convinced that it will allow dealing with higher-order functions.

byorgey commented 2 years ago

More generally, suppose that for each Swarm type tau there is a corresponding (Haskell) type of capability analyses, C(tau).

byorgey commented 2 years ago

I have thought about this some more, and I seem to be coming to a few different (interrelated) conclusions.

  1. Capabilities should (must?) really be part of types, not a separate analysis done later. i.e. we really have an effect system built into our type system.
    • However, hopefully we can infer all the capability stuff and never make users write it.
  2. In addition to the challenge of when capabilities are required for delayed things, partial application is in the mix too. For example, what capabilities are required to evaluate b?
    b : {cmd ()} -> cmd ()
    b = build "x"

    I would argue none should be required: a 3D printer should only be required once build is fully applied.

  3. Instead of carrying around stacks, it might be enough to annotate each argument type with a natural number indicating the level at which it is used (level n = inside n delays). I am not sure about that yet though.

I am now envisioning something where types can be annotated with a set of capabilities, and function arrows are annotated with levels, or something like that... I'll keep banging away at it.

byorgey commented 2 years ago

I have a very rough sketch of a type system on the base-caps branch. But I'm getting kind of sick of it so I'm going to switch to working on something else for a while. Maybe a simpler approach will suggest itself.

byorgey commented 2 years ago

I did a bunch more work on the base-caps branch, but it's still incomplete---I got sick of it again. Need to get back to it at some point. The basic idea is that any type representing some kind of delay (function, delay, or command) carry a capability set annotation, and our basic judgment is now of the form Gamma |- t : sigma ; Delta meaning "In context Gamma, t has type sigma, and its evaluation requires capabilities Delta". We also need capability set polymorphism. See https://github.com/swarm-game/swarm/blob/bef7acb28b2f742cf891a59c9d1997491ecd128a/docs/ott/swarm.pdf .

Checking this involves generating capability set inequalities with both literal capabilities and capability set variables:

(20:33) <   byorgey> Given a set of such subset constraints, we need to find an assignment of a capability set to each variable that satisfies all the constraints, ideally "minimal" capability sets (though I don't imagine the solutions will be unique in general).
(20:34) <   byorgey> In other words we need a solver for... sets of inequalities over a free idempotent commutative monoid?  I think that's the technical way to describe it.
(20:35) <   byorgey> Just wondering if anyone has seen anything like this before.  Though I think it shouldn't be too hard to work out.
(20:39) <   byorgey> I'm imagining some kind of fixed point iteration where we initialize all the variables to the empty set; then on each iteration we look at each inequality and see if there are any capabilities that show up on the LHS but not the RHS.  If so, we add them to the RHS by adding them to one of the 
                     variables on the RHS (and if there aren't any variables on the RHS then signal an error).
(20:40) <   byorgey> But if there are multiple variables on the RHS we have a choice of which one to modify.  I'm not sure how to think about generating a "best" assignment, or what that would even mean, or how much it matters.
byorgey commented 1 year ago

Wanted to update this with my latest thinking on a unified effect system for capabilities/requirements since it has changed quite a bit since the most recent comments above.

xsebek commented 1 year ago

If you have a recursive function that calls build, how can we tell how many times build will end up being called, and thus how many copies of the relevant devices we will need?

@byorgey I would argue an infinite amount should suffice 😄

byorgey commented 1 year ago

I would argue an infinite amount should suffice

Well, yes, an infinite amount would certainly suffice, but that would not be a useful answer, because then it would be impossible to run any recursive functions that call build unless you are in creative mode.

But thinking about it a bit more, perhaps that is indeed the right answer, coupled with some kind of escape mechanism whereby you can say "I know the requirements system cannot prove that this call is safe, but I want to run it anyway". Put another way, given infinite counts, we could design the requirements analysis so that it is always conservative/safe, but recognize that in some cases (especially recursive calls) we will need to provide an escape hatch to allow running things where the analysis is too conservative.

xsebek commented 1 year ago

@byorgey it could also be a challenge or a normal item providing unlimited supply. 🙂

I like the idea of a message saying it calculated an unbounded item requirements. 👍

byorgey commented 1 year ago

it could also be a challenge or a normal item providing unlimited supply.

Yes, that's true, I just meant that in general you cannot count on having an infinite amount of something, e.g. if you write a recursive function while playing the classic game.

byorgey commented 3 months ago

Another example program that ought to work but currently doesn't:

def pr = require 1 "rock"; move; place "rock" end
def x4 = \c. c;c;c;c end
build {turn east; x4 pr}

This ought to build a robot equipped with four rocks. Currently, it is equipped with only one rock, which it places and then crashes when it tries to place the next one.

I know exactly why this happens: when checking the requirements of x4 pr we just check the requirements of x4 and the requirements of pr and then union them, which is not correct of course. We need to infer a type for x4 which is something like forall (a : Type) (r : Reqs). Cmd r a -> Cmd (4r) a. (I have made up this notation, and I'm not suggesting we should show such types to the user by default, but hopefully the basic idea is clear.)

xsebek commented 3 months ago

@byorgey could you please add that as a failing test case? 🙂