This commit is contained in:
Alexander Yakovlev 2017-11-10 16:11:35 +07:00
commit 968acca766
3 changed files with 532 additions and 0 deletions

195
filters.coffee Normal file
View File

@ -0,0 +1,195 @@
###
The MIT License (MIT)
Copyright (c) 2013-2016, Reactive Sets
equals( a, b )
Returns true if a and b are deeply equal, false otherwise.
Parameters:
- a (Any type): value to compare to b
- b (Any type): value compared to a
Implementation:
'a' is considered equal to 'b' if all scalar values in a and b are strictly equal as
compared with operator '==' except for these two special cases:
- 0 == -0 but are not equal.
- NaN is not == to itself but is equal.
RegExp objects are considered equal if they have the same lastIndex, i.e. both regular
expressions have matched the same number of times.
Functions must be identical, so that they have the same closure context.
"undefined" is a valid value, including in Objects
###
equals = ( a, b ) ->
return a == b && # strict equality should be enough unless zero
a != 0 || # because 0 == -0, requires test by _equals()
_equals( a, b ) # handles not strictly equal or zero values
_equals = ( a, b ) ->
# a and b have already failed test for strict equality or are zero
# They should have the same toString() signature
if ( ( s = toString.call( a ) ) != toString.call( b ) )
return false
switch s
when '[object Number]'
# Converts Number instances into primitive values
# This is required also for NaN test bellow
a = +a
b = +b
if a
return a == b
else
if a == a # a is 0 or -O
return 1/a == 1/b # 1/0 != 1/-0 because Infinity != -Infinity
return b != b # NaN, the only Number not equal to itself!
when '[object RegExp]'
return a.source == b.source &&
a.global == b.global &&
a.ignoreCase == b.ignoreCase &&
a.multiline == b.multiline &&
a.lastIndex == b.lastIndex
when '[object Function]'
return false # functions should be strictly equal because of closure context
when '[object Array]'
if ( ( l = a.length ) != b.length )
return false
# Both have as many elements
while ( l-- )
if ( ( x = a[ l ] ) == ( y = b[ l ] ) && x != 0 || _equals( x, y ) )
continue
return false
return true
when '[object Object]'
l = 0 # counter of own properties
for p in a
if ( a.hasOwnProperty( p ) )
++l
if ( ( x = a[ p ] ) == ( y = b[ p ] ) && x != 0 || _equals( x, y ) )
continue
return false
# Check if 'b' has as not more own properties than 'a'
for p in b
if ( b.hasOwnProperty( p ) && --l < 0 )
return false
return true
else # Boolean, Date, String
return a.valueOf() == b.valueOf()
partial = (func) ->
boundArgs = arguments #slice.call(arguments, 1)
bound = () ->
position = 0
length = boundArgs.length
args = Array(length)
for i in [0..length]
args[i] = boundArgs[i]
while (position < arguments.length)
args.push(arguments[position++]);
return executeBound(func, bound, this, this, args)
return bound
TAG_COMPARISON =
TOTAL: 1
PARTIAL: 0
MISMATCH: -1
compareTags = (a, b) ->
# Returns a TAG_COMPARISON value
if (equals(a, b))
return TAG_COMPARISON.TOTAL
# If the tags are unequal but have the same length, it stands to reason
# there is a mismatch.
if (a.length == b.length)
return TAG_COMPARISON.MISMATCH
if a < b
[shorter, longer] = [a, b]
else
[shorter, longer] = [b, a]
for x, index in shorter
if x != longer[index]
return TAG_COMPARISON.MISMATCH
return TAG_COMPARISON.PARTIAL
# Ensures that the group and model don't have any mismatched tags
mismatchFilterSub = (group, model) ->
if not group.tags?
return 0
result = group.tags.find((groupTag) ->
# Look for a mismatch.
matched = model.tags.find (modelTag) =>
modelTag[0] == groupTag[0]
if not matched?
return false
if compareTags(groupTag, matched) == TAG_COMPARISON.MISMATCH
return true;
return false
)
if not result?
return 0
return null
bonusCompare = (mode, bonus = 1, cumulative = false) ->
return (group, model) ->
results = group.tags.filter((groupTag) ->
matched = model.tags.find((modelTag) =>
modelTag[0] == groupTag[0]
)
if not matched?
return false
if compareTags(groupTag, matched) == mode
return true
return false
)
if (results.length)
if cumulative
return bonus * results.length
else
return bonus
return 0
filters = {}
filters.mismatchFilter = () ->
return mismatchFilterSub
filters.partialBonus = partial(bonusCompare, TAG_COMPARISON.PARTIAL)
filters.fullBonus = partial(bonusCompare, TAG_COMPARISON.TOTAL)
filters.dryness = () ->
return (group) ->
that = this
newPhrases = []
for phrase in group.phrases
if (that.history.indexOf(phrase) == -1 && phrase != null)
newPhrases.push(phrase)
newGroup = Object.create(group)
newGroup.phrases = newPhrases
return [0, newGroup]
filters.unmentioned = (bonus = 1) ->
return (group) ->
if (!Array.isArray(group.tags))
return 0
if (group.tags.length == 0)
return 0
that = this
for t in group.tags
# Return true if the tag is "novel".
for u in that.tagHistory
if u[0] == t[0]
return bonus
return 0
Improv.filters = filters

