Feedback on a malleable operating system

Hi, I’m Chase :wave:

I’m part of a small team that’s working on a new kind of operating system which embodies many of the properties discussed in this forum. Here’s how we describe it:

Pallas is an event sourced, purely functional operating system, called an operating function.

Pallas provides an entirely unique set of features, but is inspired by a long history of systems and language research, including MIT’s exokernel. Pallas collapses the distinction between database, virtual machine, and language platform, creating a stable, simple, and extensible programming environment.

The cost to this design is a clean break with legacy systems, including Unix and most popular programming languages. In return, Pallas offers a set of features that are found nowhere else:

  • All application data is automatically persisted, without the need for imports or boilerplate. To create a database, you write a pure function. Open system calls are included in persisted state, and so are resumed on reboot.
  • Partially applied functions can be serialized and stored on-disk, or sent over the wire. Programs in mid-execution can be paused, moved to a new machine, and resumed with no impact.
  • Program execution can be parallelized via a process model. A single machine can spawn and manage multiple processes.
  • The system’s default language blends features from both Lisp and Haskell, with a syntax that is more readable and flexible than S-expressions without sacrificing regularity or homoiconicity. Metaprogramming capabilities include hot reload, zero-overhead virtualization, macro-based type systems, all the way up to custom compilers.
  • Data and code is deduplicated, merkleized, and stored in content-addressable memory pages. This creates a global referentially-transparent content store, which is naturally complemented by protocols like BitTorrent.

The foundation of Pallas is untyped, but conceptually we can say that a database is a function of the type

type DB = Input -> (Output, DB)

If a user supplies such a function, the Pallas runtime will create a database using a snapshot-and-event-log system. The user can write their programs as if they were keeping their data “in memory”, without any need for manual persistence or other forms of cache management.

The recursive part of the type above might seem strange. You can think of it almost as a normal stateful function:

type OtherDB = (State, Input) -> (State, Output)

The difference is that instead of changing the state value, the recursive version would change itself. The current version of the function is the state. In other words: programs can upgrade themselves dynamically. Code can construct code. Because of this, we can put an entire compiler toolchain inside the system and the programs it generates have zero dependencies on the outside world.

Pallas is open-source, but before we start sharing it publicly, we’ve been trying to find communities of engineers and researchers that can provide private feedback. The model is sufficiently different from the mainstream that we’re concerned about confusing first impressions, so we’re holding ourselves to a high bar by running “UX” tests on the repo and documentation site.

We would really love the opportunity to conduct a live onboarding session with anyone that’s interested. They generally last for about 90 minutes and do require screensharing. I have an open calendar available here.

If you’re interested in this idea, but not in a call, we’re aiming for a finalized first version of the repo and docs within a couple weeks. I’ll update this thread with more info when it’s available.


Edit 8/26/2024

6 Likes

Your system certainly sounds interesting in theory! Could you tell us a bit more about it? In particular:

  • What kind of physical or virtual computer does it run on?
  • What are the application domains you are envisaging?
  • How far does your “clean break with legacy systems” go? Is there any way to interface to today’s computing world, e.g. by accessing file systems, Web APIs, etc.?
1 Like

This sounds interesting.

At first blush it seems like an operating system based on Unison. Or the same principles, at least.

1 Like

Sure! Thanks for the questions.

  • What kind of physical or virtual computer does it run on?

Pallas runs as a VM right now. There’s a more complete prototype Haskell runtime and a less complete—but minimal dependency—C runtime. We have prebuilt binaries for Mac and Linux and you can build a dev environment with Nix. Although we currently assume an underlying OS, the system has been designed to eventually run on something like a hypervisor.

  • What are the application domains you are envisaging?

We’re targeting cloud environments right now. If I had to give a pithy description of the project, it would be something like, “a personal cloud computer that your grandma could use.” That doesn’t quite tell the entire story (we won’t be trapped in the cloud forever), but it’s currently the most complete part of the system.

Pallas is not well-suited for most high performance use cases today. For example, we wouldn’t recommend using it as a backend for a multiplayer game (though we have prototyped a small MUD). The places where we think it will be most immediately useful:

  • CRUD apps
  • Messaging applications, including running relays in federated architectures
  • Personal data storage (photos, images, documents, etc.)
  • Hosting personal websites or blogs
  • As a syncing layer for local-first apps
  • Content aggregation (say from RSS feeds or social media)
  • Content streaming

The Haskell runtime pretty happily handles 50+ GBs of storage and we have a clear path for increasing that to many terabytes.

  • How far does your “clean break with legacy systems” go? Is there any way to interface to today’s computing world, e.g. by accessing file systems, Web APIs, etc.?

Yes, there’s an HTTP server, as well as other “hardware devices.” We don’t have plans to expose direct access to file systems.

The system standardizes two things:

  1. An evaluation model called PLAN (see below)
  2. A set of abstract hardware interfaces

By hardware I mean things like time, network, http, etc. The specifics of the implementation are left up to the runtime though. From inside the system, the programmer just has a single uniform interface.

PLAN
The entire system is bootstrapped from a small combinator interpreter called PLAN. It’s basically just lambda calculus, restricted to a type of lambda term with no free variables (called a supercombinator). It is concrete, concise, and relatively readable, considering it’s essentially a compiler binary.

It’s also fast to compile to and easy to map back and forth between memory and disk - which is how you get a single-level store that essentially makes no distinction between in-memory and on-disk. You can unplug it while it’s running, move it to another physical machine, turn it back on and it picks up right where it left off.

Formally, it looks like this:

PLAN ::= <PLAN>           # Pin
       | {Nat Nat PLAN}   # Law
       | (PLAN PLAN)      # App
       | Nat              # Nat

Where a Nat is a natural number and a Law is a user-defined function. () / App denotes function application, {} / Law is a list of values and <> / Pin is a sort of runtime hint that has to do with optimizing memory layout.

This is both the entire data model of our system and anything that users write using this system.

I mention this because one of the ways in which we do intend to be compatible with legacy systems is as a compiler target for other functional languages. For example, a language like Elm could compile to PLAN.

3 Likes

Yea, Unison is awesome!

