From 968acca766468927ce546dadb2f068b3cd6f45d6 Mon Sep 17 00:00:00 2001 From: Alexander Yakovlev Date: Fri, 10 Nov 2017 16:11:35 +0700 Subject: [PATCH] snapshpt --- filters.coffee | 195 ++++++++++++++++++++++++++++++++++++++ index.coffee | 246 ++++++++++++++++++++++++++++++++++++++++++++++++ template.coffee | 91 ++++++++++++++++++ 3 files changed, 532 insertions(+) create mode 100644 filters.coffee create mode 100644 index.coffee create mode 100644 template.coffee diff --git a/filters.coffee b/filters.coffee new file mode 100644 index 0000000..bd119ed --- /dev/null +++ b/filters.coffee @@ -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 diff --git a/index.coffee b/index.coffee new file mode 100644 index 0000000..6f0e674 --- /dev/null +++ b/index.coffee @@ -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) diff --git a/template.coffee b/template.coffee new file mode 100644 index 0000000..27f057e --- /dev/null +++ b/template.coffee @@ -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 + )