246
index.coffee Normal file
View File

@ -0,0 +1,246 @@
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
@instantiateAuditData()
###
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
returning.
###
__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)
@history.unshift(chosenPhrase)
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
@clearHistory()
@clearTagHistory()
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])
else
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
console.log(groups)
console.log(model)
throw new Error("Ran out of phrases in #{groups} while generating #{@__currentSnippet}")
if groups.length == 0
log()
phrases = @flattenGroups(groups)
if phrases.length == 0
log()
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?
return
cbOutput = cb.call(this, 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]
else
scoreOffset = cbOutput
if scoreOffset == null
output.score = null
return
output.score += scoreOffset
output.group = currentGroup
return output
###
Starting with the raw list of groups, return a filtered list with
scores.
###
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
validGroups.push(group)
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
validGroups.push(g)
maxScore = Number.NEGATIVE_INFINITY
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 => o.phrases.map(i => [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)

91
template.coffee Normal file
View File

@ -0,0 +1,91 @@
TEMPLATE_BUILTINS =
a: (text) ->
if text.match(/^[aeioAEIO]/)
return "an #{text}"
return "a #{text}"
an: (text) ->
return this.a(text)
cap: (text) ->
return "#{text[0].toUpperCase()}#{text.slice(1)}"
A: (text) ->
return this.cap(this.a(text))
An: (text) ->
return this.A(text)
mergeInTag = (tags, tag) ->
# Find the matching tag...
i = tags.findIndex(t => t[0] == tag[0])
if i == -1
return tags.concat([tag])
# Otherwise:
# This is supposed to be a non-destructive operation
newTags = tags.concat()
newTags[i] = tag
return newTags
processDirective = (rawDirective, model, cb, generator) ->
directive = rawDirective.trim()
if directive[0] == directive.slice(-1) && directive[0] == "'"
# This is a literal directive.
return directive.slice(1, -1)
if directive.indexOf(' ') != -1
# The directive contains a space, which means it's a chained directive.
funcName = directive.split(' ')[0]
rest = directive.slice(directive.indexOf(' ') + 1)
if TEMPLATE_BUILTINS.hasOwnProperty(funcName)
return "#{TEMPLATE_BUILTINS[funcName](processDirective(rest, model, cb, generator))}"
if generator && generator.builtins && generator.builtins[funcName]
return "#{generator.builtins[funcName](processDirective(rest, model, cb, generator))}"
if typeof model[funcName] != 'function'
throw new Error("Builtin or model property \"#{funcName}\" is not a function.")
return "#{model[funcName](processDirective(rest, model, cb, generator))}"
if directive[0] == '|'
[tagStr, snippet] = directive.split(':')
# Disregard the first |
newTag = tagStr.slice(1).split('|')
newModel = Object.create(model)
newModel.tags = mergeInTag(model.tags, newTag)
return cb(snippet, newModel)
if directive[0] == ':'
return cb(directive.slice(1), model)
if directive[0] == '#'
args = directive.slice(1).split('-')
return salet.rnd.randRange(parseInt(args[0], 10), parseInt(args[1], 10))
if directive.indexOf('.') != -1
propChain = directive.split('.')
return propChain.reduce((obj, prop) =>
return [obj[prop], model] ## TODO
)
return "#{model[directive]}"
template = (phrase, model, cb, generator) ->
[openBracket, closeBracket] = [phrase.indexOf('['), phrase.indexOf(']')]
if openBracket == -1
return phrase
if closeBracket == -1
throw new Error("Missing close bracket in phrase: #{phrase}")
before = phrase.slice(0, openBracket)
after = phrase.slice(closeBracket + 1)
directive = phrase.substring(openBracket + 1, closeBracket)
return template(
"#{before}#{processDirective(directive, model, cb, generator)}#{after}",
model,
cb,
generator
)