There are definitely similar ideas shared by both projects, though we’re starting from the goal of designing an operating system, and Unison seems to have started more from an insight about code representation.

But “like an operating system based on Unison” isn’t a bad mental model to hold.

3 Likes

I wanted to follow up on my first post with a link to our repo and documentation:

We received a bunch of useful feedback over the last couple weeks and are working on updates. The repo is the best place to start for now. If you venture into the docs, be aware that our reference material is incomplete and there will be inconsistencies in architecture descriptions. For example, we found that “operating system” is confusing and are now using “application platform”, but the docs still use the old term.

Feedback of any kind is deeply appreciated and I’ll do my best to respond within 24 hours.

3 Likes

This is very interesting! It sounds much like the sort of design I’ve been dreaming about for several years now, but couldn’t quite get my head around. The concept of everything being functions that reduce to (output, function) pairs sounds correct. Will definitely be taking a look to see what PLAN is and how it works.

Edit: Hmm. A lot of “TODOs” there. I’m still a bit baffled by PLAN and what, for example, a “Pin” or a “Law” are (and um, just how PLAN relates to the entire rest of computer science: it’s got a rather strong smell of Urbit about it). Couldn’t the core innards just be a Lisp? It feels like this is something Lisp-shaped, except that it doesn’t define its data types. Is a Law a tuple, an array or a list? What do its parts represent? And what about Supercombinators? Why is it especially hard to store and transmit a closure with variables, anyway - can’t that just be a Let block?

1 Like

Hey Nate, thanks for checking it out! Sorry for the lack luster info around PLAN—it’s one of the weaker parts of the documentation right now (though if you read through some of the implementations, you can get a better sense of the design). We’re working on another big docs refresh right now. If you’re comfortable with Lisp or JS, maybe check out this recent implementation by Jared Forsyth: GitHub - jaredly/planjs

I’ll try to answer some of your initial questions here. We also have a telegram channel that you can join—PLAN has been a major topic of conversation recently:

I’m still a bit baffled by PLAN and what, for example, a “Pin” or a “Law” are

A pin is conceptually a node in a merkle DAG. It’s a hint to the runtime system that tells it to hash whatever value is inside the pin. You can think about it as a memory page where the value inside the pin is stored in a contiguous region in memory (and on disk) and is addressed by its hash.

Pins are very similar to the GHC concept of “Compact Regions”, except that each region is immutable, and regions can reference other regions, forming a DAG. They solve the following problems:

  • Pins give users control over data layout.
  • Pins make full-database normalization tractable.
  • Pins make garbage-collection fast, even with massive heaps.

A law is a user defined function. They accept a fixed number of arguments and return a single value. Laws can’t contain nested laws, but can reference other laws as constant values. Functions with nested laws can be compiled into laws using lambda lifting.

A law {n a b} has a name n, arity a and body b. In practice, this will likely be implemented as a tuple, but calling it a tuple obscures the fact that this is a native data structure which is distinct from apps, even though both could be viewed as tuples. (Apps are closures, and are in practice stored as arrays rather than nested tuples, to minimize pointer chasing.) There is a native opcode which allows code inside the system to inspect any value and choose different branches depending on whether it’s a pin, law, app or nat

Law bodies are encoded using partially applied primops:

|---------|--------------------|
| pattern | meaning            |
|---------|--------------------|
| (0 f x) | (f x)              |
| (1 v b) | let v = b          |
| (2 k)   | const k            |
| 0       | self               |
| 1       | var 1              |
| 2       | var 2              |
| x       | const x (fallback) |
|---------|--------------------|

There are four built-in operations, indicated by the first four natural
numbers.

  • (0 n a b): Construct a new law: {n a b}

  • (1 p l a n x): Pattern match on the value x (is it a pin, a law,
    an app, or a nat?).

    case x of
        PIN <i>     -> p i
        LAW {n,a,b} -> l n a b
        APP (f x)   -> a f x
        NAT x       -> n x
    
  • (2 z p x): Pattern match on a natural number (is it zero or (n+1)?).

    case x of
        0 -> z
        x -> p (x-1)
    
  • (3 x): Increment a natural number.

  • (4 x): Normalize x and construct a new pin <x>.

how PLAN relates to the entire rest of computer science

PLAN is a (lazy) purely functional combinator VM with a very small specification. Some of the core ideas can be traced back to Hughes’ paper describing the use of supercombinators in applicative languages. EYG has some similar goals to PLAN: design a very minimal AST that can be frozen and used as a language platform.

it’s got a rather strong smell of Urbit about it

Comparing PLAN to Urbit’s Nock is reasonable. Nock is more minimal than PLAN because it only uses the “noun” data structure (a noun is either a nat or a pair of nats). Nock prefers formal simplicity over implementational simplicity and makes it extremely difficult to write a performant runtime. In particular, the semantics of Nock’s first reduction rule (name dereferencing) has an atrociously high L1 cache miss rate (in the general case, the runtime has to follow O(log a) pointers).

Like Nock, PLAN includes nats and pairs of nats (which we call an “app”), but adds user-defined functions (a “law”) and pins, which I briefly described above. This results in constant time dereferencing and greatly simplifies runtime implementations. Happy to say more about how that’s achieved if you’d like.

Pallas also uses a “jet” system, but that’s not an Urbit invention (e.g., see David Barbour’s Awelon or Blockstream’s Simplicity).

That’s where the similarities end. We don’t share any of the strange language choices made by Urbit, we’re not opposed to meta-programming or modern type systems, we have no baked in hierarchical naming system, and no one is interested in global monarchy. Urbit is an art project; Pallas is not.

Couldn’t the core innards just be a Lisp? It feels like this is something Lisp-shaped, except that it doesn’t define its data types.

I’m not sure what you mean when you say that it doesn’t define its data types, could you elaborate? PLAN is very arguably a Lisp, though one which is designed to be as minimal as possible, easy to map onto hardware, and perhaps most importantly, without implicit state or magical values. There is no scoping, no symbols and no opaque terms. For example:

$ nix-shell -p chez
$ petite
Petite Chez Scheme Version 9.5.8
Copyright 1984-2022 Cisco Systems, Inc.

