Chancery

Usage

Chancery is a library for procedurally generating text and data in Common Lisp. It's heavily inspired by Tracery, and is essentially just some Lispy syntactic sugar for writing grammars.

Rules

Rules are the core construct Chancery uses to generate data, and can be created with define-rule:

(define-rule animal
  "cat"
  "dog"
  "mouse")

(define-rule color
  "black"
  "white"
  "brown"
  "gray")

Rules are compiled into vanilla Lisp functions, so you can call them like you would any other function.

(loop :repeat 10
      :collect (list (color) (animal)))
; =>
(("gray" "dog") ("white" "mouse") ("gray" "dog")
 ("black" "mouse") ("gray" "cat") ("gray" "cat")
 ("white" "mouse") ("white" "dog") ("gray" "mouse")
 ("brown" "cat"))

Basic rules select one of their body terms at random and evaluate it according to some special rules. Most kinds of objects (e.g. strings, keywords, numbers) evaluate to themselves, but there are a few exceptions.

Symbols evaluate to (funcall 'symbol), so rules can easily call other rules:

(define-rule cat-name
  "fluffy"
  "whiskers")

(define-rule dog-name
  "spot"
  "fido"
  "lassie")

(define-rule animal-name
  cat-name
  dog-name)

(loop :repeat 10 :collect (animal-name))
; =>
("spot" "spot" "fido" "fluffy" "fido" "spot"
 "fluffy" "spot" "spot" "lassie")

Lists recursively evaluate their members and return the result as a fresh list:

(define-rule pet
  (cat-name color :cat)
  (dog-name color :dog))

(loop :repeat 5 :collect (pet))
; =>
(("fluffy" "brown" :CAT) ("fluffy" "white" :CAT)
 ("fido" "brown" :DOG) ("lassie" "white" :DOG)
 ("fluffy" "black" :CAT))

If you want to return a literal symbol or list from a rule you can quote it:

(define-rule foo
  'a
  '(x y))

(loop :repeat 5 :collect (foo))
; =>
(A (X Y) (X Y) A A)

Text Generation

Grammars are often used to generate strings, and Chancery has a few extra bits of syntactic sugar to make it easy to generate readable text. define-string can be used to define a rule function that stringifies its result before returning it:

(define-string animal "cat" "dog" "mouse")
(define-string name "Alice" "Bob" "Carol")
(define-string place "mountain" "forest" "river")

(define-string story
  (name "the" animal "went to the" place)
  ("a friendly" animal "lived near a" place))

(loop :repeat 5 :collect (story))
; =>
; ("Alice the mouse went to the river"
;  "Bob the cat went to the forest"
;  "a friendly mouse lived near a river"
;  "a friendly cat lived near a forest"
;  "Bob the cat went to the river")

define-string works the same way as define-rule, except that the evaluation of certain items is slightly different.

Strings evaluate to themselves. NIL evaluates to an empty string. Other non-keyword symbols evaluate to procedure calls, as with define-rule.

Lists evaluate their arguments and concatenate them with one #\Space between each. If you don't want a space between two items you can use the special symbol :. to suppress it:

(define-string foo "x" "y")

(define-string with-spaces
  (foo foo foo))

(define-string without-spaces
  (foo :. foo :. foo))

(with-spaces)    ; => "x x y"
(without-spaces) ; => "xyx"

The special form (quote ...) evaluates to its argument, without stringifying it. Note that this is one case where a define-string could return something that isn't a string, so use it with care.

Everything else (except vectors, which we'll talk about shortly) evaluates to the result of calling princ-to-string on it.

Distributions

By default each body term in a rule has an equal chance of being chosen.

Chancery also includes support for weighting the terms so some will be chosen more often than others:

(define-rule (metal :distribution :weighted)
  (10 :iron)
  (5 :steel)
  (2 :silver)
  (1 :gold)
  (1 :platinum))

Weighting each term by hand can be tedious. Chancery can automatically calculate weights based on the order of the body terms according to Zipf's law:

(define-rule (armor-type :distribution :zipf)
  :scale-mail
  :chain-mail
  :banded-mail
  :plate-mail)

The Zipf distribution can take an argument exponent, which is the exponent characterizing the distribution. The default is 1.0, and larger exponents will result in the earlier terms being chosen more often:

(define-rule (foo :distribution :zipf)
  :a :b :c :d :e)

(define-rule (bar :distribution (:zipf :exponent 3.0))
  :a :b :c :d :e)

(define-rule (baz :distribution (:zipf :exponent 0.25))
  :a :b :c :d :e)

(count :a (loop :repeat 1000 :collect (foo)))
; => 413

(count :a (loop :repeat 1000 :collect (bar)))
; => 831

(count :a (loop :repeat 1000 :collect (baz)))
; => 257

Single Generation

Sometimes it can be handy to evaluate a Chancery expression without defining an actual rule. The generate macro evaluates vanilla Chancery expressions, and generate-string evaluates Chancery string expressions:

(define-rule x 1 2 3)
(define-rule animal "cat" "dog" "mouse")

(generate :foo)    ; => :FOO
(generate x)       ; => 2
(generate (x x x)) ; => (2 2 1)

(generate-string :foo)             ; => "FOO"
(generate-string x)                ; => "1"
(generate-string ("fuzzy" animal)) ; => "fuzzy mouse"

String Modifiers

Procedurally generating readable text can be tricky, so Chancery includes some syntax and a few helper functions to make your life easier.

Inside a define-string vectors are evaluated specially. The first element of the vector is evaluated as usual in define-string. All remaining elements in the vector are treated as function names, and the result is threaded through each function in turn. Finally, the resulting value will be stringified. For example:

(define-rule animal "cat" "dog" "mouse")

(generate-string ("the" animal "ran"))
; =>
; "the cat ran"

(generate-string ("the" #(animal string-upcase) "ran"))
; =>
; "the MOUSE ran"

Chancery defines a few helpful functions for generating English text:

These are just normal Lisp functions that happen to be useful as modifiers:

(define-string animal "cat" "aardvark" "fly")

(loop :repeat 5
      :collect (generate-string
                 ("a bunch of" #(animal s))))
; =>
; ("a bunch of flies" "a bunch of flies" "a bunch of aardvarks"
;  "a bunch of cats" "a bunch of aardvarks")

(loop :repeat 5
      :collect (generate-string
                 (#(animal a cap) "is a good pet")))
; =>
; ("A cat is a good pet" "A fly is a good pet" "A fly is a good pet"
;  "A cat is a good pet" "An aardvark is a good pet")

English is a messy language. Pull requests to improve these functions (or add more useful ones) are welcome.

Evaluation

Sometimes it can be useful to switch out of "Chancery evaluation" and back into normal Lisp evaluation. The (eval ...) special form can be used to do this in Chancery rules and string rules:

(define-rule treasure
  :weapon
  :armor
  ((eval (random 100)) :gold))

(loop :repeat 5 :collect (treasure))
; =>
; ((93 :GOLD)
;  :WEAPON
;  (83 :GOLD)
;  :WEAPON
;  :ARMOR)

(define-string animal "cat" "dog")
(define-string story
  ("The" animal "had" (eval (+ 2 (random 3))) "friends."))

(loop :repeat 5 :collect (story))
; =>
; ("The dog had 3 friends." "The cat had 2 friends." "The dog had 3 friends."
;  "The cat had 4 friends." "The dog had 2 friends.")

You can think of eval and generate as two sides of the same coin, kind of like quasiquote's backquote and comma. eval flips from Chancery evaluation to Lisp evaluation, and generate flips in the other direction.

Reader Macros

Chancery provides four reader macros you can use to make defining grammars even more concise. It uses named-readtables to keep them safely stashed away unless you want them.

The first reader macro is [...] to replace the standard vector #(...) syntax, to make string modifiers stand out more:

(named-readtables:in-readtable :chancery)

(define-string animal "cat" "dog")

(generate-string ("a few" [animal s]))
; => "a few cats"

The reader macro !form expands to (eval form):

(named-readtables:in-readtable :chancery)

(define-rule treasure
  :weapon
  :armor
  (!(random 100) :gold))

(loop :repeat 5 :collect (treasure))
; =>
; (:ARMOR (53 :GOLD) (49 :GOLD) :ARMOR :WEAPON)

The reader macro @form expands to (generate form), and $form expands to (generate-string form). Note that none of th

(named-readtables:in-readtable :chancery)

(define-string animal "cat" "dog")

$("the" animal "ran")
; => "the cat ran"

Together these let you jump in and out of Chancery-style evaluation as needed:

(named-readtables:in-readtable :chancery)

(define-string animal "cat" "dog")

(define-string story
  ("the" animal "went to sleep")
  !(let ((pet (animal)))
     $("the" !pet "was a good" !pet)))

(loop :repeat 4 :collect (story))
; =>
; ("the dog was a good dog" "the cat was a good cat"
;  "the dog was a good dog" "the cat went to sleep")

This last example is a little tricky, but it helps to think of $ and @ as quasiquote's backquote, and ! as comma. Flipping back and forth between Lisp and Chancery can let you define some really interesting grammars.

Runtime Rule Creation

All of the Chancery things we've been using so far have been macros. If you want to create rules at runtime you can use their function counterparts:

For example:

(define-rule foo :a :b :c)
(foo)
; versus
(funcall (create-rule (list :a :b :c)))

(define-string (bar :distribution :zipf)
  ("one" foo)
  ("two" [foo s]))
(bar)
; versus
(funcall (create-string (list '("one" foo)
                              '("two" [foo s]))
           :distribution :zipf))

Tips

Chancery aims to be a very thin layer on top of Lisp. Chancery rules are just vanilla Lisp functions—you can describe them, disassemble them, trace them, funcall them, and anything else you might normally want to do with functions. Similarly, Chancery string modifiers are just unary functions—you can create your own, or even use built-in Lisp functions like string-upcase.

Remember that you can flip back and forth between Chancery evaluation and normal Lisp evaluation. Sometimes it's easier to just do a quick !(if foo then else) than to make Yet Another Rule. Use your own judgement to determine when a rule is getting too hairy and needs to be split into simpler parts, just like you would for any other Lisp function.

Examples

If you want some less trivial examples than the ones seen here, you might want to take a look at some of the Twitter bots I've built with Chancery: