diff --git a/lang/examples/___charts.arr b/lang/examples/___charts.arr new file mode 100644 index 000000000..cc66b2a95 --- /dev/null +++ b/lang/examples/___charts.arr @@ -0,0 +1,9 @@ +include charts +include image +import color as C + +fun some-fun(x): 1 / x end +a-series = from-list.function-plot(some-fun) + +img = render-chart(a-series).get-image() +save-image(img, "chart.png") \ No newline at end of file diff --git a/lang/src/arr/compiler/cli-module-loader.arr b/lang/src/arr/compiler/cli-module-loader.arr index 12beefe11..0e1d3c477 100644 --- a/lang/src/arr/compiler/cli-module-loader.arr +++ b/lang/src/arr/compiler/cli-module-loader.arr @@ -36,7 +36,7 @@ include from E: end include from CS: - type Loadable + type Loadable, type NameResolution end diff --git a/lang/src/js/trove/adaptive.js b/lang/src/js/trove/adaptive.js new file mode 100644 index 000000000..9ca3f2610 --- /dev/null +++ b/lang/src/js/trove/adaptive.js @@ -0,0 +1,246 @@ +/** @satisfies {PyretModule} */ +({ + requires: [], + nativeRequires: [ + 'pyret-base/js/js-numbers', + ], + provides: { + values: {}, + types: {} + }, + theModule: function(RUNTIME, NAMESPACE, uri, jsnums) { + /** + * Euclidean distance between two points + * @param {[number, number]} xs - a pair of x-values + * @param {[number, number]} ys - a corresponding pair of y-values + * @returns {number} - the loss for the interval + */ + function defaultLoss(xs, ys) { + const dx = xs[1] - xs[0]; + const dy = ys[1] - ys[0]; + return Math.hypot(dx, dy); + } + + /** + * Distance between two x-values + * @param {[number, number]} xs - a pair of x-values + * @param {[number, number]} ys - unused (only passed in for consistency with other loss functions) + * @returns {number} - the loss for the interval + */ + function uniformLoss(xs, ys) { + return xs[1] - xs[0]; + } + + /** + * Penalizes y-values close to 0 + * Implementation of abs_min_log_loss from adaptive (min not required for scalars) + * @param {[number, number]} xs - a pair of x-values + * @param {[number, number]} ys - a corresponding pair of y-values + * @returns {number} - the loss for the interval + */ + function absLogLoss(xs, ys) { + // bound the transformed y-value in case y = 0 + const lowerBound = -1e12; + const ysLog = ys.map(y => Math.max(lowerBound, Math.log(Math.abs(y)))); + return defaultLoss(xs, (/** @type {[number, number]} */ (ysLog))); + } + + /** @typedef {{x: number[], y: number[]}} Foo */ + + /** + * Flow: + * 1. Initialize data + * 2. Compute losses + * 3. Identify interval with max loss + * 4. Split interval in half + * 5. Repeat steps 2-4 until stopping condition + */ + class AdaptiveSampler { + /** + * @param {PyretFunction} func - function to plot + * @param {number} xMinValue - min x-value + * @param {number} xMaxValue - max x-value + * @param {Function} lossFunction - loss function + * @param {number} numSamples - max number of data points to sample + */ + constructor(func, xMinValue, xMaxValue, lossFunction, numSamples) { + // format: [[x1, x2, loss]] + this.lossManager = (/** @type {[number, number, number][]} */ ([])); + // format: {x1: y1, x2: y2, ...} + this.data = new Map(); + this.func = func; + this.xMinValue = xMinValue; + this.xMaxValue = xMaxValue; + this.lossFunction = lossFunction; + this.numSamples = numSamples; + this.pending = (/** @type {[number, number][]} */ ([])); + } + // runs the function more safely (handles zero division and Pyret nums) + // FIX: janky error handling + /** + * @param {Number} input + * @returns {Foo} + */ + runFuncSafe(input) { + let x = input; + return RUNTIME.safeCall( + () => RUNTIME.execThunk(RUNTIME.makeFunction(() => this.func.app(x))), + (result) => { + if (result.$name === "left") { + const output = RUNTIME.getField(result, "v"); + const y = typeof output == "number" ? output : jsnums.toFixnum(output); + return {"x": [x], "y": [y]}; + } else { + return null; + } + }, + "runFuncSafe" + ); + }; + + runFunc(input, offset = 1e-6) { + let x = input; + console.log("[runFunc] calling func with x =", x); + return RUNTIME.safeCall( + () => this.runFuncSafe(x), + (result) => { + if (result !== null) { + console.log("[runFunc] success: x =", x, "y =", result.y[0]); + return result; + } + console.log("[runFunc] error for x =", x, "trying offsets"); + const x1 = x - offset; + const x2 = x + offset; + return RUNTIME.safeCall( + () => this.runFunc(x1), + (r1) => RUNTIME.safeCall( + () => this.runFunc(x2), + (r2) => ({"x": [x1, x2], "y": [r1.y[0], r2.y[0]]}), + "runFunc-offset-2" + ), + "runFunc-offset-1" + ); + }, + "runFunc" + ); + }; + + // initialize data by computing f(x) for endpoints + initData() { + console.log("[initData] starting with xMin =", this.xMinValue, "xMax =", this.xMaxValue); + return RUNTIME.safeCall( + () => this.runFunc(this.xMinValue), + (lower) => { + console.log("[initData] lower result:", lower); + return RUNTIME.safeCall( + () => this.runFunc(this.xMaxValue), + (upper) => { + console.log("[initData] upper result:", upper); + lower.x.forEach((xi, i) => this.data.set(xi, lower.y[i])); + upper.x.forEach((xi, i) => this.data.set(xi, upper.y[i])); + // adds lower x1 and upper x2 (outer bounds) if there is more than one x returned + this.pending.push([lower.x[0], (/** @type {number} */ (upper.x.at(-1)))]); + console.log("[initData] done, data size =", this.data.size, "pending =", this.pending); + }, + "initData-upper" + );}, + "initData-lower" + ); + }; + + // compute loss for each interval in pending + // FIX: janky error handling + computeLosses() { + while (this.pending.length > 0) { + const xs = /** @type {[number, number]} */ (this.pending.pop()); + const ys = [this.data.get(xs[0]), this.data.get(xs[1])]; + if (ys.includes(undefined)) continue; + const loss = this.lossFunction(xs, ys); + this.lossManager.push([...xs, loss]); + } + }; + + // get the interval with the max loss + // TODO: handling multiple intervals with the same max loss (particularly important for uniform loss) + getMaxLoss() { + let maxLoss = -Infinity; + let maxInterval = null; + let maxIndex = null; + + for (let i = 0; i < this.lossManager.length; i++) { + const item = this.lossManager[i]; + if (item[2] > maxLoss) { + maxLoss = item[2]; + maxInterval = item.slice(0, 2); + maxIndex = i; + } + } + return { maxInterval, maxIndex }; + }; + + // split an interval in half, compute y-value of the midpoint, and add new intervals to pending + /** + * @param {number[]} maxInterval + * @param {number} maxIndex + */ + splitInterval(maxInterval, maxIndex) { + const [l, r] = maxInterval; + const m = (l + r) / 2; + return RUNTIME.safeCall( + () => this.runFunc(m), + (coord) => { + coord.x.forEach((xi, i) => this.data.set(xi, coord.y[i])); + this.lossManager.splice(maxIndex, 1); + this.pending.push([l, coord.x[0]], [/** @type {number} */ (coord.x.at(-1)), r]); + }, + "splitInterval" + ); + }; + + // runs the adaptive sampler + // TODO: adding different stopping conditions (e.g. error threshold) + runner() { + console.log("[runner] starting, numSamples =", this.numSamples); + /** @returns {PyretNothing} */ + const iterate = () => { + console.log("[iterate] data size =", this.data.size, "/ target =", this.numSamples); + if (this.data.size >= this.numSamples) { + console.log("[iterate] done, final data size =", this.data.size); + return RUNTIME.nothing; + } + const { maxInterval, maxIndex } = this.getMaxLoss(); + if (maxInterval === null || maxIndex === null) { + console.log("[iterate] no intervals found in lossManager, stopping early"); + return RUNTIME.nothing; + } + console.log("[iterate] splitting interval", maxInterval, "at index", maxIndex); + return RUNTIME.safeCall( + () => this.splitInterval(maxInterval, maxIndex), + () => { + this.computeLosses(); + return iterate(); + }, + "runner-iterate" + ); + }; + return RUNTIME.safeCall( + () => this.initData(), + () => { + console.log("[runner] initData complete, computing initial losses"); + this.computeLosses(); + return iterate(); + }, + "runner-init" + ); + }; + + } + + return RUNTIME.makeModuleReturn({}, {}, { + defaultLoss: defaultLoss, + uniformLoss: uniformLoss, + absLogLoss: absLogLoss, + AdaptiveSampler: AdaptiveSampler + }); + } +}) \ No newline at end of file diff --git a/lang/src/js/trove/charts-lib.js b/lang/src/js/trove/charts-lib.js index 2fa7d5fb0..a116630de 100644 --- a/lang/src/js/trove/charts-lib.js +++ b/lang/src/js/trove/charts-lib.js @@ -2,6 +2,7 @@ requires: [ { 'import-type': 'builtin', 'name': 'image-lib' }, { "import-type": "builtin", 'name': "charts-util" }, + { "import-type": "builtin", 'name': "adaptive" } ], nativeRequires: [ 'pyret-base/js/js-numbers', @@ -20,7 +21,7 @@ 'plot': "tany", } }, - theModule: function (RUNTIME, NAMESPACE, uri, IMAGELIB, CHARTSUTILLIB, jsnums, vega, canvasLib) { + theModule: function (RUNTIME, NAMESPACE, uri, IMAGELIB, CHARTSUTILLIB, ADAPTIVELIB, jsnums, vega, canvasLib) { 'use strict'; @@ -61,6 +62,7 @@ var IMAGE = get(IMAGELIB, "internal"); var CHARTSUTIL = get(CHARTSUTILLIB, "values"); + var ADAPTIVE = get(ADAPTIVELIB, "internal"); const ann = function(name, pred) { return RUNTIME.makePrimitiveAnn(name, pred); @@ -2780,20 +2782,18 @@ // NOTE: Must be run on Pyret stack - function recomputePoints(func, samplePoints, then) { + function recomputePoints(func, xMinValue, xMaxValue, numSamples, then) { + const loss = ADAPTIVE.defaultLoss; + const sampler = new ADAPTIVE.AdaptiveSampler(func, xMinValue, xMaxValue, loss, numSamples); return RUNTIME.safeCall(() => { - return RUNTIME.raw_array_map(RUNTIME.makeFunction((sample) => { - return RUNTIME.execThunk(RUNTIME.makeFunction(() => func.app(sample))); - }), samplePoints); - }, (funcVals) => { + return sampler.runner(); + }, () => { + const dataMap = sampler.data; const dataValues = []; - funcVals.forEach((result, idx) => { - cases(RUNTIME.ffi.isEither, 'Either', result, { - left: (value) => dataValues.push({ - x: toFixnum(samplePoints[idx]), - y: toFixnum(value) - }), - right: () => {} + dataMap.forEach((yVal, xVal) => { + dataValues.push({ + x: toFixnum(xVal), + y: toFixnum(yVal) }) }); return then(dataValues); @@ -2814,8 +2814,6 @@ const xAxisType = globalOptions['x-axis-type']; const yAxisType = globalOptions['y-axis-type']; - const fraction = (xMaxValue - xMinValue) / (numSamples - 1); - const data = [ { name: `${prefix}table` } ]; const signals = [ @@ -2865,9 +2863,7 @@ addCrosshairs(prefix, ['Dots'], signals, marks, pointColor); - const samplePoints = [...Array(numSamples).keys().map((i) => (xMinValue + (fraction * i)))]; - - return recomputePoints(func, samplePoints, (dataValues) => { + return recomputePoints(func, xMinValue, xMaxValue, numSamples, (dataValues) => { data[0].values = dataValues; return { prefix, @@ -2882,14 +2878,13 @@ const numSamples = globalOptions.numSamples; const xMinValue = globalOptions.xMinValue; const xMaxValue = globalOptions.xMaxValue; - const fraction = (xMaxValue - xMinValue) / (numSamples - 1); - const samplePoints = [...Array(numSamples).keys().map((i) => (xMinValue + (fraction * i)))]; + RUNTIME.runThunk(() => { // NOTE(Ben): We can use view.data(`${prefix}rawTable`, ...newData...) // to replace the existing data points in the _current_ view, so that // we do not have to reconstruct a new vega.View or restart the rendering process. // See https://vega.github.io/vega/docs/api/view/#view_data - return recomputePoints(func, samplePoints, (dataValues) => { + return recomputePoints(func, xMinValue, xMaxValue, numSamples, (dataValues) => { view.data(data[0].name, dataValues); }); }, (res) => { diff --git a/lang/src/js/trove/prototype.js b/lang/src/js/trove/prototype.js new file mode 100644 index 000000000..09291c1cf --- /dev/null +++ b/lang/src/js/trove/prototype.js @@ -0,0 +1,132 @@ +/** + * Euclidean distance between two points + * @param {[number, number]} xs - a pair of x-values + * @param {[number, number]} ys - a corresponding pair of y-values + * @returns {number} - the loss for the interval + */ + function defaultLoss(xs, ys) { + const dx = xs[1] - xs[0]; + const dy = ys[1] - ys[0]; + return Math.hypot(dx, dy); +} + +/** + * Distance between two x-values + * @param {[number, number]} xs - a pair of x-values + * @param {[number, number]} ys - unused (only passed in for consistency with other loss functions) + * @returns {number} - the loss for the interval + */ +export function uniformLoss(xs, ys) { + return xs[1] - xs[0]; +} + +/** + * Penalizes y-values close to 0 + * @param {[number, number]} xs - a pair of x-values + * @param {[number, number]} ys - a corresponding pair of y-values + * @returns {number} - the loss for the interval + */ +export function absMinLogLoss(xs, ys) { + // bound the transformed y-value in case y = 0 + const lowerBound = -1e12; + const ysLog = ys.map(y => Math.max(lowerBound, Math.log(Math.abs(y)))); + return defaultLoss(xs, ysLog); +} + +/** + * Flow: + * 1. Initialize data + * 2. Compute losses + * 3. Identify interval with max loss + * 4. Split interval in half + * 5. Repeat steps 2-4 until stopping condition + * @param {Function} func - function to plot + * @param {number} xMinValue - min x-value + * @param {number} xMaxValue - max x-value + * @param {Function} lossFunction - loss function + * @param {number} numSamples - max number of data points to sample + */ +export function AdaptiveSampler(func, xMinValue, xMaxValue, lossFunction, numSamples) { + this.lossManager = []; + this.data = new Map(); + this.func = func; + this.xMinValue = xMinValue; + this.xMaxValue = xMaxValue; + this.lossFunction = lossFunction; + this.numSamples = numSamples; + this.pending = []; + + // initialize data by uniformly computing f(x) across the domain + this.initData = function() { + for (let i = xMinValue; i <= xMaxValue; i++) { + this.data.set(i, this.func(i)); + if (i < xMaxValue) { + this.pending.push([i, i + 1]); + } + } + }; + + // compute loss for each interval in pending + this.computeLosses = function() { + while (this.pending.length > 0) { + const xs = this.pending.pop(); + const ys = [this.data.get(xs[0]), this.data.get(xs[1])] + const loss = this.lossFunction(xs, ys) + this.lossManager.push([...xs, loss]) + } + }; + + // get the interval with the max loss + // TODO: handling multiple intervals with the same max loss (particularly important for uniform loss) + this.getMaxLoss = function() { + let maxLoss = -Infinity; + let maxInterval = null; + let maxIndex = null; + + for (let i = 0; i < this.lossManager.length; i++) { + const item = this.lossManager[i]; + if (item[2] > maxLoss) { + maxLoss = item[2]; + maxInterval = item.slice(0, 2); + maxIndex = i; + } + } + return { maxInterval, maxIndex }; + }; + + // split an interval in half, compute y-value of the midpoint, and add new intervals to pending + this.splitInterval = function(maxInterval, maxIndex) { + const [l, r] = maxInterval; + const m = (l + r) / 2; + this.data.set(m, this.func(m)); + this.lossManager.splice(maxIndex, 1); + this.pending.push([l, m], [m, r]); + }; + + // runs the adaptive sampler + // TODO: adding different stopping conditions + this.runner = function() { + this.initData(); + this.computeLosses(); + while (this.data.size < this.numSamples) { + const maxLoss = this.getMaxLoss() + this.splitInterval(maxLoss.maxInterval, maxLoss.maxIndex) + this.computeLosses() + } + }; +} + +// testing +const learner = new AdaptiveSampler(x => 1 / x, -5, 5, absMinLogLoss, 100); +// learner.initData(); +// learner.computeLosses(); +// console.log(learner.data, learner.pending, learner.lossManager); +// const result = learner.getMaxLoss(); +// learner.splitInterval(result.maxInterval, result.maxIndex); +learner.runner() +console.log(learner.data); + +const map = new Map(); +map.set(1, 1); +console.log(map.get(1)); +console.log(map.get(2)) \ No newline at end of file diff --git a/lang/src/types.d.ts b/lang/src/types.d.ts index dfe98eac6..953ec82e9 100644 --- a/lang/src/types.d.ts +++ b/lang/src/types.d.ts @@ -224,7 +224,7 @@ declare namespace ABI { type __ErrorExt = { pyretStack?: string[]; exn?: val; - } + } & PyretObject; class FailureResult { exn: Error & __ErrorExt; @@ -244,6 +244,8 @@ type PyretMethod = val & ABI.PMethod; type PyretTuple = val & ABI.PTuple; +type PyretEither = val & { $name: "left" | "right" }; + // TODO: type RunOptions = { sync: boolean; @@ -267,6 +269,7 @@ interface PyretRuntime { options?: RunOptions, ): void; safeCall(fun: () => T, after: (result: T) => U, stackFrame: string): U; + execThunk(thunk: PyretFunction): PyretEither; ffi: PyretFFI; @@ -313,7 +316,7 @@ interface PyretRuntime { makeNumberFromString(s: string): val; makeBoolean(b: boolean): PyretBoolean; makeString(s: string): PyretString; - makeFunction(fun: func, name: string): PyretFunction; + makeFunction(fun: func, name?: string): PyretFunction; makeMethod(meth: func, full_meth: func, name: string): PyretMethod; // ... makeTuple(tup: val[]): PyretTuple; @@ -363,7 +366,7 @@ interface PyretRuntime { checkOpaque(v: val): void; checkPyretVal(v: unknown): void; - nothing: val; + nothing: PyretNothing; toRepr(v: val): val; makeSrcloc(srcloc: SrcLocJs): val; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..bf6a0578f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "pyret", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}