> (car '(a b))
a
> (car (lambda (x) (add x 2)))
Exception in car: #<procedure> is not a pair
Type (debug) to enter the debugger.
>

What is this #<procedure> thing? If Lisp was fully reflective you would expect functions be encoded as a bunch of cons cells, no? This isn’t just a nitpick because it means you can’t send all values across a network interface. You can’t write a function value (or better yet, a partially applied function value), send it across a socket and have the other side execute it.

Along the same lines, the environment means you have hidden state which affects how functions execute. This is hidden state:

> (define (op x) (+ x 5))
> (define (doit x) (op x))
> (doit 2)
7
> (define (op x) (+ x 10))
> (doit 2)
12
>

We have changed what is essentially a global variable and have changed how the function executes. What would it mean to serialize doit and send it across the wire for remote execution, when there’s an implicit dependency on an environment?

Most Lisps are filled with built-ins which are not well specified. Here are a couple examples:

> -
#<procedure ->
> (car -)
Exception in car: #<procedure -> is not a pair
Type (debug) to enter the debugger.
>
> `#(1 2 3)
#(1 2 3)
> (car `#(1 2 3))
Exception in car: #(1 2 3) is not a pair
Type (debug) to enter the debugger.
>

Vector doesn’t have a canonical form as a list if you try to inspect them. This vector data type is a primitive and does not have a representation in the unenriched lambda calculus.

PLAN, by comparison, encodes function application with arities on top of binary trees of natural numbers. You can compile the lambda calculus to PLAN just by lambda lifting. Everything in the system is a binary tree value, but we can still achieve high performance by using the binary tree formalism to define primitive functions like subtract—or data structures like vectors with memory locality—instead of relying on extra built-ins.

Why is it especially hard to store and transmit a closure with variables, anyway - can’t that just be a Let block?

When you suggest encoding a closure with variables encoded in a let block, I presume you mean as a quoted piece of code? Since deconstructing an actual procedure is not possible, you can’t turn those into serializable values. You could of course require the user to write all their functions as quoted ASTs without free variables, but this doesn’t strike me as particularly ergonomic. Especially not if you want a compiler to dynamically generate functions which may not have any corresponding source code.

2 Likes

Hi Chase. Thanks for the detailed reply, I’m starting to get more of a picture of what PLAN might be. It seems less of a Lisp and more of a pure Lambda Calculus system.

(Which is okay, except that I personally have a particular fondness for Lisp because of its Lists as opposed to its Lambdas. The Lists were there before the Lambdas were - in IPL Information Processing Language - Wikipedia - and the importance of Lists as the primary construct is what’s missing from the entire ML derived family of functional programming thought, which owe much more to the compiler-centric Algol way of thinking about things, where source code is considered an almost existentially different class of stuff from the contents of a computer’s executing memory. As opposed to Lisp where everything in the entire computing universe, inside or outside the computer, source code or object code, is literally just sequences of other sequences, and a sequence is not always a function but a function is a certain kind of sequence which happens to generate other sequences: which I think is a cosmic-level revelation about universe-as-parseable-text and something that ML/Haskell haven’t yet fully digested. That’s my personal prejudice, anyway, which may be too narrow but colours my approach to every functional programming system.)

So with my Lisp-vs-ML prejudice out of the way:

A pin is conceptually a node in a merkle DAG. It’s a hint to the runtime system that tells it to hash whatever value is inside the pin. You can think about it as a memory page where the value inside the pin is stored in a contiguous region in memory (and on disk) and is addressed by its hash.

Ok, that makes sense. I’ve been wondering what would be the best way to go about assigning secure hashes (SHA-256/512 etc) to something small like a function or cons or object, and it seemed to me that chunking lots of small items into a sequentially allocated “page” like construct would have to be the way of doing it; we need to make sure that the amount of data associated to a large (32 or 64-byte) identifier is enough to absorb the inefficiency of the identifier and the work of hashing. So a hard-coded operation to do all of this in the VM would be logical. That would also make sure that the programmer can control when and where the work of normalising and hashing takes place.

A law is a user defined function. They accept a fixed number of arguments and return a single value. Laws can’t contain nested laws, but can reference other laws as constant values.

Ok so a “law” is roughly a Lambda, but :

  1. it uses de Bruijn indexes (numbers) and not names for the parameters, so that it doesn’t need a symbol table, okay, that’s reasonable to make a lambda easier to normalise

  2. however, every definition of a lambda always associates a name with it? So it’s what in a Lisp would be a lambda wrapped inside a let? In which symbol table is this name bound, how is that table defined, how is it processed? And you can’t ever define an anonymous lambda?

  3. what does “Laws can’t contain nested laws, but can reference other laws as constant values” actually mean? Can a law/lambda create a new lambda and return it as a value, or not? Can it reference a parameter/argument passed to or a variable defined by a “parent” lambda/law ? (ie what I woud consider a “closure” in Scheme or Javascript) Or is it restricted to only being able to refer to its own immediate arguments?

Is this actually Lambda Calculus , or is it a restricted form of it, and if so, what’s the implication of the “no nested” restriction?

Oh yeah - it’s also a lazy VM, so applying a function to an argument presumably doesn’t immediately reduce to a value but creates an “application” object… which is cool, but at what point does actual reduction take place?

const k

By “const” I assume you maybe mean what in other programming languages would be called a “literal” rather than a “constant”?

Since an immutable function language presumably only has variable bindings of the kind that are considered “constants” in other languages).

Pattern match on a natural number (is it zero or (n+1)?).

Hmm, so your built-ins don’t include silicon-friendly mathematics as in Uxn (ie add/sub/mul/div/bit-operations), but do at least include “increment” and a combined “decrement and test for zero” operation, which is quite cute and I like it.

Only having natural numbers is probably very nice for mathematicians, but raises the question of: How exactly is the ordinary programmer going to represent integers, floats, strings, etc? Do all of these have to be lambdas of some kind? If a function is passed a lambda, how does it know what kind of thing it represents? Although PLAN is basically untyped lambda calculus (or perhaps a very restricted subset of lambda calculus, see above?) extended with literal natural numbers, is practical usage of it going to need strict typing, because there’s no way at runtime to “unpick” a lambda/law/app and find out what class/kind/type of object it is?

