Improv ported to Salet (Coffeescript and uses Salet.rnd instead of Math.random)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

246 lines
6.7 KiB

defaults =
filters: []
reincorporate: false
persistence: true
audit: false
salienceFormula: (a) ->
return a
class Improv
constructor: (snippets, options = {}) ->
# Constructor for Improv generators.
# We don't want to mutate the options object we've been given we don't know
# where it's been.
spec = {}
$.extend(spec, defaults)
$.extend(spec, options)
@snippets = snippets
@filters = spec.filters
@reincorporate = Boolean(spec.reincorporate)
@persistence = Boolean(spec.persistence)
@audit = Boolean(spec.audit)
@salienceFormula = spec.salienceFormula
@builtins = spec.builtins
@history = []
@tagHistory = []
if @audit
Actually generate text. Separate from #gen() because we don't want to clear
history or error-handling data while a call to #gen() hasn't finished
__gen: (snippet, model) ->
For the sake of better error handling, we try to keep an accurate record
of what snippet is being generated at any given time.
if (typeof model.bindings == 'object' && model.bindings[snippet])
# The snippet already exists in the model's bindings.
return model.bindings[snippet]
@__currentSnippet = snippet
chosenPhrase = @selectPhrase(@scoreFilter(@applyFilters(snippet, model)), model)
if @audit
phraseTotal = @__phraseAudit.get(snippet).get(chosenPhrase)
@__phraseAudit.get(snippet).set(chosenPhrase, phraseTotal + 1)
output = template(chosenPhrase, model, @__gen.bind(this), this)
if @snippets[snippet].bind
model.bindings ?= {}
model.bindings[snippet] = output
return output
This is a getter so that the internals of how auditing data is stored
and calculated can change without changing the API.
phraseAudit: () ->
if !@audit
throw new Error('Tried retriving audit from generator not in auditing mode.')
return @__phraseAudit
Generate text (user-facing API). Since this function can recur, most of
the heavy lifting is done in __gen().
gen: (snippet, model = {}) ->
# Make sure the model has a tag property.
model.tags ?= []
if not @snippets[snippet]?
throw new Error("Tried generating snippet \"#{snippet}\", but no such snippet exists in spec")
output = @__gen(snippet, model)
if !@persistence
return output
# Add a group's tags to the model, for reincorporation.
mergeTags: (model, groupTags) ->
mergeTag = (a, b) ->
if a.length < b.length
return b
return a
for a in groupTags
# Find the corresponding tag in the model.
site = model.tags.findIndex (b) =>
a[0] == b[0]
if site == -1
# No such tag simply add the group's tags to the model.
model.tags = model.tags.concat([a])
model.tags[site] = mergeTag(model.tags[site], a)
return model
# Once we have a list of suitable groups, finally select a phrase at random.
selectPhrase: (groups, model) ->
log = () =>
if @audit
throw new Error("Ran out of phrases in #{groups} while generating #{@__currentSnippet}")
if groups.length == 0
phrases = @flattenGroups(groups)
if phrases.length == 0
if !this
console.log('not this either!')
chosen = salet.rnd.randomElement(phrases)
if (@reincorporate)
@mergeTags(model, chosen[1])
if Array.isArray(chosen[1])
@tagHistory = chosen[1].concat(@tagHistory)
return chosen[0]
Run the filters through an individual group.
applyFiltersToGroup: (group, model) ->
output = { score: 0 }
# Make sure the group has tags.
group.tags ?= []
# Since we might return a different group than we got, we use a variable.
currentGroup = group
for cb in @filters
if not output.score?
cbOutput =, currentGroup, model)
scoreOffset = undefined
if Array.isArray(cbOutput)
# We got a tuple, meaning the filter wants to modify the group before
# moving on.
scoreOffset = cbOutput[0]
currentGroup = cbOutput[1]
scoreOffset = cbOutput
if scoreOffset == null
output.score = null
output.score += scoreOffset = currentGroup
return output
Starting with the raw list of groups, return a filtered list with
applyFilters: (snippetName, model) ->
if not @snippets[snippetName]?
throw new Error("Missing snippet object for snippet #{snippetName}.")
groups = @snippets[snippetName].groups
if !Array.isArray(groups)
throw new Error("Missing or bad groups array for snippet #{snippetName} was #{typeof groups}")
if @audit
console.log "Eligible groups for applyFilters, snippet #{@__currentSnippet}:"
console.log groups
validGroups = []
for group in groups
rgr = @applyFiltersToGroup(group, model)
if rgr? and rgr.score?
group.score = rgr.score
return validGroups
Starting with the scored list from applyFilters(), return a list that has
invalid groups scrubbed out and only includes groups with a score past
the threshold.
scoreFilter: (groups) ->
if @audit
console.log "Eligible groups for scoreFilter, snippet #{@__currentSnippet}: "
console.log groups
# Filter out groups emptied out by dryness()
if groups.length == 0
return groups
validGroups = []
for g in groups
if g.phrases.length > 0
for group in validGroups
if group.score > maxScore
maxScore = group.score
scoreThreshold = @salienceFormula(maxScore)
return validGroups.filter (o) =>
o.score >= scoreThreshold
Starting with a list of scored groups, flatten them into a simple list
of phrases.
flattenGroups: (groups) ->
if groups.length == 0
return Array()
return ```groups
.map(o => => [i, o.tags]))
.reduce((a, b) => a.concat(b), []);
clearHistory: () ->
@history = []
clearTagHistory: () ->
@tagHistory = []
instantiateAuditData: () ->
Create and fill audit maps with starter data, ie zeroes.
@__phraseAudit = new Map()
self = this
for key in Object.keys(self.snippets)
self.__phraseAudit.set(key, new Map())
for group in self.snippets[key].groups
for phrase in group.phrases
self.phraseAudit().get(key).set(phrase, 0)