Your choice of “natural number” as the lowest concrete kind of value in the system… again is interesting to mathematicans, perhaps, but I’m not sure exactly how it’s “hardware friendly”. No computer hardware gives us raw natural numbers - it gives us unsigned integers, yes, but in fixed word sizes. If the VM has to manage unsigned ints of arbitrarily large sizes… that might potentially cause problems? What happens if a piece of source code off the Internet tries to allocate a single natural number whose representation in RAM takes say, 5 gigabytes? 5 terabytes? 5 petabytes? Can a VM ever throw up its hands and say “sorry, number too big” or does doing that mean it’s failed to correctly implement the spec?

We don’t share any of the strange language choices made by Urbit, we’re not opposed to meta-programming or modern type systems, we have no baked in hierarchical naming system, and no one is interested in global monarchy. Urbit is an art project; Pallas is not.

That’s good.

I’m not sure what you mean when you say that it doesn’t define its data types, could you elaborate?

So what I’m referring to is that, in a definition of a VM, I like for the base constructs to be fairly concrete, so an implementation can be tested as to whether it’s compliant or not. And in a programming language, I like for “data literal” to be a thing that’s a defined part of the language (and therefore of the VM and of the calculus that the VM implements). And further, because a thing I like to do a lot on my computer is to store and process and retrieve sets of data, I like for the “data literals” that my VM processes to include some kind of structured type. While I’m aware that my computer deals fundamentally with bytes grouped into words grouped into sequential pages of RAM and disk… I’d like for my VM to give me some way of talking about chunks of data that include numbers, strings, and some kind of structure (preferably not more than one or two of those kinds: Lisp’s conses and Javascripts numbers/strings/arrays/objects sound about the right order of magnitude to me, with Javascript being a few too many base data types).

PLAN appears to define one data literal (“natural number”) of apparently arbitary size, plus lambdas, applications, and pins - but no data-structure types like “cons/pair”, “sequence”, “array”, “Unicode string” etc. If I have, say, an array of strings or numbers or object-like things to store… do I have to store it as a single massive “natural number”? Or as a lambda/law? I feel like those are two extremes: it would be nice to have something more like “typed data object” that’s a little bit in the middle.

there is no scoping, no symbols and no opaque terms.

Hmm. Let me unpick this argument, because I agree strongly with some of what you state here, but query some of it.

What is this #<procedure> thing? If Lisp was fully reflective you would expect functions be encoded as a bunch of cons cells, no?

So: Yes, in principle, I very strongly agree that non-cons objects like “compiled procedures” and “vectors” that exist in a lot of modern Lisps, are extremely poor and we can do much better. Yes, non-cons things can’t be sent across a network interface, and even conses need a lot of preprocessing to avoid loops and/or express the distinction between two conses with the same contents but different ids, and that’s very bad. Yes, I’d like a Lisp-like thing that was built entirely out of conses (or something similarly small) and took that that restriction seriously.

However: There does exist at least one modern Lisp that does have this quality! PicoLisp really does build absolutely everything out of cons cells. Everything. Including symbols and procedures. So, it is possible. PicoLisp does a number of odd things that I don’t like (it’s dynamically bound, for instance, and extremely not-immutable). But it’s an example proof of one, that there could be a Lisp family tree that really stuck close to “cons is everything”.

You can’t write a function value (or better yet, a partially applied function value), send it across a socket and have the other side execute it.

Yes, in fact in PicoLisp you can do exactly this, and that’s how its HTML server and GUI library works. Probably this causes heaps of security problems, but yep. At least I think so from glancing at the manual- I’ve never actually tried to do GUI coding in it. It’s a very odd and very retro language in a lot of ways.

We have changed what is essentially a global variable and have changed how the function executes.

Hmm. Yeah, (define) is definitely something that doesn’t fit with the immutable concept and although it is occasionally very nice to be able to rebind global variables - and the Smalltalk approach is all about late-binding and rebinding of names everywhere - I agree that we shouldn’t have that if we’re doing the pure-functional thing.

Vector doesn’t have a canonical form as a list if you try to inspect them.

Yep, I don’t like this, and PicoLisp “solves” this - again in it odd way - by simply not having vectors. If that’s too much of a downside, then I’d argue that another way to solve it in a Lisplike manner is to extend the list serialization syntax with something like “#” – but as an actual magic symbol that exists inside a cons and so can be reflected on at runtime, not as a raw syntax thing. This was the basis of the idea I was playing with a decade or so ago, which I’ve never developed into an actual language because language development is hard and I’m lazy. I still think it’s cool though.

PLAN, by comparison, encodes function application with arities on top of binary trees of natural numbers.

Hmm. So “a binary tree of natural numbers” sounds a little like a cons cell, but in fact isn’t, quite? How exactly is this binary tree represented at a hardware level? Are there well-known encodings specified for things like, eg, integers, floats, and strings? Or are the encodings to and from natural numbers entirely up to each VM, in which case, how will integers, floats and strings be transferred between VMs?

When you suggest encoding a closure with variables encoded in a let block, I presume you mean as a quoted piece of code?

I’m assuming that the VM has more access to what’s inside a function than the user-level programmer does, yes, and that a function has something like a symbol or variable table, and that therefore the VM can add a header to the function that’s the equivalent of “bind this value to this variable”. That’s the thing that I would think of as being roughly equivalent to a “let” declaration that the user could write around code they had access to.

How this is done in Lisps (or Scheme) was quite a chunk of historical work as I understand it - and what led to the use of the term “closure” to describe “functions that carry their environment, or a reference to their enclosing environment, with them, as opposed to old-school dynamically-scoped Lisp 1.5 functions that don’t” so it’s maybe not always an easy thing to get right.

But I imagine your reified “application” objects/terms accomplish the same thing, without needing a symbol table.

Since deconstructing an actual procedure is not possible, you can’t turn those into serializable values.

Well, but deconstructing a procedure and serializing it could be very possible if you want it to be! There’s no law of VM design that says you must throw all this valuable data away when you compile or execute a function. If nothing else, you could just have the VM attach an AST of the source code or environment of every function to the compiled form of it (cost: one pointer in RAM per function, plus of course the tokenised source code itself - but we have large RAMs these days so why not?) Being able to have all this metadata on hand is perhaps hard to imagine outside of a VM that implements a Lisp cons-cell environment, but almost impossible not to imagine if you do have such a VM. Then, the question becomes not “can” we do this but “should” we - how much reflection about source code and debugging/meta-information should we attach to the runtime form of objects, how could this information be abused by bad code, how do we regulate access to it, etc.

3 Likes

While we’re sharing prejudices, I tend to think the original sin of Lisp was calling them Lists rather than Trees. McCarthy’s paper is clearly using cons cells to represent ASTs, and they are the dominant use for cons cells. We really should have called objects like '(a b c) trees rather than lists.

And now it seems we gained the terminology of Lists from IPL, where they were a much more complex data structure from what I can make out. So it did the thing of taking something commonly used but arcane and giving it a short and misleading name just because any name would be misleading for something so complex/arcane. Lisp simplified the data structure radically, but missed the opportunity to rename them to something less misleading.

Why do you say the lists were there before the lambdas? Lambda calculus obviously predates IPL.

Also, now it bothers me that I can’t find anything about the history of the idea of trees. Did they come from math? Who first called them trees in programming? TAOCP doesn’t say…

3 Likes

I meant in programming. Lambda calculus was there much earlier of course, but it was considered a throwaway piece of a failed project - part of an attempt to remove the paradoxes from logic. From what I understand, when McCarthy picked up the lambda, it was given the side-eye by mathematicians for quite a while: how could this nasty thing known to produce paradoxes, be at all reliable for computing? It was McCarthy’s (cond) ie if-then form which tamed the paradox by forcing the non-matching branch of the computation to be ignored and not evaluated. And also, by just brute-force ignoring the fact that you could still generate infinite loops, and just saying “so don’t write them then”. And accepting that a formalism that might not be mathematically perfect, could still be useful for computation.

At least that’s how I understand the story - it may be mythological.

I often wonder what might have happened if McCarthy had rummaged through a different discard drawer in the logic attic and gone straight to the combinators instead of the lambdas. There would have been much the same result, except that the idea of “environment” wouldn’t have been baked into the early Lisp idea of “function” (although symbols and naming would still have been useful). Conses would have made implementing a combinator stack machine easy, so we might have got something looking like Forth/Joy/Factor in the 1950s instead of Lisp. With stacks, performance might have been a lot faster from the beginning, leading to less of a quest for compilation and for hardware Lisp machines. That in turn might have made Lisp far more appealing to the wider computing culture. If Lisp had spread further in the 1960s, maybe SGML would just have been S-expressions, SQL would have just been S-expressions, XML would never have happened, HTML and CSS and Javascript would all have just been S-exps, nobody would ever have tried to generate HTML or SQL by banging strings together but rather by consing conses like civilised people… and a billion string-escape security vulnerabilities just wouldn’t have existed.

We really should have called objects like '(a b c) trees rather than lists.

Hmm. I thought that it’s always been understood that the Lisp definition of List is a tree, and is what makes it absolutely not an Array, although it looks in print like an Array. I guess that’s not actually that well understood, then?

From my perspective, it’s the idea of parsing sequences as trees which is the genius of Lists. It leads to very fast recursive algorithms where you always are thinking about “first item, and rest of items, that’s it, nothing else I need to remember” rather than “item 1, item 2, item3, whoops I forgot the exact index number or I ran out of numbers”, and where you always have the ability - in absolutely any context - for the “first item” to itself be a list, instead of “whoops, this one was only defined as an Array of Tokens so I can only have a token there”. Or worse, only an Array of Numbers (my BASIC childhood remembers, and shivers).

And also, the very strictly defined mapping of the tree structure to the list structure is non-trivial and it’s something that many other ad-hoc AST formats struggle with. The List allows easy and almost pain-free reading in and writing out of arbitrary trees (except in the presence of loops - and that’s a huge problem actually).

It probably would have introduced far too much complexity into early Lisp to have happened, but I wonder if we had S-expressions plus a very strict cons structure that had no mutations (so there could be no loops) and no sense of “individual identity” on conses (ie: two cells with the same contents always being defined to be equal, and no other equality operation). Then we might achieve true 100% read-write round-trip ability from cons structure to S-expressions. I feel like that might have been an important win for data transmission - and might still be.

Mind you, one thing about immutable conses/lists (and also Merkle trees) that I hate (or at least find strange by modern standards) is how you can’t easily “append” but can only “prepend”. It’s great for reading, but terrible for writing. If Unix had been based on an immutable Lisp, we either wouldn’t have had the concept of “file”, or it would have been something quite unusual. Possibly we’d have files with lines that accumulated in reverse order? Or we’d chunk them into blocks and periodically stop to reverse the block? I kind of wonder what it might be like to have an OS and desktop based entirely on an immutable list concept.

The distinction in my mind is tree vs list rather than tree vs array. Lists and trees are both recursive structures that can share structure/memory. The difference is just one next pointer vs two. And starting with lists creates a lot of distracting self-correction when teaching where you have to go back and say, “oh you can use these lists to encode trees.” Whereas if you just call them trees it might be slightly more complex at the start, but then you never have to correct yourself down the road and say, “well actually I lied when I said…”

…the very strictly defined mapping of the tree structure to the list structure is non-trivial and it’s something that many other ad-hoc AST formats struggle with. The List allows easy and almost pain-free reading in and writing out of arbitrary trees

I’d call this a linear encoding of trees rather than a list.

1 Like

Hmm. I’ve never thought of a tree as necessarily implying “two next pointers”. A node that has two pointers plus a value would imply a triple structure (like RDF). If we interpret a cons as a “binary tree” then it’s very specifically a binary tree with no actual value attached to each node. Which is not always what people think of when they hear “tree”.

I’d call this a linear encoding of trees rather than a list.

A “lintree” perhaps? That might have caught on. I guess we have “cons structure” today if we want to be super-precise.

And yes, looking further at IPL it’s interesting just how much unlike “Lisp lists” its lists were - I can see the origin of Lisp “symbols” complete with “property lists” in there, which explains why that rather baroque structure is baked into the lowest levels of most Lisps instead of just the simple, sweet cons structure. And IPL lists can only contain symbols, so they really were only lists, NOT binary trees. So one of Lisp’s breakthroughs was the distillation of IPL lists down to their essentials, and that simplification also immensely expanded their capabilities (by making them trees). I like that. I feel like there’s probably room for even more simplification in that direction, but making things more simple is much harder than making them more complex.

Bringing this back to PLAN, I’m quite curious as to how PLAN’s “binary tree of arbitrarily sized natural numbers” actually works and what it implies - it seems very much NOT the same thing as cons structure. I imagine it must map directly onto sequences of bytes in RAM somehow (implying it can only be allocated in sequential chunks? How does memory management work for it?)

Sorry, I’ll just clarify my position in a couple of very minor ways, and then let y’all get back to PLAN:

  1. I think a tree implies more than one pointer. That’s really it. It could be more than two pointers, and it could contain other data in each node, as you point out. But it’s not one pointer like “list” implies to me. That’s really all I’m objecting to is the term list. Perhaps Lisp should have been called ASTlang. But the term probably didn’t exist in 1960.
  2. I think s-expressions is a perfectly fine name for linearly encoded trees. Just like car and cdr are perfectly fine names for the two pointers in a cons cell. So my objection is really very surgical indeed. I’d also be fine with calling Lisp “s-languages”.

Ok, I’ll let y’all get back to it. (Sorry I don’t have the spoons to discuss PLAN. 2024 has been a tough year for me and I’ve had to closely husband my spoons…)

2 Likes

I’ve been traveling the last few days, so haven’t had a chance to read through everything in detail yet, but thanks for all the additional questions and conversation! I’ll sit down before the weekend and respond.

I’ll respond instead of Chase since i’m more knowledgeable about the technology. (Sorry for the delay in getting back to you btw, we’ve had quite a lot to do lately.)

Before getting into the details, i think it’s worth pointing out that PLAN is not intended as a user-facing language, but rather as a compile target. Maybe you already got this, but while writing my response there were several points where i realized that the confusion might stem from this not being sufficiently clear.

With that out of the way…

however, every definition of a lambda always associates a name with it? So it’s what in a Lisp would be a lambda wrapped inside a let? In which symbol table is this name bound, how is that table defined, how is it processed? And you can’t ever define an anonymous lambda?

Yes, it associates a name with it, but this name is completely nonsemantic, there’s no binding going on. The name is only there to increase readability. The goal of this system is to be radically legible while also being low-level. You indeed can’t define anonymous lambdas, but the value 0 is null in both ASCII and UTF-8, so it gets pretty close. This is what most high-level compilers should compile anonymous lambdas to.

(We’ve also considered adding nonsemantic names to let-bindings in law bodies for the same legibility reasons.)

what does “Laws can’t contain nested laws, but can reference other laws as constant values” actually mean? Can a law/lambda create a new lambda and return it as a value, or not? Can it reference a parameter/argument passed to or a variable defined by a “parent” lambda/law ? (ie what I woud consider a “closure” in Scheme or Javascript) Or is it restricted to only being able to refer to its own immediate arguments?

Sorry, that phrasing was a bit poor. Let me clarify. A law can create a new law and return it as a value. A law can only reference its immediate arguments; there is no “parent scope”. Everything has to be passed downwards explicitly, or inlined by a compiler. Both of these operations are cheap – you’re really just passing/inlining a pointer.

Is this actually Lambda Calculus , or is it a restricted form of it, and if so, what’s the implication of the “no nested” restriction?

“No nested” was irrelevant and confusing poor phrasing, sorry about that. In some sense i would call this an enriched rather than restricted version of the lambda calculus, since functions are n-ary rather than unary and we have natural numbers and explicit memory management/hashing. But it’s restricted in the sense that all lambdas are required to be supercombinators; there are no complicated scoping rules. Talking about restriction vs enrichment might not be the most informative angle, given that it’s “a little bit of each”.

By “const” I assume you maybe mean what in other programming languages would be called a “literal” rather than a “constant”?

Yes, good observation, thanks for this! We’ve worked this into the next version of our documentation that should be published soon.

The rest of my answers are gonna go a bit out of order from how you asked the questions, to make it flow better conceptually.

Oh yeah - it’s also a lazy VM, so applying a function to an argument presumably doesn’t immediately reduce to a value but creates an “application” object… which is cool, but at what point does actual reduction take place?

Think of apps as closures or thunks. Consider the app (((f a) b) c). If f has arity >3, this app tree is unsaturated, which means that it’s a piece of inert data which can be arbitrarily deconstructed, inspected and used to construct new apps, laws or pins. But once it’s been saturated, it has graduated to a thunk and will always be reduced before it can be further manipulated. The “function” here is either a native opcode (whose arities are hardcoded), a law {n a b} (whose arity is a) or another app, whose arity is trivially calculable by recursion.

So “a binary tree of natural numbers” sounds a little like a cons cell, but in fact isn’t, quite? How exactly is this binary tree represented at a hardware level?

It’s worth noting that since functions can only access their immediate arguments, (f a b c) actually contains all the data required to reduce f, assuming that f is ternary. Formally, this is (((f a) b) c), but since this is always going to be left-skewed, all realistic interpreters will optimize apps to vectors, to improve memory locality during evaluation. So the left-associativity is in some sense a more informative way to think about what’s actually going on than the formal tree structure.

PLAN appears to define one data literal (“natural number”) of apparently arbitary size, plus lambdas, applications, and pins - but no data-structure types like “cons/pair”, “sequence”, “array”, “Unicode string” etc. If I have, say, an array of strings or numbers or object-like things to store… do I have to store it as a single massive “natural number”? Or as a lambda/law? I feel like those are two extremes: it would be nice to have something more like “typed data object” that’s a little bit in the middle.

Agreed. What we do here is that we take advantage of the fact that all realistic interpreters optimize app trees to vectors in memory. So if we want to represent the array [1 2 3] we can do so using something like ({0 4 0} 1 2 3). Note that the law’s arity is 4, so the app isn’t saturated and so won’t reduce. As every other irreducible expression in PLAN, this can be reflected on using opcode 1, which can then be used to implement all sorts of utility functions that treat closures as arrays. A cons/pair is then simply an array of length 2, i.e. a partially applied law whose arity is 3.

(Ab)using laws in this way might seem ugly and overloaded, but notice the semantics of the law: its body is 0, so it simply returns itself. This is objectively useless behavior, so overloading it in this way is safe, if a bit ugly. Additionally, we have a new version of PLAN where we don’t even have to do this ugly trick: in this new version, opcodes will need to be wrapped in pins, while all plain nats will be given infinite arity (formally 0), and so [1 2 3] can simply be encoded as (0 1 2 3). We also get tagged unions for free:


LEFT(x) = (0 x)

RIGHT(x) = (1 x)

PicoLisp “solves” this - again in it odd way - by simply not having vectors. If that’s too much of a downside, then I’d argue that another way to solve it in a Lisplike manner is to extend the list serialization syntax with something like “#” – but as an actual magic symbol that exists inside a cons and so can be reflected on at runtime, not as a raw syntax thing. This was the basis of the idea I was playing with a decade or so ago, which I’ve never developed into an actual language because language development is hard and I’m lazy. I still think it’s cool though.

I agree, that is cool! And i think the approach outlined above is spiritually very similar, except that we (at least in the future version of PLAN) have infinitely many such symbols, which in our case aren’t even magical. The vector-like behavior just falls out of the model. Do you agree that it’s similar to what you were playing around with?

Only having natural numbers is probably very nice for mathematicians, but raises the question of: How exactly is the ordinary programmer going to represent integers, floats, strings, etc? Do all of these have to be lambdas of some kind?

Integers can be trivially mapped onto nats in at least one way: 0, 1, -1, 2, -2, …, though we’ve also considered representing -n as [n]. Both representations are memory friendly and regardless of the representation, writing functions over it is straightforward. Strings are similarly easily represented by thinking of ASCII strings as hexadecimal unsigned integers, which they in fact are. "foo" == 0x6F6F66 == 7303014

Floats are trickier – there’s a tradeoff space between performance, accuracy and determinism. We generally highly prioritize determinism in this system, but trading off performance doesn’t seem satisfactory in this particular case. We’ll take this as it comes, and i expect many different approaches to be both explored and used, just as in most other environments. It’s unfortunately an unsolved problem in computer science.

But for most other kinds of data – MP3 files, unicode strings, tables – we expect natural numbers and arrays to take us very far. Think of natural numbers as bytestrings, if you must. It’s reasonably straightforward to create a type system completely inside the VM that allows you to enforce invariants on your nats so that you know that they actually are of a certain word size and overflow in the expected ways instead of growing out of bounds. We don’t need to enforce this on the VM level. At network boundaries the type system can do an initial check while casting, and refuse messages that contain ill-typed nats.

No computer hardware gives us raw natural numbers - it gives us unsigned integers, yes, but in fixed word sizes. If the VM has to manage unsigned ints of arbitrarily large sizes… that might potentially cause problems? What happens if a piece of source code off the Internet tries to allocate a single natural number whose representation in RAM takes say, 5 gigabytes? 5 terabytes? 5 petabytes? Can a VM ever throw up its hands and say “sorry, number too big” or does doing that mean it’s failed to correctly implement the spec?

We don’t need to allocate the whole thing. An interpreter can page data in and out of memory as it wishes. But yes, even disk space is limited. The cop-out answer here is that the system is intended to scale up into the cloud (or to a home server) as well, which is naturally supported by the stable pointers provided by the merkle-DAG structure. But even the cloud has storage limitations, either because you ran out of money or because your provider literally can’t buy more storage space.

Given a finite observable universe, we can’t guarantee that the spec will always be formally, mathematically adhered to, but we’re much more concerned with building really good system software than we are with platonic truth. And given that the VM offers the ability to virtualize the evaluation of arbitrary expressions, we expect that instituting policies for handling different kinds of failures will be tractable.

But yes, there’s certainly a strong argument that it would be more honest to choose the bit as our lowest concrete kind of value, and build up everything using vectors of bits. This would require a significant redesign of the spec – for example, if there’s any point to this honesty, the increment operation would have to be made wordsize-aware and with the possibility of overflow. But this starts to get into details that could differ between physical machines, and since we want code and data to be completely portable, we prefer to keep it entirely abstract and instead allow the VM to transparently page and stream data on demand.

Well, but deconstructing a procedure and serializing it could be very possible if you want it to be! There’s no law of VM design that says you must throw all this valuable data away when you compile or execute a function. If nothing else, you could just have the VM attach an AST of the source code or environment of every function to the compiled form of it (cost: one pointer in RAM per function, plus of course the tokenised source code itself - but we have large RAMs these days so why not?) Being able to have all this metadata on hand is perhaps hard to imagine outside of a VM that implements a Lisp cons-cell environment, but almost impossible not to imagine if you do have such a VM. Then, the question becomes not “can” we do this but “should” we - how much reflection about source code and debugging/meta-information should we attach to the runtime form of objects, how could this information be abused by bad code, how do we regulate access to it, etc.

The issue is not primarily about having the metadata, but that this serializable metadata doesn’t necessarily mean anything on its own. The compiler could have performed type-directed code generation, meaning that the same AST could have resulted in many different compiled procedures. Sending only the serializable AST over the network doesn’t mean that the receiver will be able to reconstruct the original procedure.

But i agree, there’s nothing saying that we must throw away ASTs! I’m very partial to the philosophy that all metadata should be kept around, but that’s a question for a compiler targeting PLAN. PLAN itself is simply concerned with making it possible to serialize procedures (and builtins). On higher levels, we welcome all sorts of experimentation!

2 Likes

Hi kjekak, and thanks for the detailed explanation. I can follow some of it, but not all.

I’m getting that “Laws” are lambdas with no free variables but with an arity, and that “Apps” are automatically reduced when the arity of the Law they apply to is reached, which makes sense. So it’s only semi-lazy: the laziness turns into eagerness when the arity limit is reached. That seems like a sensible compromise between eagerness and laziness.

On cons cells vs “binary trees of natural numbers”, I think you maybe misunderstood the question I was asking. A cons cell is a binary tree that can contain multiple data types (at the very least, as in Picolisp: numbers, symbols, functions, and conses) in both of its nodes. The Lisp language’s core data structure being able to directly contain all of the language’s core data types is a very important and useful quality. I’m still not sure from your description whether PLAN’s Nats (or perhaps Apps containing nats?) can directly contain PLAN’s Apps, Laws and Pins - or whether the programmer would have to write an entire serialization/deserialization layer to and from Nat’, for every piece of non-Nat data? Because that’s a lot of work being shunted to the programmer and/or compiler writer, if so.

But the more important thing I hear you saying is that an App (but perhaps not a Law or Pin) can be automatically deconstructed at runtime by any other code which references it?

This seems like a very radical choice to put into the core of a programming language that also intends to be an operating system, and I feel like you may encounter some very serious security problems by doing this.

Many languages today rely heavily on functions being security/privacy boundaries. PLAN seems to have two different types of functions in Laws and Apps - and at least one of these function types can expose its previously bound parameters at runtime to arbitrary calling code, leading to a breach of the security boundary.

As an example, suppose I have a function with the signature:

secret-key → encrypted-message → decrypted-message

and I would like to apply / specialise / partially evaluate it by applying a secret key to it, so I end up with a new function with the signature:

encrypted-message → decrypted-message

and then I (a core operating system API, perhaps) would like to pass this second specialised function to a less-trusted function (a user program or plugin, perhaps) such that it can decrypt messages sent to it, but can’t extract the key.

If I had a guarantee from the VM that functions could never look inside other functions passed to them as parameters, then all functions like this can be security boundaries or capabilities. But if the VM allows some functions to be looked inside, then secret keys, or any other secrets, can leak - potentially anywhere. For very small VMs like Uxn, where the VM is one execution of a tiny program with no serious input/output access to the wider machine, this is less of a problem. But for a large VM which intends to be a persistent operating system at the scale of terabytes, this is a problem.

Perhaps Laws and Pins can remain security boundaries while Apps can’t? But if so, that’s again a lot of work being shunted to the programmer, to constantly understand and manage exactly what type of function-like entity they are working with - the safe one or the unsafe one - at every step of their program’s execution.

I assume that you’ve thought about this problem and found a solution?

Ah no, sorry! It is fully lazy, see for example:

> (const "foo" (die "crash"))
"foo"

The arity of const is 2, and the arity of die is 1. But since const never uses its second argument, (die "crash") is never evaluated despite being saturated.

What i meant was that if an app is saturated, then it cannot be deconstructed before being evaluated. For example:

> (SOME 5)
(0 5) ;; We encode SOME by applying 0 to a value.

> (cdr (fmapMaybe (SOME 5)))
(0 5) ;; The partial application of fmapMaybe is deconstructed using cdr.

> (cdr (fmapMaybe (SOME 5) inc))
6 ;; fmapMaybe is fully applied, and so it's evaluated before the result is passed to cdr.

I hope that clarifies the evaluation model?

We have a serialization format that goes PLAN -> Nat and Nat -> Maybe PLAN. Since all “builtins” have legal representations as PLAN values, this allows us to serialize any data to a nat. This is what we use to write the current state of the VM to disk, and to send arbitrary values over the network.

I hope that answers your question? It feels like i still might not completely understand what you’re getting at, since this property of being able to serialize anything to a nat/bytestring seems to be rather different from encoding everything using structured cons cells. Our version of “encoding everything using cons cells” is “encoding everything using apps” – apps are binary trees that can contain multiple data types (pins, laws, apps and nats).

Yes, this is correct, and it does extend to laws and pins as well. Any value can be reflected on, at least on the PLAN level. A user-facing language needn’t expose this functionality, of course. But without this fundamental capability, it’s not clear how we could serialize arbitrary PLAN expressions – arbitrary closures. In many ways, PLAN is really just a language for serializing closures.

The solution that i favor here is types. The core OS API should force any user program or plugin to adhere to a type system where functions are opaque terms. It can do this because it literally defines what it means for something to be a user program or a plugin. Again, PLAN is not user facing. Compare this to Haskell vs GHC Core – in Haskell, the type system allows me to enforce security boundaries in the way that you describe, while in GHC core, all values that we close over will be stored verbatim, including secret-key.

This does however mean that you need to be careful at network boundaries, because you can’t enforce types on the other side of those. If i send the partially applied function in your example over the network, i’m leaking secret-key. This doesn’t seem to be worse than any existing system, except perhaps that “send any program over the network” could be argued to be a false affordance.

Thank you, yes, that answers the question I was asking. So non-saturated Apps are your equivalent of Conses. That’s a lot better than “the only data structure is raw Nats” that I first thought was your answer.

Hmm. So there’s zero memory-read security in the VM, it’s all a wide-open world readable RAM. (Although it does have memory write security because of immutability, and automatic hashing/caching, which are two big advances from say Retro, Dawn, Uxn or Picolisp).

Since PLAN is not recommended for use by the programmer (and presumably direct PLAN access must be completely banned if the system is to have any security at all), do you have any ideas for what a compiled layer of the language/OS would look like?

And can one write a compiler or OS extension in this compiled language? Have you invented a sufficiently smart type system that can with 100% certainty allow “the type of all secure compilers” but not “the type of all insecure compilers”?

As a suggested alternative: if functions could not automatically be introspected, then one way a system could still provide introspection capabilities would be by maintaining separate parallel sets of “source” vs “object” references of functions. Then it would be possible to provide untrusted code with only an “object” reference, which could only be evaluated/executed, while trusted code could be passed the “source” reference, which could be deconstructed. The “source” form in this case need not be raw unparsed ASCII text, it could be a very efficient tokenized AST or pointer structure, just without the memory-read permission that lets its parameters be deconstructed.

I feel like a scheme like this at the VM/kernel level would give you much better security properties than a wide-open RAM and forcing all security to the upper levels.